0?'ok':'warn');
return {ok:ok, fail:fail};
};
// ────────────── AI TEST (multi provider) ──────────────
async function waCallAi(messages, opts){
opts = opts || {};
var cfg = window._waConfig;
var provider = opts.provider || cfg.ai_provider || 'claude';
var systemPrompt = opts.systemPrompt || _waBuildSystemPrompt();
try{
if(provider === 'claude'){
if(!cfg.claude_key) return {ok:false, err:'Claude API key belum di-set'};
var resp = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': cfg.claude_key,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
'content-type': 'application/json'
},
body: JSON.stringify({
model: cfg.claude_model || 'claude-haiku-4-5',
max_tokens: cfg.ai_max_tokens || 600,
temperature: cfg.ai_temperature || 0.7,
system: systemPrompt,
messages: messages
})
});
var data = await resp.json();
if(data.error) return {ok:false, err: data.error.message||'API error'};
var text = (data.content && data.content[0] && data.content[0].text) || '';
return {ok:true, text:text, usage:data.usage};
}
else if(provider === 'openai'){
if(!cfg.openai_key) return {ok:false, err:'OpenAI API key belum di-set'};
var msgs = [{role:'system', content:systemPrompt}].concat(messages);
var resp = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {'Authorization':'Bearer '+cfg.openai_key, 'content-type':'application/json'},
body: JSON.stringify({
model: cfg.openai_model || 'gpt-4o-mini',
max_tokens: cfg.ai_max_tokens || 600,
temperature: cfg.ai_temperature || 0.7,
messages: msgs
})
});
var data = await resp.json();
if(data.error) return {ok:false, err: data.error.message||'API error'};
var text = data.choices?.[0]?.message?.content || '';
return {ok:true, text:text, usage:data.usage};
}
else if(provider === 'gemini'){
if(!cfg.gemini_key) return {ok:false, err:'Gemini API key belum di-set'};
var contents = messages.map(function(m){return {role: m.role==='assistant'?'model':'user', parts:[{text:m.content}]};});
var resp = await fetch('https://generativelanguage.googleapis.com/v1beta/models/'+(cfg.gemini_model||'gemini-2.0-flash')+':generateContent?key='+cfg.gemini_key, {
method:'POST',
headers:{'content-type':'application/json'},
body: JSON.stringify({
systemInstruction: {parts:[{text:systemPrompt}]},
generationConfig: {temperature: cfg.ai_temperature||0.7, maxOutputTokens: cfg.ai_max_tokens||600},
contents: contents
})
});
var data = await resp.json();
if(data.error) return {ok:false, err:data.error.message};
var text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
return {ok:true, text:text, usage:data.usageMetadata};
}
return {ok:false, err:'Provider tidak dikenal'};
}catch(e){ return {ok:false, err:e.message||String(e)}; }
}
window.waCallAi = waCallAi;
// ────────────── BUILD SYSTEM PROMPT (dari persona + KB) ──────────────
function _waBuildSystemPrompt(){
var c = window._waConfig;
var styleDesc = {formal:'formal dan profesional', friendly:'ramah, hangat, sopan', santai:'santai dan akrab seperti teman'};
var langDesc = {id:'bahasa Indonesia', en:'English', mix:'bahasa Indonesia (boleh campur English bila perlu)'};
var emojiInstr = c.bot_use_emoji ? 'Boleh pakai emoji secukupnya untuk menghangatkan percakapan.' : 'JANGAN pakai emoji.';
var p = '# IDENTITAS\nAnda adalah "'+c.bot_name+'", asisten WhatsApp resmi BLP (Bintang Lima Plastindo).\n\n';
p += '# GAYA BICARA\nJawab dengan gaya '+(styleDesc[c.bot_style]||'ramah')+' dalam '+(langDesc[c.bot_lang]||'bahasa Indonesia')+'.\n'+emojiInstr+'\nJawaban singkat-padat, maksimal 3 paragraf pendek.\nTutup pesan dengan tanda tangan: "'+c.bot_signature+'"\n\n';
if(c.kb_company_profile) p += '# COMPANY PROFILE\n'+c.kb_company_profile+'\n\n';
if(c.kb_general_info) p += '# INFO UMUM\n'+c.kb_general_info+'\n\n';
// Knowledge Base Q&A
if(c.kb_enabled && window._waKB && window._waKB.length){
p += '# KNOWLEDGE BASE (Q&A)\nGunakan ini sebagai referensi utama:\n';
window._waKB.filter(function(k){return k.active!==false;}).forEach(function(k,i){
p += '\nQ: '+k.question+'\nA: '+k.answer+'\n';
});
p += '\n';
}
// Dokumen tambahan
if(window._waKBDocs && window._waKBDocs.length){
p += '# DOKUMEN REFERENSI\n';
window._waKBDocs.filter(function(d){return d.active!==false;}).slice(0,5).forEach(function(d){
p += '\n## '+d.title+'\n'+(d.content||'').slice(0,1500)+'\n';
});
p += '\n';
}
// Produk dari DB_BARANG
if(c.kb_use_products && window.DB_BARANG && DB_BARANG.length){
p += '# DAFTAR PRODUK (excerpt, '+Math.min(30,DB_BARANG.length)+' dari '+DB_BARANG.length+')\n';
DB_BARANG.slice(0,30).forEach(function(b){
p += '- '+b.nama+(b.kelas?' ('+b.kelas+')':'')+'\n';
});
p += '\n';
}
// Handover instruction
p += '# KAPAN ESCALATE KE MARKETING\n';
p += 'JIKA customer menanyakan/menyatakan:\n';
p += '- Harga deal spesifik atau quotation tertulis\n';
p += '- Mau order/pesan/beli sekarang\n';
p += '- Butuh PO atau dokumen formal\n';
p += '- Pertanyaan teknis sangat spesifik (warna, dimensi custom, finishing)\n';
p += 'MAKA jawab dengan: "ESCALATE: [alasan singkat]" di baris pertama, lalu kasih intro 1-2 kalimat ke customer.\n';
p += 'Jika hanya tanya umum/info ringan/konsultasi awal, jawab langsung TANPA escalate.\n\n';
// v1.10.171: INFO GATHERING — supaya AI bot proaktif tanya data customer untuk profiling
p += '# INFO GATHERING (TANYA SAMBIL JALAN, JANGAN INTEROGASI)\n';
p += 'Selama percakapan, kumpulkan info berikut secara natural dan bertahap (jangan tanya semua sekaligus):\n';
p += '1. NAMA customer — kalau belum disebut, sapa: "Boleh tahu nama kakak siapa?"\n';
p += '2. PERUSAHAAN/USAHA — tanya kalau relevan: "Untuk usaha apa kak?" atau "Dari perusahaan mana?"\n';
p += '3. KOTA/LOKASI — biasanya untuk hitung ongkir: "Lokasi pengiriman di kota mana?"\n';
p += '4. PRODUK MINAT — apa yang dia butuhkan (jenis kemasan, ukuran, material).\n';
p += '5. QTY ESTIMASI — berapa banyak (pcs/lembar/ton/kg).\n';
p += '6. TAHU BLP DARI MANA — di awal/tengah percakapan, tanya dengan ramah: "Oh ya, boleh tahu kakak dapat info BLP dari mana ya? Instagram, Google, atau ada yang rekomendasikan?" — INFO INI PENTING untuk analytics marketing BLP.\n';
p += 'ATURAN: Maksimal 1-2 pertanyaan per giliran. Jangan tanya info yang sudah customer sebutkan sebelumnya. Tetap fokus jawab pertanyaan customer dulu, baru sisipkan info gathering.\n\n';
if(c.autoreply_only_business){
p += '# BATASAN\nHanya bahas topik bisnis BLP. Jika ditanya hal di luar (politik, hiburan, dll), arahkan sopan kembali ke topik.\n\n';
}
return p;
}
window.waBuildSystemPrompt = _waBuildSystemPrompt;
window.waUiTestAi = async function(){
var prompt = document.getElementById('wa_ai_test_in')?.value;
var out = document.getElementById('wa_ai_test_out');
if(!prompt){ alert('Isi pesan test dulu'); return; }
if(out) out.innerHTML = ' Memanggil AI...
';
var res = await waCallAi([{role:'user', content:prompt}]);
if(!out) return;
if(res.ok){
var isEsc = res.text.startsWith('ESCALATE:');
out.innerHTML = ''
+ (isEsc?'⚠️ AI memutuskan escalate \n\n':'✅ AI auto-reply \n\n')
+ _wa_escHtml(res.text) + '
'
+ 'Provider: '+(window._waConfig.ai_provider)+' • Tokens in/out: '+
(res.usage?.input_tokens || res.usage?.prompt_tokens || res.usage?.promptTokenCount || '?')+'/'+
(res.usage?.output_tokens || res.usage?.completion_tokens || res.usage?.candidatesTokenCount || '?')+'
';
} else {
out.innerHTML = '❌ '+_wa_escHtml(res.err)+'
';
}
};
// ────────────── MAIN SCREEN ──────────────
function waBotHubScreen(){
if(!_wa_hasAccess()){
var dbgRole = String(((typeof CU!=='undefined' && CU && CU.role)) || '(kosong)');
var dbgName = String(((typeof CU!=='undefined' && CU && CU.name)) || '(kosong)');
return ''
+ '
'
+ '
Khusus Owner, Admin, & Marketing.
'
+ '
'
+ 'Debug info: Role: '+dbgRole+' Name: '+dbgName+''
+ ' Screenshot ini ke Claude kalau Anda Owner tapi tetap muncul pesan ini.
'
+ '
';
}
var c = window._waConfig;
var fullAccess = _wa_canFull();
var isHeadMkt = (typeof _wa_isHeadMarketing==='function') && _wa_isHeadMarketing();
var tab = window._waTab || (fullAccess ? (isHeadMkt ? 'inbox' : 'dashboard') : 'send');
// Tab list — restructured ke 4 grup biar tidak overload
// Owner/Admin: full (Utama, Setup, Pesan, Kontak & Segment)
// Head Marketing (Andrean): cuma Pesan + Kontak & Segment (no Setup/Dashboard/Log/Super Admin)
// Marketing biasa: limited (Kirim, Inbox, Log)
var tabGroups;
if(fullAccess && isHeadMkt){
tabGroups = [
{label:'Pesan', items:[
['send','ti-send','Kirim'],
['broadcast','ti-speakerphone','Broadcast'],
['inbox','ti-messages','Inbox'],
['templates','ti-template','Template']
]},
{label:'Kontak & Segment', items:[
['contacts','ti-address-book','Kontak'],
['segments','ti-target','Segment'],
['queue','ti-clock-hour-4','Jadwal']
]}
];
} else if(fullAccess){
tabGroups = [
{label:'Utama', items:[
['dashboard','ti-layout-dashboard','Dashboard'],
['log','ti-history','Log']
]},
{label:'Setup', items:[
['connection','ti-plug-connected','Koneksi'],
['ai','ti-robot','AI Engine'],
['knowledge','ti-book-2','Knowledge'],
['persona','ti-id-badge-2','Persona'],
['automation','ti-bolt','Otomasi'],
['super_admin','ti-crown','Super Admin']
]},
{label:'Pesan', items:[
['send','ti-send','Kirim'],
['broadcast','ti-speakerphone','Broadcast'],
['inbox','ti-messages','Inbox'],
['templates','ti-template','Template']
]},
{label:'Kontak & Segment', items:[
['contacts','ti-address-book','Kontak'],
['segments','ti-target','Segment'],
['queue','ti-clock-hour-4','Jadwal']
]}
];
} else {
tabGroups = [{label:'', items:[['send','ti-send','Kirim'],['inbox','ti-messages','Inbox'],['log','ti-history','Log']]}];
}
// Flatten untuk routing compatibility
var tabs = [];
tabGroups.forEach(function(g){ g.items.forEach(function(t){ tabs.push(t); }); });
var tabBar = '';
if(fullAccess){
// Layout berkelompok: section label + sub-pills
tabBar += '';
tabGroups.forEach(function(g, gi){
if(g.label){
tabBar += '
'+g.label+'
';
}
tabBar += '
';
g.items.forEach(function(t){
var active = tab===t[0];
tabBar += ' '+t[2]+' ';
});
tabBar += '
';
});
tabBar += '
';
} else {
// Customer-facing: simple flat
tabBar = '';
tabs.forEach(function(t){
var active = tab===t[0];
tabBar += ' '+t[2]+' ';
});
tabBar += '
';
}
// Health badges
var providerReady = (c.ai_provider==='claude' && c.claude_key) || (c.ai_provider==='openai' && c.openai_key) || (c.ai_provider==='gemini' && c.gemini_key);
var headerBadges = '';
headerBadges += _waBadge(c.enabled, 'Bot');
headerBadges += _waBadge(!!c.token, 'WA');
headerBadges += _waBadge(c.ai_enabled && providerReady, 'AI');
headerBadges += _waBadge((window._waKB||[]).length>0 || (c.kb_company_profile||'').length>30, 'KB');
var header = `
${c.bot_name||'WA Bot Hub'}
${fullAccess?'Owner Control Panel — Setup mandiri':'Mode Marketing'}
${headerBadges}
`;
var body = '';
if(tab === 'dashboard' && fullAccess) body = _waTabDashboard(c);
else if(tab === 'connection' && fullAccess) body = _waTabConnection(c);
else if(tab === 'ai' && fullAccess) body = _waTabAi(c);
else if(tab === 'knowledge' && fullAccess) body = _waTabKnowledge(c);
else if(tab === 'persona' && fullAccess) body = _waTabPersona(c);
else if(tab === 'automation' && fullAccess) body = _waTabAutomation(c);
else if(tab === 'send') body = _waTabSend(c, fullAccess);
else if(tab === 'broadcast' && fullAccess) body = _waTabBroadcast(c);
else if(tab === 'inbox') body = _waTabInbox(c);
else if(tab === 'templates' && fullAccess) body = _waTabTemplates(c);
else if(tab === 'contacts' && fullAccess) body = _waTabContacts(c);
else if(tab === 'segments' && fullAccess) body = _waTabSegments(c);
else if(tab === 'queue' && fullAccess) body = _waTabQueue(c);
else if(tab === 'log') body = _waTabLog();
setTimeout(function(){
if(tab==='log') waLogRefresh();
if(tab==='send') waMktRenderList();
if(tab==='knowledge') waKbRender();
if(tab==='inbox') waInboxRender();
if(tab==='dashboard') waDashRender();
if(tab==='templates') waTplRender();
if(tab==='contacts') waContactRender();
if(tab==='segments') waSegmentRender();
if(tab==='queue') waQueueRender();
if(tab==='broadcast' && typeof _waBcV2RenderList==='function') _waBcV2RenderList(); // v1.10.44
}, 80);
return header + tabBar + body;
}
window.waBotHubScreen = waBotHubScreen;
// Auto-trigger config load saat user buka tab WA Bot kalau belum loaded.
// Penting untuk skenario: user buka app → langsung navigate ke WA Bot sebelum 1.5s init delay
var _origWaBotHubScreen = waBotHubScreen;
window.waBotHubScreen = function(){
// Kalau config belum loaded & gak ada retry yang sedang berjalan, trigger sekarang
if(!window._waConfigLoaded && !window._waConfigRetryInProgress && typeof blpSupabase !== 'undefined' && blpSupabase.online){
setTimeout(function(){ waConfigLoadWithRetry(); }, 100);
}
return _origWaBotHubScreen.apply(this, arguments);
};
function _waBadge(active, label){
var bg = active ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.2)';
var icon = active ? '✓' : '○';
return ''+icon+' '+label+'
';
}
window.waSwitchTab = function(tab){
window._waTab = tab;
if(typeof goto==='function') goto('wabot');
else if(typeof render==='function') render('wabot');
};
// ────────────── TAB: DASHBOARD ──────────────
function _waTabDashboard(c){
var loadingBanner = '';
if(!window._waConfigLoaded){
var attempts = window._waConfigLoadAttempts || 0;
loadingBanner = ''
+ '
Memuat config dari Supabase…'+(attempts>0?' (percobaan ke-'+(attempts+1)+')':'')+'
'
+ '
Muat Ulang Sekarang'
+ '
';
}
return loadingBanner + `
🚀 Quick Actions
AI Engine
Knowledge
Broadcast
Reminder
`;
}
window.waDashRender = async function(){
try{
var c = window._waConfig;
// Stats dari log
if(typeof blpSupabase !== 'undefined' && blpSupabase.online){
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(client){
var today = new Date().toISOString().slice(0,10);
var weekAgo = new Date(Date.now()-7*86400000).toISOString();
var r1 = await client.from('blp_wa_log').select('id', {count:'exact', head:true}).gte('created_at', today);
var r2 = await client.from('blp_wa_log').select('id', {count:'exact', head:true}).eq('kind','ai_reply').gte('created_at', weekAgo);
var r3 = await client.from('blp_wa_log').select('id', {count:'exact', head:true}).eq('kind','handover').gte('created_at', weekAgo);
var r4 = await client.from('blp_wa_log').select('id', {count:'exact', head:true}).eq('success', true);
var setText = function(id,v){var el=document.getElementById(id); if(el) el.textContent=v;};
setText('dash_chats_today', r1.count||0);
setText('dash_ai_replies', r2.count||0);
setText('dash_handovers', r3.count||0);
setText('dash_sent_total', r4.count||0);
}
}
}catch(e){ console.warn('[WA] dash err:', e); }
};
// ────────────── TAB: CONNECTION ──────────────
function _waTabConnection(c){
return `
📋 Setup Fonnte (Tutorial Cepat)
Daftar di fonnte.com
Menu Devices → Add Device → pilih paket
Klik device → muncul QR Code
WhatsApp di HP BLP → Setting → Linked Devices → Scan QR
Copy Token di halaman device
Paste ke field di atas, centang Aktif, klik Simpan
Klik Test Kirim untuk verifikasi
🔗 Webhook URL (untuk AI Auto-Reply)
Paste URL ini di Fonnte → Devices → Webhook URL
https://iiwtbefswcomtqrsanld.supabase.co/functions/v1/wa-webhook
Copy URL Webhook
`;
}
// ────────────── TAB: AI ENGINE ──────────────
function _waTabAi(c){
var prov = c.ai_provider || 'claude';
return `
Pilih AI Provider
${_waProvBtn('claude','Claude','Anthropic',prov)}
${_waProvBtn('openai','OpenAI','GPT-4o',prov)}
${_waProvBtn('gemini','Gemini','Google',prov)}
AI System Prompt
Apply Template N8N
Prompt ini dikirim ke AI di setiap chat. Edit bebas untuk sesuaikan tone, knowledge base, dll. Pastikan klik Simpan AI Setting setelah edit.
Simpan AI Setting
🧪 Test AI Response
Test ke ${prov.toUpperCase()}
💰 Estimasi Biaya per 1.000 chat
• Claude Haiku 4.5 : ~Rp 15.000
• Claude Sonnet 4.6 : ~Rp 60.000
• GPT-4o mini : ~Rp 8.000
• GPT-4o : ~Rp 75.000
• Gemini 2.0 Flash : GRATIS (sampai 1500/hari)
• Gemini 1.5 Pro : ~Rp 25.000
`;
}
function _waProvBtn(id, name, sub, active){
var isAct = active===id;
return ''
+ ''+name+'
'
+ ''+sub+'
'
+ ' ';
}
window.waSwitchProv = function(p){
window._waConfig.ai_provider = p;
['claude','openai','gemini'].forEach(function(x){
var el = document.getElementById('wa_prov_'+x);
if(el) el.style.display = (x===p)?'block':'none';
});
if(typeof goto==='function') goto('wabot');
};
// ────────────── TAB: KNOWLEDGE BASE ──────────────
function _waTabKnowledge(c){
return `
🏢 Profil Perusahaan
Background BLP, sejak kapan, melayani siapa, dll. AI pakai info ini untuk perkenalan.
ℹ️ Info Umum / FAQ Singkat
Info yang sering ditanya customer (1 line per topic). Akan tersedia untuk AI.
❓ Q&A Pairs (0 )
Tambah Q&A
📄 Dokumen Pengetahuan (0 )
Tambah Dokumen
📦 Auto-sync Produk
Sertakan daftar produk dari DB Barang
${(window.DB_BARANG||[]).length} item produk akan tersedia untuk AI
SIMPAN KNOWLEDGE BASE `;
}
window.waKbShowForm = function(){
document.getElementById('wa_kb_form').style.display = 'block';
document.getElementById('wa_kb_q').focus();
};
window.waKbSubmit = async function(){
var cat = document.getElementById('wa_kb_cat')?.value || 'umum';
var q = document.getElementById('wa_kb_q')?.value;
var a = document.getElementById('wa_kb_a')?.value;
if(!q || !a){ alert('Pertanyaan & jawaban wajib diisi'); return; }
var res = await waKbAdd(cat, q, a);
if(res.ok){
_wa_toast('Q&A ditambahkan','ok');
document.getElementById('wa_kb_form').style.display='none';
['wa_kb_cat','wa_kb_q','wa_kb_a'].forEach(function(id){var el=document.getElementById(id); if(el)el.value='';});
waKbRender();
} else {
alert('❌ '+res.err);
}
};
window.waKbEdit = async function(id){
var k = window._waKB.find(function(x){return x.id===id;});
if(!k) return;
var newQ = prompt('Pertanyaan:', k.question);
if(newQ===null) return;
var newA = prompt('Jawaban:', k.answer);
if(newA===null) return;
var res = await waKbUpdate(id, {question:newQ, answer:newA});
if(res.ok){ _wa_toast('Diupdate','ok'); waKbRender(); }
else alert('❌ '+res.err);
};
window.waKbDel = async function(id){
if(!confirm('Hapus Q&A ini?')) return;
var res = await waKbDelete(id);
if(res.ok){ _wa_toast('Dihapus','ok'); waKbRender(); }
else alert('❌ '+res.err);
};
window.waKbRender = function(){
var list = window._waKB || [];
var box = document.getElementById('wa_kb_list');
var count = document.getElementById('wa_kb_count');
if(count) count.textContent = list.length;
if(!box) return;
if(!list.length){
box.innerHTML = 'Belum ada Q&A. Tambah pertanyaan-jawaban yang sering ditanya customer.
';
} else {
var html = '';
list.forEach(function(k){
html += ''
+ '
'
+ '
'+_wa_escHtml(k.category||'umum')+' '
+ '
'
+ ' '
+ ' '
+ '
'
+ '
'
+ '
Q: '+_wa_escHtml(k.question)+'
'
+ '
A: '+_wa_escHtml(k.answer)+'
'
+ '
';
});
box.innerHTML = html;
}
// Render docs
var docs = window._waKBDocs || [];
var dbox = document.getElementById('wa_kb_doc_list');
var dcount = document.getElementById('wa_kb_doc_count');
if(dcount) dcount.textContent = docs.length;
if(dbox){
if(!docs.length){
dbox.innerHTML = 'Belum ada dokumen.
';
} else {
var dhtml = '';
docs.forEach(function(d){
var prev = (d.content||'').slice(0,100);
dhtml += ''
+ '
'
+ '
'+_wa_escHtml(d.title)+'
'
+ '
'
+ '
'
+ '
'+_wa_escHtml(prev)+(d.content && d.content.length>100?'...':'')+'
'
+ '
';
});
dbox.innerHTML = dhtml;
}
}
};
window.waKbDocShowForm = function(){
document.getElementById('wa_kb_doc_form').style.display='block';
document.getElementById('wa_kb_doc_title').focus();
};
window.waKbDocSubmit = async function(){
var t = document.getElementById('wa_kb_doc_title')?.value;
var c = document.getElementById('wa_kb_doc_content')?.value;
if(!t || !c){ alert('Judul & isi wajib'); return; }
var res = await waKbDocAdd(t, c);
if(res.ok){
_wa_toast('Dokumen ditambahkan','ok');
document.getElementById('wa_kb_doc_form').style.display='none';
['wa_kb_doc_title','wa_kb_doc_content'].forEach(function(id){var el=document.getElementById(id); if(el)el.value='';});
waKbRender();
} else alert('❌ '+res.err);
};
window.waKbDocDel = async function(id){
if(!confirm('Hapus dokumen?')) return;
var res = await waKbDocDelete(id);
if(res.ok){ _wa_toast('Dihapus','ok'); waKbRender(); }
else alert('❌ '+res.err);
};
// ────────────── TAB: PERSONA ──────────────
function _waTabPersona(c){
return `
Pesan di luar jam kerja
Vars: {wh_start} {wh_end}
Pesan pertama saat customer baru chat (sebelum AI ambil alih)
SIMPAN PERSONA `;
}
function _waPersonaBtn(id, name, emoji, active){
var isAct = active===id;
return ''+emoji+'
'+name+'
';
}
window.waPersonaSet = function(s){
window._waConfig.bot_style = s;
['formal','friendly','santai'].forEach(function(x){
var btn = document.getElementById('wa_pbtn_'+x);
if(!btn) return;
var isAct = x===s;
btn.style.borderColor = isAct ? '#25D366' : 'var(--border)';
btn.style.background = isAct ? 'var(--greenl)' : 'var(--card)';
});
};
// ────────────── TAB: AUTOMATION (Reminder, Notif, Handover) ──────────────
function _waTabAutomation(c){
var days = (c.reminder_days||[]).join(', ');
var keywords = (c.handover_keywords||[]).join(', ');
return `
📅 Auto-Reminder Tagihan
Hari kirim reminder
Positif=sebelum, 0=hari H, negatif=lewat. Contoh: "3, 1, 0, -3"
Vars: {nama} {invno} {amt} {tgl} {status_jt}
Test Run Sekarang
⚡ Auto-Reminder Lengkap (Phase 4)
Generate reminder otomatis untuk Piutang + Job Deadline + CRM Idle. Hasil masuk antrian WA (blp_wa_queue) dan dikirim oleh Edge Function 24/7.
Aktifkan Auto-Reminder
Master switch — semua reminder bawahnya ikut
🔔 Push Notif ke Owner
Ringkasan reminder yang ter-generate
Generate Reminder Hari Ini
Tombol ini scan piutang/job/CRM → masuk antrian. Untuk auto 24/7, setup cron job (lihat README).
📎 Kirim Dokumen via WA — Mode
Pilih cara dokumen PDF dikirim ke customer/supplier. Ganti ke Attachment hanya jika paket Fonnte Anda Super/Advanced/Ultra (support kirim file).
🔗
Link Download
Upload ke Supabase Storage, kirim link.Untuk semua paket Fonnte.
📎
Attachment Langsung
PDF langsung sebagai file di WA.Butuh paket Super/Advanced/Ultra.
💡 Tips: Mulai dengan Link Download (gratis, kompatibel semua paket). Begitu Anda upgrade ke paket Fonnte premium (Rp 165k/bln, support attachment), tinggal ganti ke mode Attachment — tidak perlu konfigurasi tambahan.
SIMPAN OTOMASI `;
}
// ────────────── TAB: SEND ──────────────
function _waTabSend(c, fullAccess){
return `
`;
}
function _wa_myCustomers(){
if(_wa_canFull()) return window.DB_CUSTOMER||[];
if(!_wa_canMarketing()) return [];
var myName = _wa_normLc((typeof CU!=='undefined' && CU && CU.name));
return (window.DB_CUSTOMER||[]).filter(function(c){
return _wa_normLc(c.mkt) === myName;
});
}
window.waMktRenderList = function(){
var box = document.getElementById('wa_mkt_custlist');
if(!box) return;
var q = _wa_normLc(document.getElementById('wa_send_search')?.value);
var list = _wa_canFull() ? (window.DB_CUSTOMER||[]) : _wa_myCustomers();
if(q) list = list.filter(function(c){
return _wa_normLc(c.nama).includes(q) || _wa_normLc(c.perusahaan).includes(q) || _wa_normLc(c.kota).includes(q);
});
if(!list.length){ box.innerHTML = 'Tidak ada customer.
'; return; }
var html = '';
list.slice(0, 80).forEach(function(c){
var hp = c.hp || '';
var allHps = (typeof custGetAllHPs === 'function') ? custGetAllHPs(c) : (hp ? [{hp:hp}] : []);
var disabled = allHps.length === 0;
var btnStyle = disabled ? 'background:var(--border);color:var(--muted);cursor:not-allowed' : 'background:#25D366;color:#fff;cursor:pointer';
var hpInfo = disabled ? 'HP kosong' : (hp || allHps[0].hp);
var multiBadge = allHps.length > 1 ? ' +'+(allHps.length-1)+' PIC ' : '';
html += ''
+ '
'
+ '
'+_wa_escHtml(c.nama||'-')+multiBadge+'
'
+ '
'+_wa_escHtml(c.perusahaan||c.kota||'')+' • '+hpInfo+'
'
+ '
'
+ '
'
+ '
';
});
if(list.length > 80) html += '… '+(list.length-80)+' lainnya
';
box.innerHTML = html;
};
window.waMktQuickSend = async function(custId){
var cust = (window.DB_CUSTOMER||[]).find(function(c){return c.id===custId;});
if(!cust){ alert('Customer tidak ditemukan'); return; }
if(!_wa_isMyCust(cust)){ alert('Bukan customer Anda.'); return; }
// Cek semua HP yang ada (utama + multi-kontak)
var allHps = (typeof custGetAllHPs === 'function') ? custGetAllHPs(cust) : [];
if(allHps.length === 0){ alert('Customer belum punya HP / kontak.'); return; }
// Buka modal proper
_waOpenSendModal(cust, allHps);
};
// Modal proper untuk kirim WA — dengan pilih kontak, pilih template, preview
function _waOpenSendModal(cust, allHps){
// Default: pilih kontak primary (atau pertama)
var primary = allHps.find(function(h){return h.isPrimary;}) || allHps[0];
window._waSendState = {
custId: cust.id,
selectedHps: [primary.hp],
templateId: '',
body: ''
};
var hpListHtml = allHps.map(function(h){
var checked = h.hp === primary.hp;
return ''
+ ' '
+ ''
+ '
'+(h.nama||'-')+(h.isPrimary?' PRIMARY ':'')+'
'
+ '
'
+ ' '+h.hp+' '
+ (h.role && h.role!=='Utama' ? ''+h.role+' ' : '')
+ '
'
+ '
'
+ ' ';
}).join('');
// Templates (filter exclude quick_reply karena itu khusus inbox)
var allTemplates = (window._waTemplates||[]).filter(function(t){return t.aktif && t.kategori !== 'quick_reply';});
var tplOpts = '— Tanpa Template (tulis manual) — '
+ allTemplates.map(function(t){
return ''+t.nama+' ('+t.kategori+')'+(t.shortcut?' /'+t.shortcut:'')+' ';
}).join('');
var modal = document.createElement('div');
modal.id = '_waSendModal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:99999;padding:14px';
modal.innerHTML = ''
+ '
'
+ '
'
+ '
Kirim WA ke
'
+ '
'+(cust.nama||'-')+'
'
+ (cust.perusahaan ? '
'+cust.perusahaan+'
' : '')
+ '
'
+ '
'
+ '
'
+ '
'
+ '
📞 Pilih Kontak ('+allHps.length+')
'
+ '
'+hpListHtml+'
'
+ '
📝 Template
'
+ '
'+tplOpts+' '
+ '
'
+ 'Variabel: {nama} {perusahaan} {tagihan} {marketing} '
+ '
'
+ '
'
+ '
'
+ '
👁️ Preview (variabel ke-replace)
'
+ '
'
+ '
'
+ '
'
+ '
'
+ ' '
+ '⏰ Jadwalkan kirim nanti '
+ ' '
+ '
'
+ '
'
+ '
Pesan akan masuk ke Jadwal. Buka tab Jadwal → "Proses Sekarang" untuk eksekusi.
'
+ '
'
+ '
'
+ '
'
+ 'Batal '
+ ' Kirim Sekarang '
+ '
'
+ '
'
+ '
';
document.body.appendChild(modal);
modal.onclick = function(e){ if(e.target===modal) _waCloseSendModal(); };
}
window._waCloseSendModal = function(){
var m = document.getElementById('_waSendModal');
if(m) m.remove();
window._waSendState = null;
};
window._waSendToggleHp = function(checkbox){
if(!window._waSendState) return;
var hp = checkbox.value;
var idx = window._waSendState.selectedHps.indexOf(hp);
if(checkbox.checked && idx < 0) window._waSendState.selectedHps.push(hp);
if(!checkbox.checked && idx >= 0) window._waSendState.selectedHps.splice(idx, 1);
// Update visual style of label
var label = checkbox.parentElement;
if(label){
label.style.borderColor = checkbox.checked ? '#0369A1' : '#E5E7EB';
label.style.background = checkbox.checked ? '#F0F9FF' : '#fff';
}
};
window._waSendApplyTemplate = function(templateId){
if(!window._waSendState) return;
window._waSendState.templateId = templateId;
if(!templateId){
// Clear body
var ta = document.getElementById('_waSendBody');
if(ta) ta.value = '';
_waSendUpdatePreview();
return;
}
var tpl = (window._waTemplates||[]).find(function(t){return t.id===templateId;});
if(!tpl) return;
var ta = document.getElementById('_waSendBody');
if(ta){
ta.value = tpl.body || '';
_waSendUpdatePreview();
}
};
window._waInsertVar = function(varName){
var ta = document.getElementById('_waSendBody');
if(!ta) return;
var start = ta.selectionStart;
var end = ta.selectionEnd;
var before = ta.value.substring(0, start);
var after = ta.value.substring(end);
ta.value = before + varName + after;
ta.focus();
ta.selectionStart = ta.selectionEnd = start + varName.length;
_waSendUpdatePreview();
};
window._waSendUpdatePreview = function(){
if(!window._waSendState) return;
var ta = document.getElementById('_waSendBody');
var previewBox = document.getElementById('_waSendPreviewBox');
var preview = document.getElementById('_waSendPreview');
if(!ta || !preview) return;
var body = ta.value;
window._waSendState.body = body;
// Show preview kalau ada variabel
if(/\{\w+\}/.test(body)){
var cust = (window.DB_CUSTOMER||[]).find(function(c){return c.id===window._waSendState.custId;});
var rendered = window._waRenderTemplate(body, cust);
preview.textContent = rendered;
if(previewBox) previewBox.style.display = 'block';
} else {
if(previewBox) previewBox.style.display = 'none';
}
};
window._waSendToggleSchedule = function(checked){
var box = document.getElementById('_waSendScheduleBox');
var btn = document.getElementById('_waSendExecuteBtn');
if(box) box.style.display = checked ? 'block' : 'none';
if(btn){
btn.innerHTML = checked ? ' Jadwalkan' : ' Kirim Sekarang';
btn.style.background = checked ? '#D97706' : '#25D366';
}
// v1.10.41: Default schedule = now + 10 menit (waktu lokal device, bukan UTC).
// Sebelumnya pakai toISOString() yang return UTC → picker tampil minus 7 jam dari WIB.
if(checked){
var time = document.getElementById('_waSendScheduleTime');
if(time && !time.value){
var dt = new Date(Date.now() + 10*60*1000);
// Format YYYY-MM-DDTHH:MM dalam local tz (datetime-local pakai local, bukan UTC)
var pad = function(n){ return n<10?'0'+n:''+n; };
var iso = dt.getFullYear()+'-'+pad(dt.getMonth()+1)+'-'+pad(dt.getDate())
+ 'T'+pad(dt.getHours())+':'+pad(dt.getMinutes());
time.value = iso;
}
}
};
window._waSendExecute = async function(){
var state = window._waSendState;
if(!state){ return; }
if(!state.selectedHps.length){ alert('Pilih minimal 1 kontak.'); return; }
if(!state.body || !state.body.trim()){ alert('Pesan tidak boleh kosong.'); return; }
var cust = (window.DB_CUSTOMER||[]).find(function(c){return c.id===state.custId;});
if(!cust){
// Mungkin dari contact, cari di _waContacts
cust = (window._waContacts||[]).find(function(c){return c.id===state.custId;});
}
if(!cust){ alert('Customer/Kontak tidak ditemukan'); return; }
var renderedBody = window._waRenderTemplate(state.body, cust);
// Cek apakah schedule mode
var isSchedule = document.getElementById('_waSendSchedule')?.checked;
if(isSchedule){
// Validasi waktu
var timeStr = document.getElementById('_waSendScheduleTime')?.value;
if(!timeStr){ alert('Pilih waktu jadwal'); return; }
var schedTime = new Date(timeStr);
if(isNaN(schedTime.getTime())){ alert('Format waktu tidak valid'); return; }
if(schedTime.getTime() < Date.now() + 60000){
if(!confirm('Waktu jadwal kurang dari 1 menit dari sekarang. Lanjutkan?')) return;
}
var hps = state.selectedHps;
if(!confirm('Jadwalkan kirim ke '+hps.length+' kontak pada '+schedTime.toLocaleString('id-ID')+'?')) return;
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){ alert('Offline'); return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
var userId = (blpSupabase.session && blpSupabase.session.user) ? blpSupabase.session.user.id : null;
var rows = hps.map(function(hp, i){
return {
id: 'q_' + Date.now() + '_' + i + '_' + Math.random().toString(36).slice(2,5),
hp: hp,
body: renderedBody,
cust_id: cust.id || null,
scheduled_at: schedTime.toISOString(),
status: 'pending',
kind: 'manual',
created_at: new Date().toISOString(),
created_by: userId
};
});
try{
var res = await client.from('blp_wa_queue').insert(rows);
if(res.error){
if(res.error.code === '42P01'){ alert('⚠️ Tabel blp_wa_queue belum dibuat. Eksekusi SQL #6 dulu.'); return; }
alert('❌ '+res.error.message); return;
}
if(state.templateId && typeof window._waTemplateMarkUsed === 'function') window._waTemplateMarkUsed(state.templateId);
if(typeof audit === 'function') audit('schedule_send', 'wa_queue', 'batch', 'Jadwalkan '+hps.length+' pesan untuk '+schedTime.toLocaleString('id-ID'), {});
_waCloseSendModal();
if(typeof _wa_toast === 'function') _wa_toast('⏰ '+hps.length+' pesan dijadwalkan','ok');
else alert('⏰ '+hps.length+' pesan berhasil dijadwalkan.');
}catch(e){ alert('Error: '+e.message); }
return;
}
// Kirim sekarang (logic existing)
var hps = state.selectedHps;
if(!confirm('Kirim WA ke '+hps.length+' kontak?\n\nPreview:\n'+renderedBody.slice(0,200)+(renderedBody.length>200?'…':''))) return;
if(state.templateId && typeof window._waTemplateMarkUsed === 'function') window._waTemplateMarkUsed(state.templateId);
var btn = document.querySelector('#_waSendModal button[onclick="_waSendExecute()"]');
if(btn){ btn.disabled = true; btn.innerHTML = ' Mengirim...'; }
var okCount = 0, errCount = 0, errMsgs = [];
for(var i=0; i'+_wa_escHtml(m)+'';}).join('');
return `
📣 Broadcast WhatsApp
② Pesan
Vars: {nama} {perusahaan} {kota}
③ Lampiran (opsional, multi-file)
Upload file (gambar / PDF / dokumen)
Max 16MB per file. Format: gambar, PDF, Word, Excel, MP4, MP3.
KIRIM BROADCAST
💡 Tips: Throttle 1 detik antar pesan. Gunakan {nama} biar terasa personal. Multi-attachment: file pertama dikirim bersama teks, sisanya menyusul. Schedule = pakai pg_cron 24/7.
`;
}
// ── State & helpers ──
window._waBcV2GetAllCandidates = function(){
return (window.DB_CUSTOMER||[]).filter(function(c){
return c.hp && String(c.hp).replace(/\D/g,'').length >= 8 && !c.deleted_at;
});
};
window._waBcV2GetFiltered = function(){
var s = window._waBcV2;
var list = _waBcV2GetAllCandidates();
// Apply shortcut filter
switch(s.shortcutFilter){
case 'tier_prioritas': list = list.filter(function(c){return _wa_normLc(c.kelas)==='prioritas';}); break;
case 'tier_retail': list = list.filter(function(c){return _wa_normLc(c.kelas)==='retail';}); break;
case 'by_mkt':
var mkt = _wa_normLc(s.shortcutMkt||'');
if(mkt) list = list.filter(function(c){return _wa_normLc(c.mkt)===mkt;});
break;
case 'with_open_piutang':
var pIds = new Set();
(window.DB_PIUTANG_JOB||[]).forEach(function(p){
if((p.amt||0)>(p.dibayarMigrasi||0)){
var job = (window.JOBS||[]).find(function(j){return j.scno===p.scRef||j.spk===p.scRef;});
if(job && job.custId) pIds.add(job.custId);
}
});
list = list.filter(function(c){return pIds.has(c.id);});
break;
}
// Apply search query
var q = (s.searchQuery||'').toLowerCase().trim();
if(q){
list = list.filter(function(c){
return (c.nama||'').toLowerCase().includes(q)
|| (c.hp||'').includes(q)
|| (c.perusahaan||'').toLowerCase().includes(q)
|| (c.kota||'').toLowerCase().includes(q);
});
}
return list;
};
window._waBcV2RenderList = function(){
var box = document.getElementById('_waBcV2List');
if(!box) return;
var s = window._waBcV2;
var filtered = _waBcV2GetFiltered();
// Update shortcut button highlight
document.querySelectorAll('._waBcV2Btn').forEach(function(b){
var active = b.dataset.filter === s.shortcutFilter;
b.style.background = active ? '#7C3AED' : '#fff';
b.style.color = active ? '#fff' : '#374151';
b.style.borderColor = active ? '#7C3AED' : '#E5E7EB';
});
if(!filtered.length){
box.innerHTML = 'Tidak ada kontak yang cocok.
';
document.getElementById('_waBcV2SelAllLbl').textContent = 'Pilih semua hasil filter (0)';
_waBcV2UpdateCount();
return;
}
var html = '';
filtered.slice(0, 500).forEach(function(c){
var checked = s.selectedIds.has(c.id);
html += ''
+ '
'
+ '
'
+ '
'+_wa_escHtml(c.nama||'(tanpa nama)')+'
'
+ '
'+_wa_escHtml(c.perusahaan||'')+(c.perusahaan&&c.kota?' • ':'')+_wa_escHtml(c.kota||'')+' • '+_wa_escHtml(c.hp||'')+'
'
+ '
'
+ '
';
});
if(filtered.length > 500) html += '+ '+(filtered.length-500)+' kontak lainnya. Persempit pencarian.
';
box.innerHTML = html;
// Update select-all label & checkbox state
document.getElementById('_waBcV2SelAllLbl').textContent = 'Pilih semua hasil filter ('+filtered.length+')';
var allChecked = filtered.length > 0 && filtered.every(function(c){return s.selectedIds.has(c.id);});
var chk = document.getElementById('_waBcV2SelAllChk');
if(chk) chk.checked = allChecked;
_waBcV2UpdateCount();
};
window._waBcV2UpdateCount = function(){
var el = document.getElementById('_waBcV2SelCount');
if(el) el.textContent = window._waBcV2.selectedIds.size;
};
window._waBcV2OnSearch = function(q){
window._waBcV2.searchQuery = q;
_waBcV2RenderList();
};
window._waBcV2Shortcut = function(filter, mkt){
window._waBcV2.shortcutFilter = filter;
window._waBcV2.shortcutMkt = mkt || '';
if(filter !== 'by_mkt'){
var sel = document.getElementById('_waBcV2MktSel');
if(sel) sel.value = '';
}
_waBcV2RenderList();
};
window._waBcV2ToggleOne = function(custId){
var s = window._waBcV2;
if(s.selectedIds.has(custId)) s.selectedIds.delete(custId);
else s.selectedIds.add(custId);
_waBcV2RenderList();
};
window._waBcV2ToggleSelectAllFiltered = function(checked){
var s = window._waBcV2;
var filtered = _waBcV2GetFiltered();
filtered.forEach(function(c){
if(checked) s.selectedIds.add(c.id);
else s.selectedIds.delete(c.id);
});
_waBcV2RenderList();
};
window._waBcV2ClearSelection = function(){
window._waBcV2.selectedIds.clear();
_waBcV2RenderList();
};
// ── Schedule mode ──
window._waBcV2OnScheduleChange = function(){
var modes = document.getElementsByName('_waBcV2Mode');
var mode = 'now';
for(var i=0;i JADWALKAN BROADCAST'
: ' KIRIM BROADCAST';
btn.style.background = mode==='schedule' ? '#D97706' : '#25D366';
}
// Default sched time = now + 10 menit (local time)
if(mode==='schedule'){
var inp = document.getElementById('_waBcV2SchedTime');
if(inp && !inp.value){
var d = new Date(Date.now() + 10*60*1000);
var pad = function(n){return n<10?'0'+n:''+n;};
inp.value = d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())+'T'+pad(d.getHours())+':'+pad(d.getMinutes());
}
}
};
// ── Upload media ──
window._waBcV2OnUpload = async function(files){
if(!files || !files.length) return;
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){ alert('⚠️ Offline — tidak bisa upload file'); return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client){ alert('⚠️ Supabase client tidak siap'); return; }
var box = document.getElementById('_waBcV2MediaList');
var s = window._waBcV2;
for(var i=0; i 16*1024*1024){ alert('⚠️ "'+file.name+'" lebih dari 16MB, dilewati.'); continue; }
// Filename unik: timestamp + random + nama asli
var ts = Date.now();
var rand = Math.floor(Math.random()*9999);
var safeName = file.name.replace(/[^a-zA-Z0-9._-]/g,'_');
var path = 'broadcast/'+ts+'_'+rand+'_'+safeName;
// Tampilkan placeholder loading
var placeholderId = 'mediaUp_'+ts+'_'+rand;
box.insertAdjacentHTML('beforeend',
''
+ '
'
+ '
⏳ Uploading '+_wa_escHtml(file.name)+'...
'
+ '
'
);
try{
var up = await client.storage.from('blp-wa-media').upload(path, file, {cacheControl:'3600', upsert:false});
if(up.error){
var ph = document.getElementById(placeholderId);
if(ph) ph.outerHTML = '❌ Gagal upload '+_wa_escHtml(file.name)+': '+_wa_escHtml(up.error.message)+'
';
continue;
}
// Get public URL
var pub = client.storage.from('blp-wa-media').getPublicUrl(path);
var publicUrl = pub.data.publicUrl;
var mediaItem = {
url: publicUrl,
path: path,
filename: file.name,
mime_type: file.type,
size_bytes: file.size
};
s.media.push(mediaItem);
// Replace placeholder dengan item final
var ph2 = document.getElementById(placeholderId);
if(ph2) ph2.remove();
_waBcV2RenderMedia();
}catch(e){
var ph3 = document.getElementById(placeholderId);
if(ph3) ph3.outerHTML = '❌ Error: '+_wa_escHtml(e.message)+'
';
}
}
// Reset file input supaya bisa upload file yang sama lagi
var inp = document.getElementById('_waBcV2FileInput');
if(inp) inp.value = '';
};
window._waBcV2RenderMedia = function(){
var box = document.getElementById('_waBcV2MediaList');
if(!box) return;
var s = window._waBcV2;
if(!s.media.length){ box.innerHTML = ''; return; }
var html = '';
s.media.forEach(function(m, idx){
var icon = m.mime_type && m.mime_type.startsWith('image/') ? '🖼️'
: m.mime_type === 'application/pdf' ? '📄'
: m.mime_type && m.mime_type.startsWith('video/') ? '🎬'
: m.mime_type && m.mime_type.startsWith('audio/') ? '🎵'
: '📎';
var sizeKb = (m.size_bytes/1024).toFixed(1);
var sizeStr = sizeKb > 1024 ? (sizeKb/1024).toFixed(1)+' MB' : sizeKb+' KB';
html += ''
+ '
'+icon+' '
+ '
'
+ '
'+_wa_escHtml(m.filename)+'
'
+ '
'+sizeStr+'
'
+ '
'
+ '
✕ '
+ '
';
});
box.innerHTML = html;
};
window._waBcV2RemoveMedia = async function(idx){
var s = window._waBcV2;
var m = s.media[idx];
if(!m) return;
if(!confirm('Hapus lampiran "'+m.filename+'"?')) return;
// Delete dari storage juga (best-effort, tidak block kalau gagal)
try{
if(typeof blpSupabase !== 'undefined' && blpSupabase.online){
var client = blpSupabase.client;
if(client && m.path) await client.storage.from('blp-wa-media').remove([m.path]);
}
}catch(e){ console.warn('remove media storage err:', e); }
s.media.splice(idx, 1);
_waBcV2RenderMedia();
};
// ── Submit (kirim sekarang atau jadwalkan) ──
window._waBcV2Submit = async function(){
var s = window._waBcV2;
var msg = (document.getElementById('_waBcV2Msg')?.value || '').trim();
// Validasi
if(s.selectedIds.size === 0){ alert('⚠️ Belum ada penerima yang dipilih.\n\nGunakan search atau shortcut, lalu centang minimal 1 kontak.'); return; }
if(!msg && s.media.length === 0){ alert('⚠️ Isi pesan atau attach minimal 1 file.'); return; }
var targets = (window.DB_CUSTOMER||[]).filter(function(c){return s.selectedIds.has(c.id);});
if(!targets.length){ alert('⚠️ Customer yang dipilih tidak ditemukan di database.'); return; }
var btn = document.getElementById('_waBcV2SubmitBtn');
var prog = document.getElementById('_waBcV2Progress');
// === MODE: JADWALKAN ===
if(s.scheduleMode === 'schedule'){
var schedVal = document.getElementById('_waBcV2SchedTime')?.value;
if(!schedVal){ alert('⚠️ Pilih waktu kirim dulu.'); return; }
var schedTime = new Date(schedVal);
if(isNaN(schedTime.getTime())){ alert('⚠️ Format waktu tidak valid.'); return; }
if(schedTime.getTime() < Date.now() - 60000){ alert('⚠️ Waktu kirim sudah lewat. Pilih waktu di masa depan.'); return; }
if(!confirm('Jadwalkan broadcast ke '+targets.length+' kontak pada '+schedTime.toLocaleString('id-ID')+'?\n\nLampiran: '+s.media.length+' file. Pesan akan dikirim otomatis oleh server.')) return;
if(btn){ btn.disabled = true; btn.innerHTML = ' Menjadwalkan...'; btn.style.opacity = '0.7'; }
if(prog) prog.innerHTML = '⏳ Insert antrian... ';
// Bulk insert ke blp_wa_queue
try{
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
var rows = targets.map(function(c){
var nama = c.nama || 'Bapak/Ibu';
var rendered = (typeof _wa_renderTpl === 'function')
? _wa_renderTpl(msg, {nama:nama, perusahaan:c.perusahaan||'', kota:c.kota||'', isi_pesan:''})
: msg.replace(/\{nama\}/g, nama).replace(/\{perusahaan\}/g, c.perusahaan||'').replace(/\{kota\}/g, c.kota||'');
return {
id: 'qbc_'+Date.now()+'_'+c.id+'_'+Math.random().toString(36).slice(2,8),
hp: _wa_normalizeHp(c.hp),
body: rendered,
cust_id: c.id,
scheduled_at: schedTime.toISOString(),
status: 'pending',
kind: 'broadcast',
media: s.media.length ? JSON.stringify(s.media.map(function(m){return {url:m.url, filename:m.filename, mime_type:m.mime_type};})) : null,
created_at: new Date().toISOString()
};
});
// Chunked insert (Supabase batch limit ~1000, kita pakai 100 supaya aman)
var inserted = 0, failed = 0;
for(var i=0; i';
}
if(typeof audit === 'function') audit('broadcast_v2_schedule', 'wa_queue', 'manual', 'Broadcast v2 schedule ke '+targets.length+' kontak utk '+schedTime.toLocaleString('id-ID'), {target:rows.length, ok:inserted, fail:failed, media:s.media.length});
if(prog) prog.innerHTML = '✅ '+inserted+' pesan terjadwal'+(failed?' • ❌ '+failed+' gagal':'')+' ';
if(btn){ btn.innerHTML = ' Terjadwal — Reset'; btn.style.background = '#15803D'; btn.style.opacity = '1'; btn.disabled = false; btn.onclick = _waBcV2Reset; }
if(typeof _wa_toast === 'function') _wa_toast('✅ '+inserted+' pesan terjadwal','ok');
}catch(e){
if(prog) prog.innerHTML = '❌ Error: '+_wa_escHtml(e.message)+' ';
if(btn){ btn.disabled = false; btn.innerHTML = ' JADWALKAN BROADCAST'; btn.style.opacity = '1'; }
}
return;
}
// === MODE: KIRIM SEKARANG ===
if(!confirm('Kirim sekarang ke '+targets.length+' kontak?\n\nEstimasi: ~'+Math.ceil(targets.length*(s.media.length+1))+' detik. Jangan tutup tab.')) return;
if(btn){ btn.disabled = true; btn.innerHTML = ' Mengirim...'; btn.style.opacity = '0.7'; }
if(prog) prog.innerHTML = '⏳ Memulai... ';
var ok=0, fail=0;
var bcId = 'bcv2_' + Date.now();
var mediaPayload = s.media.length ? s.media.map(function(m){return {url:m.url, filename:m.filename, mime_type:m.mime_type};}) : null;
for(var j=0; j';
await new Promise(function(r){ setTimeout(r, 1000); });
}
if(typeof audit === 'function') audit('broadcast_v2_now', 'wa_broadcast', 'manual', 'Broadcast v2 now ke '+targets.length+' kontak: '+ok+' ok, '+fail+' gagal', {target:targets.length, ok:ok, fail:fail, media:s.media.length});
if(prog) prog.innerHTML = '✅ Selesai: '+ok+' sukses'+(fail?', '+fail+' gagal':'')+' ';
if(btn){ btn.innerHTML = ' Selesai — Reset'; btn.style.background = '#15803D'; btn.style.opacity = '1'; btn.disabled = false; btn.onclick = _waBcV2Reset; }
if(typeof _wa_toast === 'function') _wa_toast('Broadcast selesai: '+ok+' sukses'+(fail?', '+fail+' gagal':''), ok>0?'ok':'warn');
};
window._waBcV2Reset = function(){
window._waBcV2 = {
searchQuery: '',
shortcutFilter: 'all',
shortcutMkt: '',
selectedIds: new Set(),
media: [],
scheduleMode: 'now',
scheduleAt: ''
};
// Re-render tab broadcast
if(typeof waRefreshTab === 'function') waRefreshTab();
else if(typeof waRender === 'function') waRender();
else location.reload();
};
// ────────────── TAB: INBOX ──────────────
function _waTabInbox(c){
return `
💬 Conversation Inbox
${_wa_canFull() ? ' Archive ' : ''}
Semua
Unread
Belum dibalas >24h
Handover
🔔 Need follow-up
⚠️ Komplain
💰 Closing
⭐ VIP
`;
}
// ── FULL-SCREEN CHAT NAV (Back + Close) ──
window.waInboxBack = function(){
var overlay = document.getElementById('wa_chat_overlay');
if(overlay){ overlay.style.display = 'none'; }
// Restore body scroll
document.body.style.overflow = '';
// Optional: refresh inbox list buat update unread count
if(typeof window.waInboxRender === 'function'){ window.waInboxRender(); }
};
// ESC key untuk close overlay
if(!window._waOverlayEscBound){
window._waOverlayEscBound = true;
document.addEventListener('keydown', function(e){
if(e.key === 'Escape'){
var ov = document.getElementById('wa_chat_overlay');
if(ov && ov.style.display === 'flex'){
window.waInboxBack();
}
}
});
}
// ════════════════════════════════════════════════════════════════
// WA CONTACT HELPERS — Lightweight contact book (independent dari DB_CUSTOMER)
// ════════════════════════════════════════════════════════════════
// Cari kontak WA berdasarkan HP. Return record atau null.
// Prioritas: 1) DB_WA_CONTACT (manual save) 2) DB_CUSTOMER (master) 3) fallback HP
window._waResolveName = function(hp, fallback){
if(!hp) return fallback || '';
var nhp = _wa_normalizeHp(hp);
// 1) Lookup di DB_WA_CONTACT
var contacts = (typeof DB_WA_CONTACT !== 'undefined' && DB_WA_CONTACT) ? DB_WA_CONTACT : (window.DB_WA_CONTACT || []);
var wc = contacts.find(function(c){ return c && !c.deleted_at && _wa_normalizeHp(c.hp) === nhp; });
if(wc && wc.nama) return wc.nama;
// 2) Lookup di DB_CUSTOMER
var customers = (typeof DB_CUSTOMER !== 'undefined' && DB_CUSTOMER) ? DB_CUSTOMER : (window.DB_CUSTOMER || []);
var cu = customers.find(function(c){ return c && _wa_normalizeHp(c.hp) === nhp; });
if(cu && cu.nama) return cu.nama;
// 3) Fallback (cust_name dari payload Fonnte, atau HP itu sendiri)
return fallback || hp;
};
// Lookup kontak WA (tanpa fallback ke DB_CUSTOMER). Untuk action sheet (cek apakah sudah disave).
window._waFindContact = function(hp){
if(!hp) return null;
var nhp = _wa_normalizeHp(hp);
var contacts = (typeof DB_WA_CONTACT !== 'undefined' && DB_WA_CONTACT) ? DB_WA_CONTACT : (window.DB_WA_CONTACT || []);
return contacts.find(function(c){ return c && !c.deleted_at && _wa_normalizeHp(c.hp) === nhp; }) || null;
};
// Cek apakah HP sudah ada di DB_CUSTOMER master
window._waIsCustomer = function(hp){
if(!hp) return false;
var nhp = _wa_normalizeHp(hp);
var customers = (typeof DB_CUSTOMER !== 'undefined' && DB_CUSTOMER) ? DB_CUSTOMER : (window.DB_CUSTOMER || []);
return !!customers.find(function(c){ return c && _wa_normalizeHp(c.hp) === nhp; });
};
// ── Action Sheet: tap header → tampilkan menu opsi ──
window.waContactActionSheet = function(hp){
var existing = document.getElementById('_waContactSheet');
if(existing){ existing.remove(); }
var contact = window._waFindContact(hp);
var isCust = window._waIsCustomer(hp);
var hasSaved = !!contact;
var sheet = document.createElement('div');
sheet.id = '_waContactSheet';
sheet.style.cssText = 'position:fixed;inset:0;z-index:10001;display:flex;align-items:flex-end;justify-content:center;background:rgba(0,0,0,0.4)';
sheet.onclick = function(e){ if(e.target === sheet) sheet.remove(); };
var btns = '';
if(hasSaved){
btns += ' Edit Kontak ';
btns += ' Hapus dari Kontak ';
} else {
btns += ' Save sebagai Kontak ';
}
if(!isCust){
btns += ' Convert ke Customer ';
} else {
btns += ' Sudah jadi Customer
';
}
btns += 'Tutup ';
sheet.innerHTML = ''
+ '
'
+ '
'
+ '
'+_wa_escHtml(window._waResolveName(hp, hp))+'
'
+ '
'+hp+'
'
+ '
'
+ btns
+ '
';
document.body.appendChild(sheet);
};
// ── Form Save/Edit Kontak ──
window.waContactOpenForm = function(hp, isEdit){
var existing = document.getElementById('_waContactForm');
if(existing) existing.remove();
var contact = window._waFindContact(hp);
var nama = (isEdit && contact) ? contact.nama : '';
var catatan = (isEdit && contact) ? (contact.catatan||'') : '';
var modal = document.createElement('div');
modal.id = '_waContactForm';
modal.style.cssText = 'position:fixed;inset:0;z-index:10002;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);padding:20px';
modal.onclick = function(e){ if(e.target === modal) modal.remove(); };
modal.innerHTML = ''
+ '
'
+ '
'+(isEdit?'Edit Kontak':'Save Kontak')+'
'
+ '
'
+ '
'
+ '
'
+ '
'
+ 'No. HP '
+ ' '
+ '
'
+ '
'
+ 'Nama * '
+ ' '
+ '
'
+ '
'
+ 'Catatan (opsional) '
+ ''
+ '
'
+ '
'+(isEdit?'Update':'Simpan')+''
+ '
'
+ '
';
document.body.appendChild(modal);
setTimeout(function(){
var ipt = document.getElementById('_waContactNama');
if(ipt) ipt.focus();
}, 100);
};
// ── Save kontak ke DB_WA_CONTACT + Supabase ──
window.waContactQuickSave = async function(hp, isEdit){
var namaEl = document.getElementById('_waContactNama');
var catEl = document.getElementById('_waContactCatatan');
var nama = (namaEl && namaEl.value || '').trim();
var catatan = (catEl && catEl.value || '').trim();
if(!nama){ alert('Nama wajib diisi'); return; }
var nhp = _wa_normalizeHp(hp);
var existing = window._waFindContact(hp);
var contacts = (typeof DB_WA_CONTACT !== 'undefined' && DB_WA_CONTACT) ? DB_WA_CONTACT : window.DB_WA_CONTACT;
var nowIso = new Date().toISOString();
var record;
if(existing){
// Update memory
existing.nama = nama;
existing.catatan = catatan;
existing.updated_at = nowIso;
record = existing;
} else {
// Buat baru
record = {
id: 'wc_' + Date.now() + '_' + Math.random().toString(36).slice(2,6),
hp: nhp,
nama: nama,
catatan: catatan,
created_at: nowIso,
updated_at: nowIso
};
contacts.push(record);
}
// Persist ke Supabase
try{
if(typeof blpSupabase !== 'undefined' && blpSupabase.online){
var r = await blpSupabase.upsertTable('blp_wa_contact', record);
if(!r.ok){
_wa_toast('Gagal simpan ke cloud: '+(r.error||'?'),'err');
return;
}
}
// Save local backup
if(typeof saveLocal === 'function') saveLocal();
_wa_toast(existing?'✓ Kontak diupdate':'✓ Kontak disimpan','ok');
var modal = document.getElementById('_waContactForm');
if(modal) modal.remove();
// Refresh: header overlay + inbox list
var ovName = document.getElementById('wa_chat_overlay_name');
if(ovName) ovName.textContent = nama;
var ovAv = document.getElementById('wa_chat_overlay_avatar');
if(ovAv) ovAv.textContent = (nama.charAt(0)||'?').toUpperCase();
if(typeof window.waInboxRender === 'function') window.waInboxRender();
}catch(e){
_wa_toast('Error: '+(e.message||e),'err');
}
};
// ── Hapus kontak (soft delete) ──
window.waContactDelete = async function(hp){
var existing = window._waFindContact(hp);
if(!existing) return;
if(!confirm('Hapus kontak "'+existing.nama+'" dari WA Contact?\n\nNama akan kembali jadi nomor HP di inbox.')) return;
var nowIso = new Date().toISOString();
existing.deleted_at = nowIso;
existing.updated_at = nowIso;
try{
if(typeof blpSupabase !== 'undefined' && blpSupabase.online){
var r = await blpSupabase.upsertTable('blp_wa_contact', existing);
if(!r.ok){ _wa_toast('Gagal hapus: '+(r.error||'?'),'err'); return; }
}
if(typeof saveLocal === 'function') saveLocal();
_wa_toast('✓ Kontak dihapus','ok');
// Refresh header + inbox
var ovName = document.getElementById('wa_chat_overlay_name');
if(ovName) ovName.textContent = hp;
var ovAv = document.getElementById('wa_chat_overlay_avatar');
if(ovAv) ovAv.textContent = (hp.charAt(0)||'?').toUpperCase();
if(typeof window.waInboxRender === 'function') window.waInboxRender();
}catch(e){ _wa_toast('Error: '+(e.message||e),'err'); }
};
// ── Convert ke Customer (buka modal customer baru) ──
window.waContactConvertToCustomer = function(hp){
var contact = window._waFindContact(hp);
var namaPrefill = contact ? contact.nama : '';
// Tutup overlay chat dulu
var overlay = document.getElementById('wa_chat_overlay');
if(overlay) overlay.style.display = 'none';
document.body.style.overflow = '';
// Cek apakah ada modal Customer edit/add
if(typeof window.openCust === 'function'){
// Pakai prefill HP + nama via tmp window
window._waCustPrefill = { hp: _wa_normalizeHp(hp), nama: namaPrefill, kategori: 'Prospek' };
window.openCust();
_wa_toast('Buka form Customer baru — isi data lengkap & simpan','ok');
} else if(typeof window.openCustomerForm === 'function'){
window._waCustPrefill = { hp: _wa_normalizeHp(hp), nama: namaPrefill, kategori: 'Prospek' };
window.openCustomerForm();
} else {
// Fallback: navigate ke menu customer
alert('Silakan buka menu Customer dan tambah customer baru dengan HP: '+hp+' dan nama: '+(namaPrefill||'(belum di-save)'));
}
};
// State filter inbox
window._waInboxActiveFilter = 'all';
window.waInboxFilter = function(filter){
window._waInboxActiveFilter = filter;
// Update visual chip buttons
var allChips = ['all','unread','overdue','handover','need_followup','komplain','closing','vip'];
allChips.forEach(function(c){
var btn = document.getElementById('_waInboxF_'+c);
if(!btn) return;
var isActive = (filter === c) || (filter === 'label_'+c);
btn.style.background = isActive ? '#25D366' : '#fff';
btn.style.color = isActive ? '#fff' : 'var(--text)';
btn.style.borderColor = isActive ? '#25D366' : '#E5E7EB';
btn.style.borderWidth = isActive ? '1.5px' : '1px';
});
waInboxRender();
};
// Debounce untuk search supaya gak query tiap keystroke
window.waInboxDebouncedRender = function(){
if(window._waInboxSearchTimer) clearTimeout(window._waInboxSearchTimer);
window._waInboxSearchTimer = setTimeout(function(){ waInboxRender(); }, 350);
};
window.waInboxRender = async function(){
var box = document.getElementById('wa_inbox_list');
if(!box) return;
box.innerHTML = 'Memuat...
';
try{
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){
box.innerHTML = 'Supabase belum siap
Tunggu sebentar dan klik refresh, atau pastikan sudah login Supabase
Coba Lagi ';
// Auto-retry dalam 2 detik
setTimeout(function(){ if(typeof blpSupabase!=='undefined' && blpSupabase.online) waInboxRender(); }, 2000);
return;
}
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client){ box.innerHTML = 'Koneksi gagal
'; return; }
var q = (document.getElementById('wa_inbox_search')?.value || '').trim();
var filter = window._waInboxActiveFilter || 'all';
// Build query — pilih kolom termasuk is_read, thread_labels (kalau ada)
var query = client.from('blp_wa_chat').select('hp,cust_name,mkt_handler,handover_state,content,role,created_at,is_read,thread_labels,thread_note').order('created_at',{ascending:false}).limit(500);
// Search: cari di HP ATAU content
if(q){
// Supabase or filter: hp ilike % OR content ilike %
query = query.or('hp.ilike.%'+q+'%,content.ilike.%'+q+'%');
}
// Filter unread (server-side biar cepat)
if(filter === 'unread'){
query = query.eq('is_read', false).eq('role', 'user');
} else if(filter === 'handover'){
query = query.eq('handover_state', 'handed_over');
}
if(_wa_canMarketing() && !_wa_canFull()){
var myCustIds = _wa_myCustomers().map(function(c){return c.id;});
if(!myCustIds.length){ box.innerHTML = 'Belum ada customer Anda.
'; return; }
query = query.in('cust_id', myCustIds);
}
var r = await query;
if(r.error){
if(r.error.code==='42P01') box.innerHTML = 'Tabel blp_wa_chat belum dibuat. Jalankan SQL setup.
';
else if(r.error.code==='42703'){
// Kolom is_read / thread_labels belum ada → owner perlu jalankan SQL #5
box.innerHTML = ''
+ '⚠️ Phase 2 SQL belum dieksekusi. '
+ 'Owner perlu menjalankan 05_wa_chat_phase2.sql di Supabase SQL Editor untuk fitur baru: unread filter, label, dan notes. '
+ ' Pakai Mode Lama (tanpa Phase 2) '
+ '
';
} else box.innerHTML = ''+r.error.message+'
';
return;
}
if(!r.data || !r.data.length){
var emptyMsg = (q || filter !== 'all') ? 'Tidak ada chat sesuai filter.' : 'Belum ada percakapan.';
box.innerHTML = ''+emptyMsg+'
'+(filter!=='all'?'
Reset Filter ':'')+'
';
return;
}
// Group by HP, ambil pesan terakhir + simpan info unread count
var grouped = {};
var unreadCountPerHp = {};
r.data.forEach(function(row){
if(!grouped[row.hp]){
grouped[row.hp] = row;
unreadCountPerHp[row.hp] = 0;
}
// Hitung unread (pesan dari user yang belum dibaca)
if(row.role === 'user' && row.is_read === false){
unreadCountPerHp[row.hp]++;
}
});
// Load thread_meta untuk semua HP (labels & notes)
var allHps = Object.keys(grouped);
var threadMetaMap = {};
try{
var tm = await client.from('blp_wa_thread_meta').select('hp,labels,note').in('hp', allHps);
if(!tm.error && tm.data){
tm.data.forEach(function(row){ threadMetaMap[row.hp] = row; });
}
}catch(e){ /* silent, thread_meta optional */ }
// Client-side filter: overdue & label
var now = Date.now();
var filtered = allHps.filter(function(hp){
var row = grouped[hp];
var meta = threadMetaMap[hp] || {labels:[], note:''};
if(filter === 'overdue'){
// Pesan terakhir dari user, >24h yang lalu
if(row.role !== 'user') return false;
var ageMs = now - new Date(row.created_at).getTime();
return ageMs > 24*60*60*1000;
}
if(filter.startsWith('label_')){
var labelKey = filter.slice(6); // 'need_followup', 'komplain', dll
return (meta.labels||[]).indexOf(labelKey) >= 0;
}
return true;
});
if(!filtered.length){
box.innerHTML = 'Tidak ada chat sesuai filter.
Reset Filter ';
return;
}
var labelMap = {
need_followup: {emoji:'🔔', color:'#D97706', bg:'#FEF3C7'},
komplain: {emoji:'⚠️', color:'#DC2626', bg:'#FEE2E2'},
closing: {emoji:'💰', color:'#15803D', bg:'#DCFCE7'},
vip: {emoji:'⭐', color:'#7C3AED', bg:'#EDE9FE'}
};
var html = '';
filtered.forEach(function(hp){
var row = grouped[hp];
var meta = threadMetaMap[hp] || {labels:[], note:''};
var t = new Date(row.created_at);
var stateBadge = '';
if(row.handover_state==='handed_over') stateBadge = 'Handover ';
else stateBadge = 'Bot Active ';
// Unread badge
var unreadCount = unreadCountPerHp[hp] || 0;
var unreadBadge = unreadCount > 0 ? ''+unreadCount+' ' : '';
// Label badges
var labelBadges = (meta.labels||[]).map(function(l){
var conf = labelMap[l] || {emoji:'🏷️',color:'#64748B',bg:'#F1F5F9'};
return ''+conf.emoji+' ';
}).join('');
// Note indicator
var noteIcon = meta.note ? ' ' : '';
var prev = (row.content||'').slice(0,60);
var rowBg = unreadCount > 0 ? 'background:#FFFBEB' : '';
html += ''
+ '
'
+ '
'
+ '
'+_wa_escHtml(window._waResolveName(hp, row.cust_name))+unreadBadge+labelBadges+noteIcon+'
'
+ stateBadge
+ '
'
+ '
'+hp+' • '+t.toLocaleString('id-ID')+'
'
+ '
"'+_wa_escHtml(prev)+'..."
'
+ '
'
+ (_wa_canFull() ? '
' : '')
+ '
';
});
box.innerHTML = html;
}catch(e){ box.innerHTML = ''+e.message+'
'; }
};
// Fallback ke render lama kalau SQL Phase 2 belum jalan
window.waInboxFallback = function(){
window._waInboxLegacyMode = true;
waInboxRender();
};
window.waInboxOpen = async function(hp){
var box = document.getElementById('wa_chat_thread');
if(!box) return;
// ── Aktifkan fullscreen overlay ──
var overlay = document.getElementById('wa_chat_overlay');
if(overlay){
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden'; // lock body scroll
// Reset header sementara
var nameEl = document.getElementById('wa_chat_overlay_name');
var subEl = document.getElementById('wa_chat_overlay_sub');
var avEl = document.getElementById('wa_chat_overlay_avatar');
var actEl = document.getElementById('wa_chat_overlay_actions');
if(nameEl) nameEl.textContent = hp;
if(subEl) subEl.textContent = 'Memuat…';
if(avEl) avEl.textContent = '?';
if(actEl) actEl.innerHTML = '';
}
box.innerHTML = '⏳ Memuat percakapan...
';
// Reset attachment state saat buka thread baru
window._waChatAttachment = null;
// Simpan HP saat ini untuk action sheet
window._waCurrentHp = hp;
try{
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
// Parallel fetch: messages + thread meta
var msgsPromise = client.from('blp_wa_chat').select('*').eq('hp', hp).order('created_at',{ascending:true}).limit(100);
var metaPromise = client.from('blp_wa_thread_meta').select('*').eq('hp', hp).maybeSingle();
var [msgsRes, metaRes] = await Promise.all([msgsPromise, metaPromise]);
if(msgsRes.error){ box.innerHTML = ''+msgsRes.error.message+'
'; return; }
var msgs = msgsRes.data||[];
var first = msgs[0]||{};
var last = msgs[msgs.length-1]||{};
var handover = last.handover_state === 'handed_over';
// Thread meta (mungkin null kalau belum pernah dipakai atau tabel belum ada)
var meta = (metaRes && !metaRes.error && metaRes.data) ? metaRes.data : {labels:[], note:''};
window._waCurrentThreadMeta = {hp:hp, labels:[...(meta.labels||[])], note:meta.note||''};
// Mark all unread messages from this HP as read (fire-and-forget)
if(msgs.some(function(m){return m.role==='user' && m.is_read===false;})){
client.from('blp_wa_chat').update({is_read:true, read_at:new Date().toISOString()})
.eq('hp', hp).eq('role', 'user').eq('is_read', false)
.then(function(){ /* refresh badge inbox */ setTimeout(function(){ if(typeof waInboxRender==='function') waInboxRender(); }, 500); })
.catch(function(){});
}
// Label/note editor section
var labelMap = {
need_followup: {emoji:'🔔', label:'Need follow-up', color:'#D97706', bg:'#FEF3C7'},
komplain: {emoji:'⚠️', label:'Komplain', color:'#DC2626', bg:'#FEE2E2'},
closing: {emoji:'💰', label:'Closing', color:'#15803D', bg:'#DCFCE7'},
vip: {emoji:'⭐', label:'VIP', color:'#7C3AED', bg:'#EDE9FE'}
};
var labelChipsHtml = Object.keys(labelMap).map(function(key){
var conf = labelMap[key];
var active = (meta.labels||[]).indexOf(key) >= 0;
return ''+conf.emoji+' '+conf.label+' ';
}).join('');
var noteValue = (meta.note||'').replace(/"/g,'"');
// ── Update overlay HEADER (kiri ada avatar + nama + sub, kanan ada bot toggle) ──
var displayName = window._waResolveName(hp, first.cust_name);
var displaySub = hp + (last.mkt_handler ? ' • ' + last.mkt_handler : '');
var avatarInit = (displayName.charAt(0)||'?').toUpperCase();
var ovName = document.getElementById('wa_chat_overlay_name');
var ovSub = document.getElementById('wa_chat_overlay_sub');
var ovAv = document.getElementById('wa_chat_overlay_avatar');
var ovAct = document.getElementById('wa_chat_overlay_actions');
if(ovName) ovName.textContent = displayName;
if(ovSub) ovSub.textContent = displaySub;
if(ovAv) ovAv.textContent = avatarInit;
if(ovAct){
ovAct.innerHTML = ''
+ (handover ? ' Aktifkan Bot' : ' Pause Bot')
+ ' ';
}
var html = ''
+ '
';
msgs.forEach(function(m){
var t = new Date(m.created_at).toLocaleString('id-ID');
var isUser = m.role==='user';
var isSys = m.role==='system';
var bg = isUser ? '#fff' : isSys ? 'var(--purple-l,#ede9fe)' : '#dcf8c6';
var align = isUser ? 'flex-start' : isSys ? 'center' : 'flex-end';
var maxW = isSys ? '90%' : '80%';
html += '
'
+ '
'
+ _wa_escHtml(m.content)
+ '
'+t+(isUser?'':' • '+_wa_escHtml(m.role))+'
'
+ '
'
+ '
';
});
html += '
'; // close _waChatMsgs
html += '
'; // close padding wrapper
// ── Reply box di bawah (sticky, mirip WhatsApp input bar) ──
var replyBoxHtml = '';
// Send reply box — Owner/Admin selalu, Marketing hanya untuk customer mereka
var canReply = false;
if(_wa_canFull()){
canReply = true;
} else if(_wa_canMarketing()){
var custHp = hp;
var myName = _wa_normLc((typeof CU!=='undefined' && CU && CU.name));
var foundCust = (window.DB_CUSTOMER||[]).find(function(c){
return _wa_normalizeHp(c.hp) === custHp;
});
var mktHere = (last && last.mkt_handler) || (foundCust && foundCust.mkt) || '';
if(_wa_normLc(mktHere) === myName) canReply = true;
}
if(canReply){
var noteHandover = handover ? '' : ' Mengirim balasan akan auto-pause bot
';
// Quick reply dropdown
var quickReplyTemplates = (window._waTemplates||[]).filter(function(t){return t.aktif && t.kategori === 'quick_reply';});
var quickReplyBtn = '';
if(quickReplyTemplates.length > 0){
var qrOpts = '⚡ Quick Reply… ' + quickReplyTemplates.map(function(t){
return ''+(t.shortcut?'/'+t.shortcut+' — ':'')+_wa_escHtml(t.nama)+' ';
}).join('');
quickReplyBtn = ''+qrOpts+' ';
}
replyBoxHtml = ''
+ noteHandover
+ quickReplyBtn
// ── Attachment preview (hidden by default) ──
+ '
'
// ── Hidden file inputs ──
+ '
'
+ '
'
+ '
'
+ ' '
+ ''
+ ' '
+ '
'
+ '
';
} else if(_wa_canMarketing()){
replyBoxHtml = ' Customer ini bukan handle Anda, tidak bisa reply
';
}
html += replyBoxHtml;
// ── Render sub-header (Label + Catatan Internal) ke elemen fixed di overlay ──
var subEl = document.getElementById('wa_chat_subheader');
if(subEl){
subEl.innerHTML = ''
+ ''
+ '
🏷️ Label / Tag
'
+ '
'+labelChipsHtml+'
'
+ '
'
+ ''
+ '
📝 Catatan Internal (tim only, tidak terlihat customer)
'
+ '
'
+ '
';
}
box.innerHTML = html;
// Auto-scroll msgs ke bawah
// Auto-scroll ke pesan paling baru di bawah
// Setelah refactor fullscreen, scrollable container adalah `wa_chat_thread` (parent),
// bukan `_waChatMsgs` (inner). Pakai requestAnimationFrame supaya scroll
// dijalankan setelah browser layout selesai render.
requestAnimationFrame(function(){
requestAnimationFrame(function(){
var threadBox = document.getElementById('wa_chat_thread');
if(threadBox) threadBox.scrollTop = threadBox.scrollHeight;
// Backward-compat: scroll juga _waChatMsgs (kalau suatu hari container berubah lagi)
var msgBox = document.getElementById('_waChatMsgs');
if(msgBox) msgBox.scrollTop = msgBox.scrollHeight;
// Auto-grow textarea
var ta = document.getElementById('wa_chat_reply');
if(ta && !ta._growBound){
ta._growBound = true;
ta.addEventListener('input', function(){
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
}
});
});
}catch(e){ box.innerHTML = ''+e.message+'
'; }
};
// Quick reply: apply template ke textarea reply
window.waChatApplyQuickReply = function(templateId, hp){
if(!templateId) return;
var tpl = (window._waTemplates||[]).find(function(t){return t.id===templateId;});
if(!tpl) return;
var ta = document.getElementById('wa_chat_reply');
if(!ta) return;
// Render template dengan customer data
var cust = (window.DB_CUSTOMER||[]).find(function(c){
return _wa_normalizeHp(c.hp) === hp;
});
var rendered = (typeof window._waRenderTemplate === 'function') ? window._waRenderTemplate(tpl.body, cust) : tpl.body;
// Append atau replace? Replace lebih intuitif untuk quick reply
ta.value = rendered;
ta.focus();
// Mark template used
if(typeof window._waTemplateMarkUsed === 'function') window._waTemplateMarkUsed(templateId);
// Reset dropdown
var sel = document.getElementById('_waChatQuickReply');
if(sel) sel.value = '';
};
// Toggle label
window.waChatToggleLabel = async function(hp, labelKey){
var meta = window._waCurrentThreadMeta;
if(!meta || meta.hp !== hp) meta = {hp:hp, labels:[], note:''};
var labels = meta.labels.slice();
var idx = labels.indexOf(labelKey);
if(idx >= 0) labels.splice(idx, 1);
else labels.push(labelKey);
window._waCurrentThreadMeta.labels = labels;
// Visual update immediately
var btn = document.getElementById('_waLabel_'+labelKey);
var labelMap = {
need_followup: {color:'#D97706', bg:'#FEF3C7'},
komplain: {color:'#DC2626', bg:'#FEE2E2'},
closing: {color:'#15803D', bg:'#DCFCE7'},
vip: {color:'#7C3AED', bg:'#EDE9FE'}
};
if(btn){
var conf = labelMap[labelKey] || {color:'#64748B', bg:'#F1F5F9'};
var active = labels.indexOf(labelKey) >= 0;
btn.style.background = active ? conf.bg : '#fff';
btn.style.color = active ? conf.color : 'var(--mut)';
btn.style.borderColor = active ? conf.color : '#E5E7EB';
}
// Save to Supabase (upsert thread_meta)
try{
if(typeof blpSupabase === 'undefined' || !blpSupabase.online) return;
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client) return;
var userId = (blpSupabase.session && blpSupabase.session.user) ? blpSupabase.session.user.id : null;
var r = await client.from('blp_wa_thread_meta').upsert({
hp: hp,
labels: labels,
note: meta.note || null,
updated_at: new Date().toISOString(),
updated_by: userId
}, {onConflict:'hp'});
if(r.error){
if(r.error.code === '42P01'){
alert('⚠️ Tabel blp_wa_thread_meta belum dibuat. Eksekusi 05_wa_chat_phase2.sql dulu.');
return;
}
console.warn('Save label gagal:', r.error);
if(typeof _wa_toast === 'function') _wa_toast('❌ Gagal simpan label','error');
} else {
if(typeof _wa_toast === 'function') _wa_toast('✅ Label disimpan','ok');
}
}catch(e){
console.warn('Toggle label error:', e);
}
};
// Save note (on blur)
window.waChatSaveNote = async function(hp){
var ta = document.getElementById('_waChatNote');
if(!ta) return;
var note = ta.value.trim();
var meta = window._waCurrentThreadMeta;
if(!meta || meta.hp !== hp) meta = {hp:hp, labels:[], note:''};
// Skip kalau gak berubah
if(meta.note === note) return;
window._waCurrentThreadMeta.note = note;
try{
if(typeof blpSupabase === 'undefined' || !blpSupabase.online) return;
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client) return;
var userId = (blpSupabase.session && blpSupabase.session.user) ? blpSupabase.session.user.id : null;
var r = await client.from('blp_wa_thread_meta').upsert({
hp: hp,
labels: meta.labels || [],
note: note || null,
updated_at: new Date().toISOString(),
updated_by: userId
}, {onConflict:'hp'});
if(r.error){
if(r.error.code === '42P01'){
alert('⚠️ Tabel blp_wa_thread_meta belum dibuat. Eksekusi 05_wa_chat_phase2.sql dulu.');
return;
}
console.warn('Save note gagal:', r.error);
if(typeof _wa_toast === 'function') _wa_toast('❌ Gagal simpan catatan','error');
} else {
if(typeof _wa_toast === 'function') _wa_toast('✅ Catatan disimpan','ok');
}
}catch(e){
console.warn('Save note error:', e);
}
};
// ────────────── INBOX: DELETE & ARCHIVE ──────────────
window.waInboxDelete = async function(hp, ev){
try{ if(ev){ ev.stopPropagation(); ev.preventDefault(); } }catch(_){}
if(!_wa_canFull()){ alert('Hanya Owner/Admin yang boleh hapus percakapan'); return; }
if(!confirm('Hapus semua percakapan untuk nomor '+hp+' ?\n\nAksi ini PERMANEN dan tidak bisa di-undo.')) return;
try{
if(typeof blpSupabase==='undefined' || !blpSupabase.online){ alert('❌ Supabase offline'); return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
var r = await client.from('blp_wa_chat').delete().eq('hp', hp);
if(r.error){ alert('❌ '+r.error.message); return; }
if(typeof _wa_toast==='function') _wa_toast('🗑️ Percakapan '+hp+' dihapus','ok');
else alert('🗑️ Percakapan dihapus');
waInboxRender();
}catch(e){ alert('❌ '+e.message); }
};
window.waArchiveOld = async function(){
if(!_wa_canFull()){ alert('Hanya Owner/Admin'); return; }
var daysStr = prompt('Pindahkan chat yang lebih lama dari berapa hari ke archive?\n\n(Chat lama akan dipindah ke tabel arsip, tabel utama tetap ringan)','90');
if(!daysStr) return;
var days = parseInt(daysStr, 10);
if(isNaN(days) || days < 7){ alert('Minimal 7 hari'); return; }
if(!confirm('Konfirmasi: pindahkan SEMUA chat lebih lama dari '+days+' hari ke archive?')) return;
try{
if(typeof blpSupabase==='undefined' || !blpSupabase.online){ alert('❌ Supabase offline'); return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(typeof _wa_toast==='function') _wa_toast('Memproses archive...','info');
var r = await client.rpc('archive_old_wa_chats', {days_threshold: days});
if(r.error){
if(r.error.code==='42883') alert('❌ Fungsi archive_old_wa_chats belum ada di DB.\n\nJalankan SQL setup chat scaling dulu.');
else alert('❌ '+r.error.message);
return;
}
var res = (r.data && r.data[0]) || {archived_count:0, deleted_count:0};
alert('✅ Archive sukses!\n\n• '+res.archived_count+' chat dipindah ke arsip\n• '+res.deleted_count+' chat dihapus dari tabel utama\n\nTabel utama sekarang lebih ringan.');
waInboxRender();
}catch(e){ alert('❌ '+e.message); }
};
window.waInboxToggleBot = async function(hp, pause){
try{
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
var state = pause ? 'handed_over' : null;
var r = await client.from('blp_wa_chat').update({handover_state: state}).eq('hp', hp);
if(r.error){ alert('❌ '+r.error.message); return; }
_wa_toast(pause?'Bot di-pause untuk customer ini':'Bot aktif kembali','ok');
waInboxOpen(hp);
}catch(e){ alert('❌ '+e.message); }
};
// ════════════════════════════════════════════════════════════════
// ATTACHMENT HANDLERS (image/video/PDF, max 16MB)
// ════════════════════════════════════════════════════════════════
// Konstanta
var WA_ATT_MAX_SIZE = 16 * 1024 * 1024; // 16 MB
var WA_ATT_ALLOWED_TYPES = {
'image/jpeg':'jpg', 'image/jpg':'jpg', 'image/png':'png', 'image/webp':'webp',
'video/mp4':'mp4', 'application/pdf':'pdf'
};
// Tampilkan menu pilihan: file picker vs kamera (mirip WhatsApp attach menu)
window.waChatShowAttachMenu = function(hp){
var existing = document.getElementById('_waAttMenu');
if(existing){ existing.remove(); return; } // toggle
var menu = document.createElement('div');
menu.id = '_waAttMenu';
menu.style.cssText = 'position:absolute;bottom:60px;left:10px;background:#fff;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:8px;display:flex;gap:8px;z-index:10000';
menu.innerHTML = ''
+ ' Kamera '
+ ' Galeri '
+ ' Dokumen ';
var thread = document.getElementById('wa_chat_thread');
if(thread) thread.parentElement.appendChild(menu);
// Auto-close kalau klik di luar
setTimeout(function(){
document.addEventListener('click', function _closeMenu(e){
if(menu && !menu.contains(e.target)){
if(menu.parentElement) menu.remove();
document.removeEventListener('click', _closeMenu);
}
});
}, 100);
};
// Handle file dipilih → validate → preview
window.waChatHandleAttachment = function(event){
var file = event.target.files && event.target.files[0];
if(!file) return;
event.target.value = ''; // reset input biar bisa pilih file yang sama lagi
// Validate type
var allowedType = WA_ATT_ALLOWED_TYPES[file.type];
if(!allowedType){
alert('Tipe file tidak didukung. Hanya: JPG, PNG, WebP, MP4, PDF.');
return;
}
// Validate size
if(file.size > WA_ATT_MAX_SIZE){
var sizeMb = (file.size / 1024 / 1024).toFixed(1);
alert('File terlalu besar ('+sizeMb+' MB). Maksimal 16 MB.');
return;
}
// Store ke window state (gak masuk ke DOM dataset biar gak ke-serialize)
window._waChatAttachment = {
file: file,
type: file.type,
name: file.name,
size: file.size,
ext: allowedType
};
// Render preview
_waRenderAttPreview();
};
function _waRenderAttPreview(){
var att = window._waChatAttachment;
var box = document.getElementById('_waAttPreview');
var body = document.getElementById('_waAttPreviewBody');
if(!att || !box || !body){ if(box) box.style.display='none'; return; }
box.style.display = 'block';
var sizeMb = (att.size / 1024 / 1024).toFixed(1);
var sizeStr = att.size < 1024*1024 ? (Math.round(att.size/1024)+' KB') : (sizeMb+' MB');
var nameSafe = _wa_escHtml(att.name);
if(att.type.indexOf('image/') === 0){
// Image preview: thumbnail
var url = URL.createObjectURL(att.file);
body.innerHTML = ''
+ ''
+ '
'
+ '
'
+ '
📷 '+nameSafe+'
'
+ '
'+sizeStr+' · siap dikirim
'
+ '
'
+ '
';
} else if(att.type.indexOf('video/') === 0){
// Video preview: video tag (poster frame, no auto-play, hemat memory)
var vurl = URL.createObjectURL(att.file);
body.innerHTML = ''
+ ''
+ '
'
+ ' '
+ ' '
+ '
'
+ '
'
+ '
🎥 '+nameSafe+'
'
+ '
'+sizeStr+' · siap dikirim
'
+ '
'
+ '
';
} else if(att.type === 'application/pdf'){
// PDF preview: icon + filename
body.innerHTML = ''
+ ''
+ '
'
+ ' '
+ '
'
+ '
'
+ '
📄 '+nameSafe+'
'
+ '
'+sizeStr+' · siap dikirim
'
+ '
'
+ '
';
}
}
// Clear attachment
window.waChatClearAttachment = function(){
var att = window._waChatAttachment;
// Revoke object URL untuk free memory
if(att && att.file){
try{
var imgs = document.querySelectorAll('#_waAttPreviewBody img, #_waAttPreviewBody video');
imgs.forEach(function(el){
if(el.src && el.src.startsWith('blob:')) URL.revokeObjectURL(el.src);
});
}catch(e){}
}
window._waChatAttachment = null;
var box = document.getElementById('_waAttPreview');
if(box){ box.style.display = 'none'; box.querySelector('#_waAttPreviewBody').innerHTML = ''; }
};
// ════════════════════════════════════════════════════════════════
// SEND REPLY (text only ATAU text + attachment)
// ════════════════════════════════════════════════════════════════
window.waChatSendReply = async function(hp){
var msgEl = document.getElementById('wa_chat_reply');
var msg = (msgEl?.value || '').trim();
var att = window._waChatAttachment;
// Validate: minimal harus ada text atau attachment
if(!msg && !att){
_wa_toast('Ketik pesan atau lampirkan file dulu','err');
return;
}
// Security check: kalau marketing, pastikan customer ini dia handle
var myName = _wa_normLc((typeof CU!=='undefined' && CU && CU.name));
var mktHandler = null;
if(_wa_canMarketing() && !_wa_canFull()){
var foundCust = (window.DB_CUSTOMER||[]).find(function(c){
return _wa_normalizeHp(c.hp) === _wa_normalizeHp(hp);
});
var custMkt = foundCust ? _wa_normLc(foundCust.mkt) : '';
if(custMkt !== myName){
// Cek juga di chat history
try{
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
var r = await client.from('blp_wa_chat').select('mkt_handler').eq('hp', _wa_normalizeHp(hp))
.not('mkt_handler','is',null).order('updated_at',{ascending:false}).limit(1);
var chatMkt = r.data && r.data[0] ? _wa_normLc(r.data[0].mkt_handler) : '';
if(chatMkt !== myName){
alert('Customer ini bukan handle Anda.'); return;
}
}catch(e){ alert('Customer ini bukan handle Anda.'); return; }
}
mktHandler = (typeof CU!=='undefined' && CU && CU.name);
}
// Disable send button + show loading
var sendBtn = document.getElementById('_waSendBtn');
if(sendBtn){
sendBtn.disabled = true;
sendBtn.style.opacity = '0.6';
sendBtn.innerHTML = ' ';
}
var res;
var attLabel = '';
try{
if(att){
// Send dengan attachment
attLabel = ' [📎 '+att.name+']';
if(att.size > 5*1024*1024){
_wa_toast('Mengupload '+(att.size/1024/1024).toFixed(1)+' MB...','ok');
}
res = await fonnteSendAttachment(hp, msg, {blob: att.file, filename: att.name}, {kind:'manual_chat_att', bypassRole:true});
} else {
// Text only
res = await fonnteSend(hp, msg, {kind:'manual_chat', bypassRole:true});
}
} catch(e){
res = {ok:false, err: e.message||String(e)};
}
// Re-enable button
if(sendBtn){
sendBtn.disabled = false;
sendBtn.style.opacity = '1';
sendBtn.innerHTML = ' ';
}
if(res.ok){
_wa_toast('✅ Terkirim'+(att?' (dgn lampiran)':''),'ok');
try{
var client2 = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
// Save reply ke chat history (tambahkan label attachment di message kalau ada)
var chatContent = msg + (att && msg ? '\n' : '') + attLabel;
await client2.from('blp_wa_chat').insert({
hp:_wa_normalizeHp(hp), role:'assistant', content: chatContent.trim() || attLabel.trim(),
mkt_handler: mktHandler,
handover_state: 'handed_over',
created_at: new Date().toISOString(), updated_at: new Date().toISOString()
});
// Auto-pause bot untuk semua row chat customer ini
await client2.from('blp_wa_chat').update({handover_state:'handed_over'})
.eq('hp', _wa_normalizeHp(hp));
}catch(e){ console.warn('[WA] save reply err:', e); }
// Clear input & attachment
if(msgEl) msgEl.value = '';
window.waChatClearAttachment();
waInboxOpen(hp);
} else {
alert('❌ '+(res.err || 'Gagal kirim'));
}
};
// ────────────── TAB: LOG ──────────────
// ────────────── TAB: TEMPLATES (Manage Library) ──────────────
function _waTabTemplates(c){
var notSetup = !window._waTemplates || !Array.isArray(window._waTemplates);
return `
📝 Template Library
Tambah Template
Template reusable untuk Kirim manual, Broadcast, & Quick Reply Inbox.
Variabel didukung: {nama} {perusahaan} {kota} {marketing} {tagihan} {sc_aktif}
Semua
Umum
Follow-up
Invoice
Reminder
Promo
Quick Reply
`;
}
window._waTplActiveFilter = '';
window.waTplFilter = function(kat){
window._waTplActiveFilter = kat;
// Update button styles
document.querySelectorAll('[id^="_waTplFilterAll"], [onclick^="waTplFilter"]').forEach(function(btn){
if(btn.onclick && btn.onclick.toString().indexOf("'"+kat+"'") >= 0){
btn.style.background = '#25D366'; btn.style.color = '#fff'; btn.style.borderColor = '#25D366';
} else {
btn.style.background = '#fff'; btn.style.color = 'var(--text)'; btn.style.borderColor = '#E5E7EB';
}
});
waTplRender();
};
window.waTplRender = async function(){
var box = document.getElementById('wa_tpl_list');
if(!box) return;
box.innerHTML = 'Memuat...
';
try{
// Reload dari Supabase supaya fresh
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){
box.innerHTML = 'Offline — tidak bisa load template
';
return;
}
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client) return;
var r = await client.from('blp_wa_template').select('*').is('deleted_at',null).order('urut',{ascending:true});
if(r.error){
if(r.error.code === '42P01'){
box.innerHTML = ''
+ '
⚠️ Tabel blp_wa_template belum dibuat. '
+ 'Owner perlu eksekusi SQL setup dulu:
'
+ '
'
+ 'Buka Supabase Dashboard → SQL Editor '
+ 'Eksekusi file 04_wa_templates.sql '
+ 'Refresh tab ini '
+ ' '
+ '
';
} else {
box.innerHTML = ''+r.error.message+'
';
}
return;
}
window._waTemplates = r.data || [];
var list = window._waTemplates;
var filter = window._waTplActiveFilter || '';
if(filter) list = list.filter(function(t){return t.kategori === filter;});
if(!list.length){
box.innerHTML = 'Belum ada template'+(filter?' di kategori "'+filter+'"':'')+'.
Klik "Tambah Template" untuk membuat baru.
';
return;
}
var katColor = {
umum: '#64748B', follow_up: '#0369A1', invoice: '#DC2626',
reminder: '#D97706', promo: '#7C3AED', quick_reply: '#15803D'
};
var html = list.map(function(t){
var color = katColor[t.kategori] || '#64748B';
var lastUsed = t.last_used ? new Date(t.last_used).toLocaleDateString('id-ID',{day:'numeric',month:'short'}) : '';
var preview = (t.body||'').slice(0, 100).replace(/\n/g,' ');
var aktifBadge = t.aktif ? '' : 'Non-aktif ';
return ''
+ '
'
+ '
'
+ '
'
+ ''+_wa_escHtml(t.nama)+' '
+ ''+(t.kategori||'umum')+' '
+ (t.shortcut ? '/'+t.shortcut+'' : '')
+ aktifBadge
+ '
'
+ '
'+_wa_escHtml(preview)+(t.body && t.body.length > 100?'...':'')+'
'
+ '
'
+ ' Dipakai '+(t.use_count||0)+'x '
+ (lastUsed ? ' Terakhir: '+lastUsed+' ' : '')
+ '
'
+ '
'
+ '
'
+ ' '
+ ' '
+ '
'
+ '
'
+ '
';
}).join('');
box.innerHTML = html;
}catch(e){
box.innerHTML = 'Error: '+_wa_escHtml(e.message)+'
';
}
};
// Open modal tambah/edit template
window.waTplOpenEdit = function(templateId){
var isEdit = !!templateId;
var t = isEdit ? (window._waTemplates||[]).find(function(x){return x.id===templateId;}) : null;
if(isEdit && !t){ alert('Template tidak ditemukan'); return; }
var data = t || {id:'', nama:'', kategori:'umum', body:'', shortcut:'', aktif:true, urut:0};
if(typeof openSheet !== 'function'){ alert('Modal tidak tersedia'); return; }
var katOpts = ['umum','follow_up','invoice','reminder','promo','quick_reply'].map(function(k){
return ''+k+' ';
}).join('');
openSheet((isEdit?'✏️ Edit':'➕ Tambah')+' Template Pesan',
''
+ '
'
+ '
'
+ '
Isi Pesan *
'
+ '
'
+ '
'
+ 'Klik untuk insert: '
+ ['nama','perusahaan','kota','marketing','tagihan','sc_aktif'].map(function(v){
return '{'+v+'}';
}).join('')
+ '
'
+ '
'
+ '
'
+ '
'
+ ' '+(isEdit?'Update Template':'Simpan Template')
+ ' '
+ '
'
);
};
window._waTplInsertVar = function(varName){
var ta = document.getElementById('_waTplBody');
if(!ta) return;
var start = ta.selectionStart;
var end = ta.selectionEnd;
ta.value = ta.value.substring(0, start) + varName + ta.value.substring(end);
ta.focus();
ta.selectionStart = ta.selectionEnd = start + varName.length;
};
window.waTplSave = async function(editId){
var nama = (document.getElementById('_waTplNama')?.value||'').trim();
var kategori = (document.getElementById('_waTplKategori')?.value||'umum').trim();
var body = (document.getElementById('_waTplBody')?.value||'').trim();
var shortcut = (document.getElementById('_waTplShortcut')?.value||'').trim();
var urut = parseInt(document.getElementById('_waTplUrut')?.value||'0') || 0;
var aktif = !!document.getElementById('_waTplAktif')?.checked;
if(!nama){ alert('Nama template wajib diisi'); return; }
if(!body){ alert('Isi pesan wajib diisi'); return; }
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){
alert('Supabase offline, tidak bisa simpan');
return;
}
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client) return;
var now = new Date().toISOString();
var userId = (typeof blpSupabase.session === 'object' && blpSupabase.session && blpSupabase.session.user) ? blpSupabase.session.user.id : null;
var row = {
nama: nama,
kategori: kategori,
body: body,
shortcut: shortcut || null,
urut: urut,
aktif: aktif,
updated_at: now,
updated_by: userId
};
try{
var res;
if(editId){
res = await client.from('blp_wa_template').update(row).eq('id', editId);
} else {
row.id = 'tpl_' + Date.now();
row.created_at = now;
row.use_count = 0;
res = await client.from('blp_wa_template').insert(row);
}
if(res.error){
if(res.error.code === '42P01'){
alert('❌ Tabel blp_wa_template belum dibuat. Eksekusi SQL 04_wa_templates.sql dulu.');
} else {
alert('❌ Gagal simpan: ' + res.error.message);
}
return;
}
if(typeof audit === 'function'){
audit(editId?'edit_template':'create_template', 'wa_template', row.id || editId, 'Template "'+nama+'" '+(editId?'diupdate':'dibuat'), {kategori:kategori});
}
if(typeof closeOverlay === 'function') closeOverlay();
if(typeof _wa_toast === 'function') _wa_toast('✅ Template '+(editId?'diupdate':'disimpan'),'ok');
setTimeout(function(){ waTplRender(); }, 200);
}catch(e){
alert('Error: '+e.message);
}
};
window.waTplDelete = async function(templateId){
var t = (window._waTemplates||[]).find(function(x){return x.id===templateId;});
if(!t){ alert('Template tidak ditemukan'); return; }
if(!confirm('Hapus template "'+t.nama+'"?\n\nTemplate yang dihapus tidak bisa dikembalikan.')) return;
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){ alert('Offline'); return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client) return;
try{
// Soft delete
var res = await client.from('blp_wa_template').update({deleted_at: new Date().toISOString()}).eq('id', templateId);
if(res.error){ alert('❌ Gagal: '+res.error.message); return; }
if(typeof audit === 'function'){
audit('delete_template', 'wa_template', templateId, 'Template "'+t.nama+'" dihapus', {kategori:t.kategori});
}
if(typeof _wa_toast === 'function') _wa_toast('✅ Template dihapus','ok');
setTimeout(function(){ waTplRender(); }, 200);
}catch(e){
alert('Error: '+e.message);
}
};
// ═══════════════════════════════════════════════════════════════════════
// TAB: CONTACTS (Standalone — prospek, supplier, vendor, dll)
// ═══════════════════════════════════════════════════════════════════════
window._waContacts = [];
window._waContactsLoaded = false;
window._waContactFilter = {tipe:'', status:'aktif', q:''};
window._waContactSelected = new Set(); // ID kontak yang sedang dipilih untuk bulk action
function _waTabContacts(c){
return `
📇 Kontak Standalone
Sync BLP
Kontak
Kontak non-customer: prospek, supplier, vendor, mantan customer. Bisa dipakai untuk broadcast & target spesifik.
Semua
🎯 Prospek
📦 Supplier
🚚 Vendor
🤝 Partner
😔 Ex-Customer
Lainnya
`;
}
// ── BULK ACTION HELPERS ──
window.waContactToggleSelect = function(id){
if(window._waContactSelected.has(id)) window._waContactSelected.delete(id);
else window._waContactSelected.add(id);
window._waContactUpdateBulkBar();
// Update checkbox UI saja, tidak perlu re-render full
var cb = document.getElementById('_waCBox_'+id);
if(cb) cb.checked = window._waContactSelected.has(id);
};
window.waContactSelectAllVisible = function(){
var box = document.getElementById('_waContactList');
if(!box) return;
var checkboxes = box.querySelectorAll('input[type="checkbox"][data-cid]');
var allSelected = Array.from(checkboxes).every(function(cb){ return window._waContactSelected.has(cb.dataset.cid); });
checkboxes.forEach(function(cb){
if(allSelected) window._waContactSelected.delete(cb.dataset.cid);
else window._waContactSelected.add(cb.dataset.cid);
cb.checked = !allSelected;
});
window._waContactUpdateBulkBar();
};
window.waContactBulkClear = function(){
window._waContactSelected.clear();
window._waContactUpdateBulkBar();
var box = document.getElementById('_waContactList');
if(box) box.querySelectorAll('input[type="checkbox"][data-cid]').forEach(function(cb){ cb.checked = false; });
};
window._waContactUpdateBulkBar = function(){
var bar = document.getElementById('_waContactBulkBar');
var cnt = document.getElementById('_waContactBulkCount');
var n = window._waContactSelected.size;
if(!bar) return;
if(n > 0){
bar.style.display = 'flex';
if(cnt) cnt.textContent = n + ' dipilih';
} else {
bar.style.display = 'none';
}
};
window.waContactBulkSetTipe = async function(){
var n = window._waContactSelected.size;
if(!n) return;
var opts = [
['prospek','🎯 Prospek'],['supplier','📦 Supplier'],['vendor','🚚 Vendor'],
['partner','🤝 Partner'],['ex_customer','😔 Ex-Customer'],['other','👤 Lainnya']
];
var prompt_txt = 'Pilih tipe baru untuk '+n+' kontak:\n\n';
opts.forEach(function(o,i){ prompt_txt += (i+1)+'. '+o[1]+'\n'; });
prompt_txt += '\nKetik angka (1-'+opts.length+'):';
var ans = prompt(prompt_txt);
if(!ans) return;
var idx = parseInt(ans, 10) - 1;
if(isNaN(idx) || idx < 0 || idx >= opts.length){ alert('Pilihan tidak valid'); return; }
var newTipe = opts[idx][0];
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){ alert('Offline'); return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client) return;
var ids = Array.from(window._waContactSelected);
var updated = 0, failed = 0;
for(var i=0; i'
+ ''+_wa_escHtml(c.nama)+'
'
+ ''+_wa_escHtml(c.hp)+(c.perusahaan?' • '+_wa_escHtml(c.perusahaan):'')+'
'
+ ''
+ ''
+ '
⏳ Memuat riwayat...
'
+ '
'
);
try{
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){
document.getElementById('_waHistList').innerHTML = 'Offline — tidak bisa load riwayat
';
return;
}
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client) return;
// Query log by target HP (in: cocokkan beberapa varian)
var r = await client.from('blp_wa_log').select('*').in('target', hpVariants).order('created_at', {ascending:false}).limit(200);
if(r.error){
document.getElementById('_waHistList').innerHTML = 'Error: '+_wa_escHtml(r.error.message)+'
';
return;
}
var logs = r.data || [];
var box = document.getElementById('_waHistList');
if(!box) return;
if(!logs.length){
box.innerHTML = ''
+ '
'
+ '
Belum ada riwayat interaksi WA dengan kontak ini.
'
+ '
';
return;
}
// Stats
var totalSent = logs.filter(function(l){return l.success;}).length;
var totalFail = logs.length - totalSent;
var byKind = {};
logs.forEach(function(l){ byKind[l.kind||'?'] = (byKind[l.kind||'?']||0) + 1; });
var kindStr = Object.keys(byKind).map(function(k){return k+': '+byKind[k];}).join(', ');
var lastSent = logs[0]?.created_at;
var stats = ''
+ '
Total: '+logs.length+' pesan (sukses '+totalSent+(totalFail?', gagal '+totalFail:'')+')
'
+ '
Per jenis: '+kindStr+'
'
+ (lastSent ? '
Terakhir: '+new Date(lastSent).toLocaleString('id-ID')+'
' : '')
+ '
';
var kindMap = {
manual: {emoji:'✋', label:'Manual', color:'#0369A1'},
broadcast: {emoji:'📢', label:'Broadcast', color:'#7C3AED'},
queue: {emoji:'⏰', label:'Terjadwal', color:'#D97706'},
reminder: {emoji:'⏰', label:'Reminder', color:'#D97706'},
ai_reply: {emoji:'🤖', label:'AI Reply', color:'#15803D'},
handover: {emoji:'🙋', label:'Handover', color:'#DC2626'},
bot: {emoji:'🤖', label:'Bot', color:'#15803D'},
'auto-reminder':{emoji:'⏰', label:'Auto Reminder', color:'#D97706'}
};
var timeline = logs.map(function(l){
var k = kindMap[l.kind] || {emoji:'💬', label:l.kind||'?', color:'#64748B'};
var dt = l.created_at ? new Date(l.created_at) : null;
var dtStr = dt ? dt.toLocaleString('id-ID',{day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '-';
var statusBadge = l.success
? '✓ terkirim '
: '✗ gagal ';
var sentBy = l.sent_by ? 'oleh '+_wa_escHtml(l.sent_by)+' ' : '';
return ''
+ '
'
+ ''+k.emoji+' '+k.label+' '
+ statusBadge
+ ''+dtStr+' '
+ sentBy
+ '
'
+ '
'+_wa_escHtml(l.message||'(kosong)')+'
'
+ '
';
}).join('');
box.innerHTML = stats + timeline + (logs.length >= 200 ? '(Hanya 200 terbaru ditampilkan)
' : '');
}catch(e){
var bx = document.getElementById('_waHistList');
if(bx) bx.innerHTML = 'Error: '+_wa_escHtml(e.message)+'
';
}
};
window.waContactFilterTipe = function(tipe){
window._waContactFilter.tipe = tipe;
['','prospek','supplier','vendor','partner','ex_customer','other'].forEach(function(t){
var btn = document.getElementById('_waCFT_'+(t||'all'));
if(!btn) return;
var active = (window._waContactFilter.tipe === t);
btn.style.background = active ? '#25D366' : '#fff';
btn.style.color = active ? '#fff' : 'var(--text)';
btn.style.borderColor = active ? '#25D366' : '#E5E7EB';
btn.style.borderWidth = active ? '1.5px' : '1px';
});
waContactRender();
};
window.waContactSearchDebounced = function(){
if(window._waContactSearchTimer) clearTimeout(window._waContactSearchTimer);
window._waContactSearchTimer = setTimeout(function(){
window._waContactFilter.q = (document.getElementById('_waContactSearch')?.value || '').trim().toLowerCase();
waContactRender();
}, 300);
};
window.waContactRender = async function(){
var box = document.getElementById('_waContactList');
var stats = document.getElementById('_waContactStats');
if(!box) return;
box.innerHTML = 'Memuat...
';
try{
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){
box.innerHTML = 'Offline — tidak bisa load kontak
';
return;
}
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client) return;
var r = await client.from('blp_contact').select('*').is('deleted_at',null).order('created_at',{ascending:false}).limit(1000);
if(r.error){
if(r.error.code === '42P01'){
box.innerHTML = ''
+ '⚠️ Tabel blp_contact belum dibuat. '
+ 'Eksekusi SQL 06_contact_table.sql di Supabase Dashboard dulu, lalu refresh tab ini.'
+ '
';
return;
}
box.innerHTML = ''+r.error.message+'
';
return;
}
window._waContacts = r.data || [];
window._waContactsLoaded = true;
// Filter client-side
var filter = window._waContactFilter;
var list = window._waContacts.filter(function(c){
if(filter.tipe && c.tipe !== filter.tipe) return false;
if(filter.status && c.status !== filter.status) return false;
if(filter.q){
var hay = (c.nama+' '+c.perusahaan+' '+c.hp+' '+c.email+' '+c.kota+' '+(c.tags||[]).join(' ')).toLowerCase();
if(hay.indexOf(filter.q) < 0) return false;
}
return true;
});
// Stats
if(stats){
var total = window._waContacts.length;
stats.innerHTML = 'Total: '+total.toLocaleString('id-ID')+' kontak'+(list.length !== total ? ' • Ditampilkan: '+list.length.toLocaleString('id-ID')+' ' : '');
}
if(!list.length){
box.innerHTML = 'Belum ada kontak'+(filter.q || filter.tipe?' sesuai filter':'')+'.
';
return;
}
var tipeMap = {
prospek: {emoji:'🎯', color:'#0369A1'},
supplier: {emoji:'📦', color:'#7C3AED'},
vendor: {emoji:'🚚', color:'#D97706'},
partner: {emoji:'🤝', color:'#15803D'},
ex_customer: {emoji:'😔', color:'#64748B'},
other: {emoji:'👤', color:'#64748B'}
};
var html = list.map(function(c){
var t = tipeMap[c.tipe] || tipeMap.other;
var statusBadge = c.status==='blacklist' ? '🚫 BL '
: c.status==='non_aktif' ? 'Non-aktif '
: '';
var tagsBadges = (c.tags||[]).slice(0,3).map(function(tg){
return '#'+_wa_escHtml(tg)+' ';
}).join('');
var isSel = window._waContactSelected.has(c.id);
return ''
+ '
'
+ '
'+t.emoji+'
'
+ '
'
+ '
'+_wa_escHtml(c.nama)+statusBadge+tagsBadges+'
'
+ '
'+(c.perusahaan?_wa_escHtml(c.perusahaan)+' • ':'')+_wa_escHtml(c.hp||'')+(c.kota?' • '+_wa_escHtml(c.kota):'')+'
'
+ '
'
+ '
'
+ (c.hp ? ' ' : '')
+ (c.hp ? ' ' : '')
+ ' '
+ ' '
+ '
'
+ '
';
}).join('');
// Header dengan "Pilih semua" kalau ada hasil
var headerSelect = '';
box.innerHTML = headerSelect + html;
// Update bulk bar state (kalau row sebelumnya yang dipilih sudah tidak terlihat karena filter)
window._waContactUpdateBulkBar();
}catch(e){
box.innerHTML = 'Error: '+_wa_escHtml(e.message)+'
';
}
};
window.waContactOpenEdit = function(contactId){
var isEdit = !!contactId;
var c = isEdit ? (window._waContacts||[]).find(function(x){return x.id===contactId;}) : null;
if(isEdit && !c){ alert('Kontak tidak ditemukan'); return; }
var d = c || {id:'', nama:'', perusahaan:'', hp:'', email:'', kota:'', alamat:'', jabatan:'', tipe:'prospek', status:'aktif', tags:[], source:'', notes:''};
if(typeof openSheet !== 'function'){ alert('Modal tidak tersedia'); return; }
var tipeOpts = [['prospek','🎯 Prospek'],['supplier','📦 Supplier'],['vendor','🚚 Vendor'],['partner','🤝 Partner'],['ex_customer','😔 Ex-Customer'],['other','👤 Lainnya']].map(function(t){
return ''+t[1]+' ';
}).join('');
var statusOpts = [['aktif','Aktif'],['non_aktif','Non-aktif'],['blacklist','🚫 Blacklist']].map(function(s){
return ''+s[1]+' ';
}).join('');
openSheet((isEdit?'✏️ Edit':'➕ Tambah')+' Kontak',
''
+ '
'
+ '
'
+ '
'
+ '
'
+ '
'
+ '
'
+ '
'
+ '
'
+ '
'+(isEdit?'Update':'Simpan')+' Kontak'
+ '
'
);
};
window.waContactSave = async function(editId){
var nama = (document.getElementById('_wcNama')?.value||'').trim();
var hp = (document.getElementById('_wcHp')?.value||'').trim();
if(!nama){ alert('Nama wajib diisi'); return; }
if(!hp){ alert('No HP wajib diisi'); return; }
var tags = (document.getElementById('_wcTags')?.value||'').split(',').map(function(t){return t.trim();}).filter(Boolean);
var row = {
nama: nama,
hp: hp,
perusahaan: (document.getElementById('_wcPerush')?.value||'').trim(),
email: (document.getElementById('_wcEmail')?.value||'').trim(),
kota: (document.getElementById('_wcKota')?.value||'').trim(),
alamat: (document.getElementById('_wcAlamat')?.value||'').trim(),
jabatan: (document.getElementById('_wcJabatan')?.value||'').trim(),
tipe: document.getElementById('_wcTipe')?.value || 'prospek',
status: document.getElementById('_wcStatus')?.value || 'aktif',
tags: tags,
source: (document.getElementById('_wcSource')?.value||'').trim(),
notes: (document.getElementById('_wcNotes')?.value||'').trim(),
updated_at: new Date().toISOString()
};
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){ alert('Offline'); return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client) return;
var userId = (blpSupabase.session && blpSupabase.session.user) ? blpSupabase.session.user.id : null;
row.updated_by = userId;
try{
var res;
if(editId){
res = await client.from('blp_contact').update(row).eq('id', editId);
} else {
row.id = 'ct_' + Date.now() + '_' + Math.random().toString(36).slice(2,6);
row.created_at = new Date().toISOString();
row.created_by = userId;
res = await client.from('blp_contact').insert(row);
}
if(res.error){ alert('❌ '+res.error.message); return; }
if(typeof audit === 'function') audit(editId?'edit_contact':'create_contact', 'contact', row.id||editId, 'Kontak "'+nama+'"', {tipe:row.tipe});
if(typeof closeOverlay === 'function') closeOverlay();
if(typeof _wa_toast === 'function') _wa_toast('✅ Kontak '+(editId?'diupdate':'disimpan'),'ok');
setTimeout(function(){ waContactRender(); }, 200);
}catch(e){ alert('Error: '+e.message); }
};
window.waContactDelete = async function(contactId){
var c = (window._waContacts||[]).find(function(x){return x.id===contactId;});
if(!c){ alert('Kontak tidak ditemukan'); return; }
if(!confirm('Hapus kontak "'+c.nama+'"?\nAksi ini tidak bisa di-undo.')) return;
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){ alert('Offline'); return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client) return;
try{
var res = await client.from('blp_contact').update({deleted_at: new Date().toISOString()}).eq('id', contactId);
if(res.error){ alert('❌ '+res.error.message); return; }
if(typeof audit === 'function') audit('delete_contact', 'contact', contactId, 'Kontak "'+c.nama+'" dihapus', {});
if(typeof _wa_toast === 'function') _wa_toast('✅ Kontak dihapus','ok');
setTimeout(function(){ waContactRender(); }, 200);
}catch(e){ alert('Error: '+e.message); }
};
// Quick send WA ke kontak (pakai modal Phase 1)
window.waContactQuickSend = function(contactId){
var c = (window._waContacts||[]).find(function(x){return x.id===contactId;});
if(!c){ alert('Kontak tidak ditemukan'); return; }
if(!c.hp){ alert('Kontak belum punya HP'); return; }
// Convert contact ke format mirip customer untuk dipakai modal kirim
var pseudoCust = {
id: c.id,
nama: c.nama,
perusahaan: c.perusahaan,
kota: c.kota,
mkt: (typeof CU!=='undefined' && CU && CU.name) || '',
hp: c.hp,
contacts: []
};
var allHps = [{nama: c.nama, hp: c.hp, role: c.jabatan || 'Kontak', isFromMultiContact: false, isPrimary: true}];
if(typeof _waOpenSendModal === 'function') _waOpenSendModal(pseudoCust, allHps);
else alert('Modal kirim tidak tersedia');
};
// Export CSV
window.waContactExport = function(){
var list = window._waContacts || [];
if(!list.length){ alert('Belum ada kontak untuk di-export'); return; }
var headers = ['nama','perusahaan','hp','email','kota','alamat','jabatan','tipe','status','tags','source','notes'];
var rows = list.map(function(c){
return headers.map(function(h){
var v = c[h];
if(Array.isArray(v)) v = v.join('|');
v = String(v||'').replace(/"/g,'""');
return '"'+v+'"';
}).join(',');
});
var csv = headers.join(',') + '\n' + rows.join('\n');
var blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = 'kontak_BLP_'+new Date().toISOString().slice(0,10)+'.csv';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// ═══════════════════════════════════════════════════════════════════════
// SYNC dari Data BLP — auto-populate kontak dari DB_CUSTOMER/SUPPLIER/VENDOR
// ═══════════════════════════════════════════════════════════════════════
// Normalize HP: hapus non-digit, prefix '62' kalau perlu, return null kalau invalid
window._waNormalizeHp = function(raw){
if(!raw) return null;
var s = String(raw).replace(/[^0-9]/g,'');
if(!s) return null;
if(s.length < 8) return null; // pendek banget — invalid
if(s.startsWith('62')) return s;
if(s.startsWith('0')) return '62' + s.slice(1);
if(s.startsWith('8')) return '62' + s;
return s; // angka lain — biarkan, mungkin int'l
};
// Build preview: hitung berapa rekord per sumber yang punya HP valid
window._waSyncPreview = function(){
// PENTING: pakai referensi langsung (typeof check) karena const top-level
// di WebKit/Safari tidak otomatis ter-attach ke window.
var _C = (typeof DB_CUSTOMER !== 'undefined') ? DB_CUSTOMER : (window.DB_CUSTOMER || []);
var _S = (typeof DB_SUPPLIER !== 'undefined') ? DB_SUPPLIER : (window.DB_SUPPLIER || []);
var _V = (typeof DB_VENDOR !== 'undefined') ? DB_VENDOR : (window.DB_VENDOR || []);
var custs = _C.filter(function(c){ return window._waNormalizeHp(c.hp); });
var supps = _S.filter(function(s){ return window._waNormalizeHp(s.hp); });
var vends = _V.filter(function(v){ return window._waNormalizeHp(v.hp || v.pic); });
return { customer: custs.length, supplier: supps.length, vendor: vends.length,
totalCustomer: _C.length, totalSupplier: _S.length, totalVendor: _V.length };
};
window.waContactSyncDialog = async function(){
if(typeof openSheet !== 'function') return;
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){
alert('⚠️ Offline. Sync kontak butuh koneksi Supabase.');
return;
}
// ── AUTO-LOAD BLP DATA kalau kosong (sering kejadian saat user buka WA Bot duluan sebelum data BLP loaded) ──
// PENTING: gunakan referensi langsung (bukan window.DB_CUSTOMER) karena const top-level
// di WebKit/Safari tidak otomatis ter-attach ke window object.
var _custRef = (typeof DB_CUSTOMER !== 'undefined') ? DB_CUSTOMER : (window.DB_CUSTOMER || []);
var _suppRef = (typeof DB_SUPPLIER !== 'undefined') ? DB_SUPPLIER : (window.DB_SUPPLIER || []);
var _vendRef = (typeof DB_VENDOR !== 'undefined') ? DB_VENDOR : (window.DB_VENDOR || []);
var custEmpty = !Array.isArray(_custRef) || _custRef.length === 0;
var suppEmpty = !Array.isArray(_suppRef) || _suppRef.length === 0;
var vendEmpty = !Array.isArray(_vendRef) || _vendRef.length === 0;
if(custEmpty || suppEmpty || vendEmpty){
// Tampilkan loading sheet dulu
openSheet('🔄 Sync Kontak dari Data BLP',
''
+ '
⏳
'
+ '
Memuat data BLP dari cloud...
'
+ '
Customer, Supplier, Vendor sedang ditarik dari Supabase. Tunggu sebentar.
'
+ '
'
);
var loadResults = {};
try{
var toLoad = [];
if(custEmpty) toLoad.push('blp_customer');
if(suppEmpty) toLoad.push('blp_supplier');
if(vendEmpty) toLoad.push('blp_vendor');
console.log('[Sync Auto-load] Loading tables:', toLoad);
// Load parallel dan capture hasil per tabel
await Promise.all(toLoad.map(function(t){
return blpSupabase.fetchMergeTable(t).then(function(res){
loadResults[t] = {ok: !res || !res.err, result: res};
console.log('[Sync Auto-load] '+t+' result:', res);
}).catch(function(e){
loadResults[t] = {ok: false, err: e && e.message};
console.warn('[Sync Auto-load] '+t+' gagal:', e && e.message);
});
}));
}catch(e){
console.warn('[Sync Auto-load] Top-level error:', e);
}
// Refresh referensi setelah load
var _custRef2 = (typeof DB_CUSTOMER !== 'undefined') ? DB_CUSTOMER : (window.DB_CUSTOMER || []);
var _suppRef2 = (typeof DB_SUPPLIER !== 'undefined') ? DB_SUPPLIER : (window.DB_SUPPLIER || []);
var _vendRef2 = (typeof DB_VENDOR !== 'undefined') ? DB_VENDOR : (window.DB_VENDOR || []);
console.log('[Sync Auto-load] Final memory state:', {
customer: _custRef2.length,
supplier: _suppRef2.length,
vendor: _vendRef2.length
});
var stillEmpty = (!Array.isArray(_custRef2) || _custRef2.length === 0)
&& (!Array.isArray(_suppRef2) || _suppRef2.length === 0)
&& (!Array.isArray(_vendRef2) || _vendRef2.length === 0);
if(stillEmpty){
if(typeof closeOverlay === 'function') closeOverlay();
// Build detailed error message
var diagMsg = '⚠️ Data BLP gagal di-load.\n\n';
diagMsg += 'Hasil load per tabel:\n';
['blp_customer','blp_supplier','blp_vendor'].forEach(function(t){
var r = loadResults[t];
if(!r){ diagMsg += '• '+t+': (tidak di-load)\n'; return; }
if(r.ok) diagMsg += '• '+t+': OK tapi tidak masuk memory\n';
else diagMsg += '• '+t+': '+(r.err || (r.result && r.result.err) || 'unknown')+'\n';
});
diagMsg += '\nMemory state:\n';
diagMsg += '• DB_CUSTOMER: '+(window.DB_CUSTOMER||[]).length+' rows\n';
diagMsg += '• DB_SUPPLIER: '+(window.DB_SUPPLIER||[]).length+' rows\n';
diagMsg += '• DB_VENDOR: '+(window.DB_VENDOR||[]).length+' rows\n';
diagMsg += '\nBuka console (F12) untuk detail. Coba buka tab Customer dulu, lalu sync lagi.';
alert(diagMsg);
return;
}
// Tutup loading sheet dulu, lalu buka dialog sync (recursive call dengan data sudah ready)
if(typeof closeOverlay === 'function') closeOverlay();
// Tunggu sebentar biar overlay benar-benar tertutup sebelum buka dialog baru
setTimeout(function(){ window.waContactSyncDialog(); }, 150);
return;
}
// === Dialog Sync normal (data sudah ready) ===
var p = window._waSyncPreview();
openSheet('🔄 Sync Kontak dari Data BLP',
''
+ 'Apa yang akan dilakukan: '
+ '• Tarik data dari Customer, Supplier, Vendor BLP yang punya HP. '
+ '• Tambahkan ke daftar kontak dengan tipe yang sesuai (prospek/supplier/vendor). '
+ '• Tag auto: BLP_CUSTOMER, BLP_SUPPLIER, atau BLP_VENDOR + info kelas/lokasi. '
+ '• Kontak yang sudah pernah di-sync (cek by ID) akan di-update , bukan dibuat ulang. '
+ '• Aman dijalankan berkali-kali (idempotent).'
+ '
'
+ ''
+ '
SUMBER DATA
'
+ '
'
+ ' '
+ ''
+ '
👥 Customer BLP
'
+ '
'+p.customer+' dari '+p.totalCustomer+' punya HP valid → tipe prospek
'
+ '
'
+ ' '
+ '
'
+ ' '
+ ''
+ '
📦 Supplier BLP
'
+ '
'+p.supplier+' dari '+p.totalSupplier+' punya HP valid → tipe supplier
'
+ '
'
+ ' '
+ '
'
+ ' '
+ ''
+ '
🚚 Vendor BLP
'
+ '
'+p.vendor+' dari '+p.totalVendor+' punya HP valid → tipe vendor
'
+ '
'
+ ' '
+ '
'
+ '
'
+ ' Mulai Sync '
);
};
window.waContactSyncExecute = async function(){
var doCust = document.getElementById('_waSyncCust')?.checked;
var doSupp = document.getElementById('_waSyncSupp')?.checked;
var doVend = document.getElementById('_waSyncVend')?.checked;
var status = document.getElementById('_waSyncStatus');
if(!doCust && !doSupp && !doVend){
if(status) status.innerHTML = '⚠️ Pilih minimal 1 sumber ';
return;
}
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){
if(status) status.innerHTML = '❌ Offline ';
return;
}
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client){
if(status) status.innerHTML = '❌ Gagal init Supabase ';
return;
}
var userId = (blpSupabase.session && blpSupabase.session.user) ? blpSupabase.session.user.id : null;
var now = new Date().toISOString();
var rows = [];
// === CUSTOMER ===
if(doCust){
(window.DB_CUSTOMER||[]).forEach(function(c){
var hp = window._waNormalizeHp(c.hp);
if(!hp) return;
var tags = ['BLP_CUSTOMER'];
if(c.kelas) tags.push('kelas:'+c.kelas);
if(c.kota) tags.push('kota:'+c.kota);
if(c.kode) tags.push('kode:'+c.kode);
rows.push({
id: 'ct_blp_cust_'+c.id,
nama: c.nama || c.initial || '(tanpa nama)',
hp: hp,
perusahaan: c.perusahaan || '',
email: c.email || '',
kota: c.kota || '',
alamat: c.alamat || '',
jabatan: '',
tipe: 'prospek',
status: 'aktif',
tags: tags,
source: 'BLP Customer',
notes: c.kode ? ('Kode: '+c.kode) : '',
created_at: now,
updated_at: now,
created_by: userId,
updated_by: userId
});
});
}
// === SUPPLIER ===
if(doSupp){
(window.DB_SUPPLIER||[]).forEach(function(s){
var hp = window._waNormalizeHp(s.hp);
if(!hp) return;
var tags = ['BLP_SUPPLIER'];
if(s.kode) tags.push('kode:'+s.kode);
if(s.alamat) tags.push('lokasi:'+s.alamat);
rows.push({
id: 'ct_blp_supp_'+s.id,
nama: s.pic || s.nama || '(tanpa nama)',
hp: hp,
perusahaan: s.nama || '',
email: '',
kota: s.alamat || '',
alamat: s.alamatLengkap || s.alamat || '',
jabatan: 'PIC Supplier',
tipe: 'supplier',
status: 'aktif',
tags: tags,
source: 'BLP Supplier',
notes: [s.produk?'Produk: '+s.produk:'', s.bank?'Bank: '+s.bank+' '+(s.noRek||''):''].filter(Boolean).join(' | '),
created_at: now,
updated_at: now,
created_by: userId,
updated_by: userId
});
});
}
// === VENDOR ===
if(doVend){
(window.DB_VENDOR||[]).forEach(function(v){
var hp = window._waNormalizeHp(v.hp || v.pic);
if(!hp) return;
var tags = ['BLP_VENDOR'];
if(v.kategori) tags.push('kategori:'+v.kategori);
if(v.kode) tags.push('kode:'+v.kode);
if(v.lokasi) tags.push('lokasi:'+v.lokasi);
rows.push({
id: 'ct_blp_vend_'+v.id,
nama: v.driver || v.nama || '(tanpa nama)',
hp: hp,
perusahaan: v.nama || '',
email: '',
kota: v.lokasi || '',
alamat: v.alamat || '',
jabatan: v.kategori || 'Vendor',
tipe: 'vendor',
status: 'aktif',
tags: tags,
source: 'BLP Vendor',
notes: [v.unit?'Unit: '+v.unit:'', v.atasNama?'A.N: '+v.atasNama:'', v.bank?'Bank: '+v.bank+' '+(v.noRek||''):'', v.ket||''].filter(Boolean).join(' | '),
created_at: now,
updated_at: now,
created_by: userId,
updated_by: userId
});
});
}
if(!rows.length){
if(status) status.innerHTML = '⚠️ Tidak ada data dengan HP valid untuk di-sync ';
return;
}
if(status) status.innerHTML = '⏳ Sync '+rows.length+' kontak (upsert by id)... ';
// Upsert batch: pakai 'on conflict do update' via Supabase upsert.
// Supabase JS: client.from(t).upsert(rows, {onConflict:'id'})
var inserted = 0, updated = 0, failed = 0;
var BATCH = 200;
for(var i=0; i';
}
if(typeof audit === 'function') audit('sync_blp_contacts', 'contact', 'batch', 'Sync '+inserted+' kontak dari BLP', {cust:doCust, supp:doSupp, vend:doVend, total:rows.length, failed:failed});
if(status){
if(failed === 0){
status.innerHTML = '✅ Sync selesai: '+inserted+' kontak ditambahkan/di-update ';
} else {
status.innerHTML = '⚠️ Selesai dengan '+failed+' gagal. '+inserted+' sukses ';
}
}
if(typeof _wa_toast === 'function') _wa_toast('✅ '+inserted+' kontak ter-sync','ok');
setTimeout(function(){
if(typeof closeOverlay === 'function') closeOverlay();
if(typeof waContactRender === 'function') waContactRender();
}, 1500);
};
// Import CSV (simple)
window.waContactImportDialog = function(){
if(typeof openSheet !== 'function') return;
openSheet('📤 Import Kontak dari CSV',
''
+ 'Format CSV: kolom header wajib: nama, hp. Optional: perusahaan, email, kota, alamat, jabatan, tipe, tags, source, notes. '
+ 'Tags pakai pemisah | (pipe), bukan koma. Tipe: prospek/supplier/vendor/partner/ex_customer/other.'
+ '
'
+ ' '
+ '
'
+ ' Import CSV '
);
};
window.waContactImportExecute = async function(){
var fileInput = document.getElementById('_waContactCsv');
var status = document.getElementById('_waContactImportStatus');
if(!fileInput || !fileInput.files || !fileInput.files[0]){ alert('Pilih file CSV dulu'); return; }
var file = fileInput.files[0];
var text = await file.text();
// Simple CSV parser (handles quoted values dengan koma di dalam)
function parseCsvLine(line){
var result = [];
var current = '';
var inQuotes = false;
for(var i=0; i= 0 ? v.toLowerCase() : 'prospek'; }
else if(['perusahaan','email','kota','alamat','jabatan','source','notes','status'].indexOf(h) >= 0){
row[h] = v;
}
});
rows.push(row);
}
if(!rows.length){ alert('Tidak ada baris valid (cek nama & hp)'); return; }
if(!confirm('Import '+rows.length+' kontak?')) return;
if(status) status.innerHTML = ' Mengimport...';
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){ alert('Offline'); return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
// Batch insert per 100
var success = 0, failed = 0;
for(var b=0; b
🎯 Segment Tersimpan
Buat Segment
Filter customer/kontak dengan kriteria spesifik (AND/OR), simpan untuk broadcast nanti.
Contoh: "Customer Surabaya yang belum order >60 hari" atau "Prospek pameran 2024 yang VIP".
`;
}
window.waSegmentRender = async function(){
var box = document.getElementById('_waSegmentList');
if(!box) return;
box.innerHTML = 'Memuat...
';
try{
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){
box.innerHTML = 'Offline
';
return;
}
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
var r = await client.from('blp_wa_segment').select('*').is('deleted_at',null).order('created_at',{ascending:false});
if(r.error){
if(r.error.code === '42P01'){
box.innerHTML = ''
+ '⚠️ Tabel blp_wa_segment belum dibuat. Eksekusi 06_contact_table.sql dulu.'
+ '
';
return;
}
box.innerHTML = ''+r.error.message+'
';
return;
}
window._waSegments = r.data || [];
if(!window._waSegments.length){
box.innerHTML = 'Belum ada segment tersimpan.
Klik "Buat Segment" untuk mulai.
';
return;
}
var sourceMap = {
customer: {emoji:'👥', label:'Customer'},
contact: {emoji:'📇', label:'Kontak'},
both: {emoji:'🌐', label:'Keduanya'}
};
var html = window._waSegments.map(function(s){
var src = sourceMap[s.source] || sourceMap.customer;
var ruleCount = Array.isArray(s.rules) ? s.rules.length : 0;
var lastUsed = s.last_used ? new Date(s.last_used).toLocaleDateString('id-ID',{day:'numeric',month:'short'}) : 'Belum pernah';
return ''
+ '
'
+ '
'
+ '
'
+ ''+(s.icon||'🎯')+' '
+ _bmEsc(s.nama)
+ '
'
+ (s.deskripsi ? '
'+_wa_escHtml(s.deskripsi)+'
' : '')
+ '
'
+ ''+src.emoji+' '+src.label+' '
+ '📋 '+ruleCount+' rule ('+(s.rules_op||'and').toUpperCase()+') '
+ '📊 Dipakai '+(s.use_count||0)+'x '
+ '🕐 '+lastUsed+' '
+ '
'
+ '
'
+ '
'
+ ' '
+ ' '
+ ' '
+ ' '
+ '
'
+ '
'
+ '
';
}).join('');
box.innerHTML = html;
}catch(e){ box.innerHTML = ''+e.message+'
'; }
};
// Field definitions per source
window._waSegmentFields = {
customer: [
{key:'nama', label:'Nama', type:'text'},
{key:'perusahaan', label:'Perusahaan', type:'text'},
{key:'kota', label:'Kota', type:'text'},
{key:'mkt', label:'Marketing', type:'text'},
{key:'tier', label:'Tier Loyalti', type:'text'},
{key:'poin', label:'Total Poin', type:'number'},
{key:'tagihan_total', label:'Tagihan Terbuka (Rp)', type:'number'},
{key:'days_since_last_order', label:'Hari sejak order terakhir', type:'number'},
{key:'sc_aktif_count', label:'Jumlah SC aktif', type:'number'}
],
contact: [
{key:'nama', label:'Nama', type:'text'},
{key:'perusahaan', label:'Perusahaan', type:'text'},
{key:'kota', label:'Kota', type:'text'},
{key:'tipe', label:'Tipe', type:'enum', options:['prospek','supplier','vendor','partner','ex_customer','other']},
{key:'status', label:'Status', type:'enum', options:['aktif','non_aktif','blacklist']},
{key:'tags', label:'Tags', type:'tags'},
{key:'source', label:'Sumber', type:'text'},
{key:'jabatan', label:'Jabatan', type:'text'}
]
};
window._waSegmentOps = {
text: [['eq','='],['neq','≠'],['contains','berisi'],['not_contains','tidak berisi'],['empty','kosong'],['not_empty','tidak kosong']],
number: [['eq','='],['neq','≠'],['gt','>'],['gte','≥'],['lt','<'],['lte','≤']],
enum: [['eq','='],['neq','≠']],
tags: [['has','memiliki tag'],['not_has','tidak memiliki tag']]
};
window.waSegmentOpenEdit = function(segmentId){
var isEdit = !!segmentId;
var s = isEdit ? (window._waSegments||[]).find(function(x){return x.id===segmentId;}) : null;
if(isEdit && !s){ alert('Segment tidak ditemukan'); return; }
var d = s || {id:'', nama:'', deskripsi:'', icon:'🎯', source:'customer', rules:[], rules_op:'and'};
window._waSegmentEditState = JSON.parse(JSON.stringify(d));
if(typeof openSheet !== 'function') return;
openSheet((isEdit?'✏️ Edit':'➕ Buat')+' Segment',
''
+ '
'
+ '
'
+ '
Sumber Data
'
+ '
'
+ '👥 Customer (DB_CUSTOMER) '
+ '📇 Kontak Standalone (blp_contact) '
+ ' '
+ '
'
+ '
'
+ '
'
+ '
📋 Aturan Filter
'
+ '
'
+ 'Cocokkan '
+ ''
+ 'SEMUA (AND) '
+ 'SALAH SATU (OR) '
+ ' '
+ '
'
+ '
'
+ '
'
+ '
Tambah Rule'
+ '
'
+ '
'
+ ' Preview '
+ ' '+(isEdit?'Update':'Simpan')+' '
+ '
'
+ '
'
+ '
'
);
// Render rules existing
setTimeout(function(){ waSegmentRenderRules(); }, 50);
};
window.waSegmentChangeSource = function(source){
var state = window._waSegmentEditState;
if(!state) return;
if(state.source !== source){
if(state.rules.length > 0 && !confirm('Ganti sumber data akan reset semua rule. Lanjutkan?')){
document.getElementById('_wsSource').value = state.source;
return;
}
state.source = source;
state.rules = [];
waSegmentRenderRules();
}
};
window.waSegmentAddRule = function(){
var state = window._waSegmentEditState;
if(!state) return;
var fields = window._waSegmentFields[state.source] || [];
if(!fields.length) return;
var firstField = fields[0];
state.rules.push({field: firstField.key, op: 'eq', value: ''});
waSegmentRenderRules();
};
window.waSegmentRenderRules = function(){
var box = document.getElementById('_wsRulesList');
var state = window._waSegmentEditState;
if(!box || !state) return;
if(!state.rules.length){
box.innerHTML = 'Belum ada rule. Tanpa rule = ambil semua data.
';
return;
}
var fields = window._waSegmentFields[state.source] || [];
box.innerHTML = state.rules.map(function(rule, i){
var field = fields.find(function(f){return f.key === rule.field;}) || fields[0];
var ops = window._waSegmentOps[field.type] || window._waSegmentOps.text;
var fieldOpts = fields.map(function(f){return ''+f.label+' ';}).join('');
var opOpts = ops.map(function(o){return ''+o[1]+' ';}).join('');
var valueInput;
var noValueOps = ['empty','not_empty'];
if(noValueOps.indexOf(rule.op) >= 0){
valueInput = ' ';
} else if(field.type === 'enum'){
var enumOpts = '— Pilih — ' + field.options.map(function(o){return ''+o+' ';}).join('');
valueInput = ''+enumOpts+' ';
} else if(field.type === 'number'){
valueInput = ' ';
} else {
valueInput = ' ';
}
return ''
+ '
#'+(i+1)+'
'
+ '
'+fieldOpts+' '
+ '
'+opOpts+' '
+ '
'+valueInput+'
'
+ '
'
+ '
';
}).join('');
};
window._waSegmentSetRule = function(i, key, value){
var state = window._waSegmentEditState;
if(!state || !state.rules[i]) return;
state.rules[i][key] = value;
if(key === 'field' || key === 'op'){
// Op berubah → mungkin perlu re-render karena value input berubah
if(key === 'field'){
var fields = window._waSegmentFields[state.source] || [];
var newField = fields.find(function(f){return f.key === value;});
if(newField){
var validOps = window._waSegmentOps[newField.type] || [];
if(!validOps.find(function(o){return o[0] === state.rules[i].op;})){
state.rules[i].op = validOps[0][0];
}
}
}
waSegmentRenderRules();
}
};
window._waSegmentRemoveRule = function(i){
var state = window._waSegmentEditState;
if(!state) return;
state.rules.splice(i, 1);
waSegmentRenderRules();
};
// Evaluate segment terhadap dataset
window._waSegmentEvaluate = function(state, dataset){
if(!state.rules || !state.rules.length) return dataset;
var op = state.rules_op || 'and';
return dataset.filter(function(item){
var matches = state.rules.map(function(rule){
var v = _waSegmentGetFieldValue(item, rule.field, state.source);
var ruleV = rule.value;
switch(rule.op){
case 'eq': return String(v||'').toLowerCase() === String(ruleV||'').toLowerCase();
case 'neq': return String(v||'').toLowerCase() !== String(ruleV||'').toLowerCase();
case 'contains': return String(v||'').toLowerCase().indexOf(String(ruleV||'').toLowerCase()) >= 0;
case 'not_contains': return String(v||'').toLowerCase().indexOf(String(ruleV||'').toLowerCase()) < 0;
case 'empty': return !v || (Array.isArray(v) && !v.length);
case 'not_empty': return !!v && (!Array.isArray(v) || v.length > 0);
case 'gt': return (+v||0) > (+ruleV||0);
case 'gte': return (+v||0) >= (+ruleV||0);
case 'lt': return (+v||0) < (+ruleV||0);
case 'lte': return (+v||0) <= (+ruleV||0);
case 'has': return Array.isArray(v) && v.indexOf(ruleV) >= 0;
case 'not_has': return !Array.isArray(v) || v.indexOf(ruleV) < 0;
default: return false;
}
});
if(op === 'or') return matches.some(function(m){return m;});
return matches.every(function(m){return m;});
});
};
// Compute field value (special handling untuk derived field)
function _waSegmentGetFieldValue(item, field, source){
if(source === 'customer'){
if(field === 'tagihan_total'){
return (window.DB_PIUTANG_JOB||[]).filter(function(p){return p.custId===item.id;}).reduce(function(s,p){return s+(+p.amt||0);}, 0);
}
if(field === 'days_since_last_order'){
var jobs = (window.JOBS||[]).filter(function(j){return j.custId===item.id && j.tglOrder;});
if(!jobs.length) return 99999;
var lastDate = jobs.reduce(function(max,j){var d = new Date(j.tglOrder); return d > max ? d : max;}, new Date(0));
return Math.floor((Date.now() - lastDate.getTime()) / (24*60*60*1000));
}
if(field === 'sc_aktif_count'){
return (window.JOBS||[]).filter(function(j){return j.custId===item.id && j.jobStatus && j.jobStatus!=='selesai' && j.jobStatus!=='cancel';}).length;
}
if(field === 'tier'){
// Cari tier dari poin
var p = item.poin || 0;
if(p >= 5000) return 'platinum';
if(p >= 2000) return 'gold';
if(p >= 500) return 'silver';
return 'bronze';
}
}
return item[field];
}
// Preview live dari modal editor
window.waSegmentPreviewLive = function(){
var state = window._waSegmentEditState;
if(!state) return;
// Sync rules_op dari select
var op = document.getElementById('_wsRulesOp');
if(op) state.rules_op = op.value;
var dataset = state.source === 'customer' ? (window.DB_CUSTOMER||[]) : (window._waContacts||[]);
var filtered = _waSegmentEvaluate(state, dataset);
var box = document.getElementById('_wsPreviewBox');
if(!box) return;
box.style.display = 'block';
var srcLabel = state.source === 'customer' ? 'customer' : 'kontak';
box.innerHTML = '👁️ '+filtered.length+' / '+dataset.length+' '+srcLabel+' matched
'
+ (filtered.length === 0 ? 'Tidak ada yang cocok dengan kriteria. Cek rule-mu.
'
: ''
+ filtered.slice(0,15).map(function(c){return '• '+_wa_escHtml(c.nama || c.hp || c.id);}).join(' ')
+ (filtered.length > 15 ? '... +'+(filtered.length-15)+' lainnya ' : '')
+ '
');
};
window.waSegmentSave = async function(editId){
var nama = (document.getElementById('_wsNama')?.value||'').trim();
if(!nama){ alert('Nama segment wajib diisi'); return; }
var state = window._waSegmentEditState;
if(!state) return;
var op = document.getElementById('_wsRulesOp');
if(op) state.rules_op = op.value;
var row = {
nama: nama,
deskripsi: (document.getElementById('_wsDesc')?.value||'').trim(),
icon: (document.getElementById('_wsIcon')?.value||'🎯').trim(),
source: state.source || 'customer',
rules: state.rules || [],
rules_op: state.rules_op || 'and',
updated_at: new Date().toISOString()
};
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){ alert('Offline'); return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
var userId = (blpSupabase.session && blpSupabase.session.user) ? blpSupabase.session.user.id : null;
try{
var res;
if(editId){
res = await client.from('blp_wa_segment').update(row).eq('id', editId);
} else {
row.id = 'seg_' + Date.now();
row.created_at = new Date().toISOString();
row.created_by = userId;
res = await client.from('blp_wa_segment').insert(row);
}
if(res.error){ alert('❌ '+res.error.message); return; }
if(typeof audit === 'function') audit(editId?'edit_segment':'create_segment', 'segment', row.id||editId, 'Segment "'+nama+'"', {source:row.source, rules:row.rules.length});
if(typeof closeOverlay === 'function') closeOverlay();
if(typeof _wa_toast === 'function') _wa_toast('✅ Segment disimpan','ok');
setTimeout(function(){ waSegmentRender(); }, 200);
}catch(e){ alert('Error: '+e.message); }
};
window.waSegmentDelete = async function(segmentId){
var s = (window._waSegments||[]).find(function(x){return x.id===segmentId;});
if(!s){ return; }
if(!confirm('Hapus segment "'+s.nama+'"?')) return;
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
try{
var res = await client.from('blp_wa_segment').update({deleted_at: new Date().toISOString()}).eq('id', segmentId);
if(res.error){ alert('❌ '+res.error.message); return; }
if(typeof _wa_toast === 'function') _wa_toast('✅ Segment dihapus','ok');
setTimeout(function(){ waSegmentRender(); }, 200);
}catch(e){ alert('Error: '+e.message); }
};
window.waSegmentPreview = function(segmentId){
var s = (window._waSegments||[]).find(function(x){return x.id===segmentId;});
if(!s){ return; }
var dataset = s.source === 'customer' ? (window.DB_CUSTOMER||[]) : (window._waContacts||[]);
var filtered = _waSegmentEvaluate(s, dataset);
if(typeof openSheet !== 'function') return;
var srcLabel = s.source === 'customer' ? 'customer' : 'kontak';
openSheet('👁️ Preview Segment — '+s.nama,
''
+ '
'+filtered.length+' / '+dataset.length+' '+srcLabel+' matched
'
+ (s.deskripsi ? '
'+_wa_escHtml(s.deskripsi)+'
' : '')
+ '
'
+ (filtered.length === 0 ? 'Tidak ada yang cocok dengan kriteria.
'
: ''
+ filtered.map(function(c){
return '
'
+ '
'+_wa_escHtml(c.nama||'-')+'
'
+ '
'+_wa_escHtml(c.perusahaan||c.kota||'')+(c.hp?' • '+c.hp:'')+'
'
+ '
';
}).join('')
+ '
')
);
};
// ═══════════════════════════════════════════════════════════════════════
// SEGMENT BROADCAST (Phase 4 — item 1)
// Kirim pesan ke semua kontak/customer yang match segment. Mode: sekarang atau jadwal.
// ═══════════════════════════════════════════════════════════════════════
window.waSegmentBroadcastDialog = function(segmentId){
var s = (window._waSegments||[]).find(function(x){return x.id===segmentId;});
if(!s){ alert('Segment tidak ditemukan'); return; }
if(typeof openSheet !== 'function') return;
// Evaluasi segment → dapat target
var dataset = s.source === 'customer' ? (window.DB_CUSTOMER||[]) : (window._waContacts||[]);
var filtered = (typeof _waSegmentEvaluate === 'function') ? _waSegmentEvaluate(s, dataset) : [];
var withHp = filtered.filter(function(c){ return _wa_normalizeHp(c.hp); });
if(!withHp.length){
alert('⚠️ Segment "'+s.nama+'" tidak punya target dengan HP valid.\n\n'+filtered.length+' match segment, tapi semuanya tidak punya HP.');
return;
}
// Cache target untuk dipakai execute
window._waSegBcTargets = withHp;
window._waSegBcSegment = s;
// v1.10.43: Reset media state Sistem B (fresh start tiap buka dialog)
window._waSegBcMedia = [];
// Template options
var templates = (window._waTemplates||[]).filter(function(t){return t.aktif && t.kategori !== 'quick_reply';});
var tplOptions = '— Tulis manual — ' + templates.map(function(t){
return ''+_wa_escHtml(t.nama || t.kategori || t.id)+' ';
}).join('');
var srcLabel = s.source === 'customer' ? 'customer' : 'kontak';
openSheet('📢 Broadcast — '+s.nama,
''
+ '
Target: '+withHp.length+' '+srcLabel+'
'
+ '
'+filtered.length+' match segment • '+withHp.length+' punya HP valid'
+ (filtered.length > withHp.length ? ' • '+(filtered.length-withHp.length)+' tanpa HP (di-skip)' : '')
+ '
'
+ '
'
+ ''
+ '📋 Pilih Template (opsional) '
+ ''+tplOptions+' '
+ '
'
+ ''
+ '
💬 Pesan '
+ '
'
+ '
Placeholder: {nama}, {perusahaan}, {kota} — akan diganti otomatis per target.
'
+ '
'
// v1.10.43: Multi-attachment Sistem B
+ ''
+ '
📎 Lampiran (opsional) '
+ '
'
+ ' Upload file (gambar / PDF / dokumen)'
+ ' '
+ '
'
+ '
Max 16MB/file. Bisa multi (gambar, PDF, Word, Excel, MP4, MP3).
'
+ '
'
+ '
'
+ ''
+ '
⏰ Waktu Pengiriman
'
+ '
'
+ ' '
+ ''
+ '
⚡ Kirim Sekarang
'
+ '
Jalan langsung via Fonnte, ~1 detik per pesan (estimasi '+Math.ceil(withHp.length)+' detik )
'
+ '
'
+ ' '
+ '
'
+ ' '
+ ''
+ '
⏰ Jadwalkan
'
+ '
Masuk antrian, dieksekusi saat jadwal tiba (lihat tab Jadwal)
'
+ '
'
+ ' '
+ '
'
+ ' '
+ '
'
+ '
'
+ '
'
+ ''
+ ' Broadcast ke '+withHp.length+' '+srcLabel
+ ' '
);
};
window.waSegBcTplChange = function(){
var sel = document.getElementById('_waSegBcTpl');
var ta = document.getElementById('_waSegBcMsg');
if(!sel || !ta) return;
if(!sel.value){ return; }
var tpl = (window._waTemplates||[]).find(function(t){return t.id===sel.value;});
if(tpl) ta.value = tpl.body || tpl.text || '';
};
window.waSegBcModeChange = function(){
var modes = document.getElementsByName('_waSegBcMode');
var mode = 'now';
for(var i=0;i 16*1024*1024){ alert('⚠️ "'+file.name+'" lebih dari 16MB, dilewati.'); continue; }
var ts = Date.now();
var rand = Math.floor(Math.random()*9999);
var safeName = file.name.replace(/[^a-zA-Z0-9._-]/g,'_');
var path = 'broadcast/'+ts+'_'+rand+'_'+safeName;
var placeholderId = 'segMediaUp_'+ts+'_'+rand;
if(box) box.insertAdjacentHTML('beforeend',
''
+ '
'
+ '
⏳ Uploading '+_wa_escHtml(file.name)+'...
'
+ '
'
);
try{
var up = await client.storage.from('blp-wa-media').upload(path, file, {cacheControl:'3600', upsert:false});
if(up.error){
var ph = document.getElementById(placeholderId);
if(ph) ph.outerHTML = '❌ Gagal upload '+_wa_escHtml(file.name)+': '+_wa_escHtml(up.error.message)+'
';
continue;
}
var pub = client.storage.from('blp-wa-media').getPublicUrl(path);
window._waSegBcMedia.push({
url: pub.data.publicUrl, path: path,
filename: file.name, mime_type: file.type, size_bytes: file.size
});
var ph2 = document.getElementById(placeholderId);
if(ph2) ph2.remove();
_waSegBcRenderMedia();
}catch(e){
var ph3 = document.getElementById(placeholderId);
if(ph3) ph3.outerHTML = '❌ Error: '+_wa_escHtml(e.message)+'
';
}
}
var inp = document.getElementById('_waSegBcFileInput');
if(inp) inp.value = '';
};
window._waSegBcRenderMedia = function(){
var box = document.getElementById('_waSegBcMediaList');
if(!box) return;
var arr = window._waSegBcMedia || [];
if(!arr.length){ box.innerHTML = ''; return; }
var html = '';
arr.forEach(function(m, idx){
var icon = m.mime_type && m.mime_type.startsWith('image/') ? '🖼️'
: m.mime_type === 'application/pdf' ? '📄'
: m.mime_type && m.mime_type.startsWith('video/') ? '🎬'
: m.mime_type && m.mime_type.startsWith('audio/') ? '🎵' : '📎';
var sizeKb = (m.size_bytes/1024).toFixed(1);
var sizeStr = sizeKb > 1024 ? (sizeKb/1024).toFixed(1)+' MB' : sizeKb+' KB';
html += ''
+ '
'+icon+' '
+ '
'
+ '
'+_wa_escHtml(m.filename)+'
'
+ '
'+sizeStr+'
'
+ '
'
+ '
✕ '
+ '
';
});
box.innerHTML = html;
};
window._waSegBcRemoveMedia = async function(idx){
var arr = window._waSegBcMedia || [];
var m = arr[idx];
if(!m) return;
if(!confirm('Hapus lampiran "'+m.filename+'"?')) return;
try{
if(typeof blpSupabase !== 'undefined' && blpSupabase.online){
var client = blpSupabase.client;
if(client && m.path) await client.storage.from('blp-wa-media').remove([m.path]);
}
}catch(e){ console.warn('remove seg media err:', e); }
arr.splice(idx, 1);
_waSegBcRenderMedia();
};
window.waSegmentBroadcastExecute = async function(){
var msg = (document.getElementById('_waSegBcMsg')?.value || '').trim();
// v1.10.43: allow kosong pesan kalau ada media
var mediaArr = window._waSegBcMedia || [];
if(!msg && !mediaArr.length){ alert('⚠️ Pesan kosong & tidak ada lampiran. Isi minimal salah satu.'); return; }
var targets = window._waSegBcTargets || [];
var seg = window._waSegBcSegment;
if(!targets.length){ alert('⚠️ Tidak ada target'); return; }
var modes = document.getElementsByName('_waSegBcMode');
var mode = 'now';
for(var i=0;i';
await new Promise(function(r){ setTimeout(r, 1000); });
}
if(typeof audit === 'function') audit('segment_broadcast_now', 'wa_broadcast', seg?seg.id:'manual', 'Broadcast segment "'+(seg?seg.nama:'')+'": '+ok+' sukses, '+fail+' gagal', {target:targets.length, ok:ok, fail:fail, media: mediaArr.length});
// Update segment use_count + last_used
if(seg && typeof blpSupabase !== 'undefined' && blpSupabase.online){
try{
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(client) await client.from('blp_wa_segment').update({use_count: (seg.use_count||0)+1, last_used: new Date().toISOString()}).eq('id', seg.id);
}catch(e){}
}
if(prog) prog.innerHTML = '✅ Selesai: '+ok+' sukses'+(fail?', '+fail+' gagal':'')+' ';
if(btn){ btn.disabled = false; btn.innerHTML = ' Selesai — Tutup'; btn.style.opacity = '1'; btn.style.background = '#15803D'; btn.onclick = function(){ if(typeof closeOverlay==='function') closeOverlay(); }; }
if(typeof _wa_toast === 'function') _wa_toast('Broadcast selesai: '+ok+' sukses'+(fail?', '+fail+' gagal':''), ok>0?'ok':'warn');
return;
}
// === MODE: JADWALKAN ===
var schedInp = document.getElementById('_waSegBcSchedTime');
if(!schedInp || !schedInp.value){ alert('⚠️ Pilih waktu jadwal'); return; }
var schedTime = new Date(schedInp.value);
if(isNaN(schedTime.getTime())){ alert('⚠️ Waktu jadwal tidak valid'); return; }
if(schedTime.getTime() < Date.now() - 60000){ alert('⚠️ Waktu jadwal sudah lewat'); return; }
if(!confirm('Jadwalkan '+targets.length+' pesan untuk '+schedTime.toLocaleString('id-ID')+'?')) return;
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){ alert('Offline — tidak bisa schedule'); return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client){ alert('Gagal init Supabase'); return; }
var userId = (blpSupabase.session && blpSupabase.session.user) ? blpSupabase.session.user.id : null;
if(btn){ btn.disabled = true; btn.innerHTML = ' Menambah ke antrian…'; btn.style.opacity = '0.7'; }
if(prog) prog.innerHTML = '⏳ Membuat '+targets.length+' entri queue… ';
// Build rows: pakai body yang sudah di-render per target (placeholder digantikan)
var rows = targets.map(function(c, idx){
var nama = c.nama || 'Bapak/Ibu';
var rendered = (typeof _wa_renderTpl === 'function')
? _wa_renderTpl(msg, {nama:nama, perusahaan:c.perusahaan||'', kota:c.kota||'', isi_pesan:''})
: msg.replace(/\{nama\}/g, nama).replace(/\{perusahaan\}/g, c.perusahaan||'').replace(/\{kota\}/g, c.kota||'');
return {
id: 'q_seg_' + (seg?seg.id:'man') + '_' + Date.now() + '_' + idx + '_' + Math.random().toString(36).slice(2,5),
hp: _wa_normalizeHp(c.hp),
body: rendered,
cust_id: c.id || null,
scheduled_at: schedTime.toISOString(),
status: 'pending',
kind: 'broadcast',
segment_id: seg ? seg.id : null,
// v1.10.43: media payload (NULL kalau tidak ada attachment)
media: mediaPayload ? JSON.stringify(mediaPayload) : null,
created_at: new Date().toISOString(),
created_by: userId
};
});
// Batch insert (200 per call supaya tidak overload)
var inserted = 0, failed = 0;
var BATCH = 200;
for(var i=0; i';
}
if(typeof audit === 'function') audit('segment_broadcast_schedule', 'wa_queue', seg?seg.id:'manual', 'Jadwalkan broadcast segment "'+(seg?seg.nama:'')+'": '+inserted+' pesan untuk '+schedTime.toLocaleString('id-ID'), {target:rows.length, ok:inserted, fail:failed});
if(prog) prog.innerHTML = '✅ '+inserted+' pesan masuk antrian'+(failed?' ('+failed+' gagal)':'')+' ';
if(btn){ btn.disabled = false; btn.innerHTML = ' Selesai — Tutup'; btn.style.opacity = '1'; btn.style.background = '#15803D'; btn.onclick = function(){ if(typeof closeOverlay==='function') closeOverlay(); }; }
if(typeof _wa_toast === 'function') _wa_toast('⏰ '+inserted+' pesan terjadwal','ok');
};
// ═══════════════════════════════════════════════════════════════════════
// TAB: QUEUE (Scheduled messages)
// ═══════════════════════════════════════════════════════════════════════
function _waTabQueue(c){
return `
⏰ Jadwal Pengiriman
Proses Sekarang
💡 Catatan: Pesan terjadwal diproses otomatis tiap menit oleh Edge Function di Supabase (24/7, tanpa perlu app dibuka). Tombol "Proses Sekarang" tetap tersedia untuk eksekusi manual jika perlu. Status pesan: pending = menunggu jadwal, processing = sedang dikirim, sent = terkirim, failed = gagal setelah 3x retry.
`;
}
window.waQueueRender = async function(){
var box = document.getElementById('_waQueueList');
if(!box) return;
box.innerHTML = 'Memuat...
';
try{
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){
box.innerHTML = 'Offline
';
return;
}
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
var r = await client.from('blp_wa_queue').select('*').order('scheduled_at',{ascending:true}).limit(200);
if(r.error){
if(r.error.code === '42P01'){
box.innerHTML = '⚠️ Tabel blp_wa_queue belum dibuat. Eksekusi 06_contact_table.sql dulu.
';
return;
}
box.innerHTML = ''+r.error.message+'
';
return;
}
var items = r.data || [];
if(!items.length){
box.innerHTML = 'Belum ada pesan terjadwal.
Buat jadwal kirim dari Broadcast atau Modal Kirim.
';
return;
}
var statusMap = {
pending: {color:'#D97706', bg:'#FEF3C7', label:'Menunggu'},
sent: {color:'#15803D', bg:'#DCFCE7', label:'Terkirim'},
failed: {color:'#DC2626', bg:'#FEE2E2', label:'Gagal'},
cancelled: {color:'#64748B', bg:'#F1F5F9', label:'Dibatalkan'}
};
// Group by status
var grouped = {pending:[], sent:[], failed:[], cancelled:[]};
items.forEach(function(it){ (grouped[it.status]||grouped.pending).push(it); });
var html = '';
['pending','sent','failed','cancelled'].forEach(function(status){
var list = grouped[status];
if(!list.length) return;
var conf = statusMap[status];
html += ''
+ '
'+conf.label+' ('+list.length+')
';
list.forEach(function(it){
var schedTime = new Date(it.scheduled_at);
var isOverdue = status === 'pending' && schedTime.getTime() < Date.now();
html += '
'
+ '
'
+ '
'
+ '
'+it.hp+(isOverdue?' ⚠️ TERLEWAT ':'')+'
'
+ '
🕐 '+schedTime.toLocaleString('id-ID')+(it.sent_at?' • Terkirim: '+new Date(it.sent_at).toLocaleString('id-ID'):'')+'
'
+ '
'
+ (status === 'pending'
? '
'
: '
')
+ '
'
+ '
'+_wa_escHtml((it.body||'').slice(0,200))+(it.body && it.body.length > 200 ? '...' : '')+'
'
+ (it.error_msg ? '
'+_wa_escHtml(it.error_msg)+'
' : '')
+ '
';
});
html += '
';
});
box.innerHTML = html;
}catch(e){ box.innerHTML = ''+e.message+'
'; }
};
window.waQueueCancel = async function(queueId){
if(!confirm('Batalkan jadwal kirim ini?')) return;
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
try{
var res = await client.from('blp_wa_queue').update({status:'cancelled'}).eq('id', queueId);
if(res.error){ alert('❌ '+res.error.message); return; }
if(typeof _wa_toast === 'function') _wa_toast('✅ Jadwal dibatalkan','ok');
setTimeout(function(){ waQueueRender(); }, 200);
}catch(e){ alert('Error: '+e.message); }
};
// v1.10.41: Hard-delete row pesan dari list (untuk status sent/failed/cancelled)
// Tidak bisa di-undo. Auto-cleanup juga ada di pg_cron tiap hari jam 01:00 WIB
// untuk row sent/failed > 7 hari.
window.waQueueDelete = async function(queueId){
if(!confirm('Hapus pesan ini dari list?\n\nAksi ini permanen dan tidak bisa di-undo.')) return;
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
try{
var res = await client.from('blp_wa_queue').delete().eq('id', queueId);
if(res.error){ alert('❌ '+res.error.message); return; }
if(typeof _wa_toast === 'function') _wa_toast('🗑️ Pesan dihapus','ok');
setTimeout(function(){ waQueueRender(); }, 200);
}catch(e){ alert('Error: '+e.message); }
};
// Proses pesan pending yang sudah jatuh tempo
window.waQueueProcessPending = async function(){
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){ alert('Offline'); return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(typeof _wa_toast === 'function') _wa_toast('Cek pesan terjadwal...','info');
var nowIso = new Date().toISOString();
var r = await client.from('blp_wa_queue').select('*').eq('status','pending').lte('scheduled_at', nowIso).order('scheduled_at',{ascending:true}).limit(50);
if(r.error){ alert('❌ '+r.error.message); return; }
var due = r.data || [];
if(!due.length){
if(typeof _wa_toast === 'function') _wa_toast('Tidak ada pesan jatuh tempo','info');
return;
}
if(!confirm('Ada '+due.length+' pesan jatuh tempo. Kirim sekarang?')) return;
var success = 0, failed = 0;
for(var i=0; i
📜 Log Kirim
Semua
Reminder
SC Dibuat
Siap Kirim
Broadcast
AI Reply
Handover
Manual
${_wa_canFull() ? ' Hapus ' : ''}
`;
}
// Dialog cleanup dengan opsi periode
window.waLogCleanupDialog = async function(){
if(!_wa_canFull()){ alert('Hanya Owner/Admin yang boleh menghapus log.'); return; }
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){ alert('Tidak online'); return; }
// Cek dulu jumlah log yang ada
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client) return;
var totalResult = await client.from('blp_wa_log').select('id', {count:'exact', head:true});
var totalCount = totalResult.count || 0;
if(totalCount === 0){ alert('Belum ada log untuk dihapus.'); return; }
// Hitung counts per periode
var now = new Date();
var d30 = new Date(now.getTime() - 30*24*60*60*1000).toISOString();
var d90 = new Date(now.getTime() - 90*24*60*60*1000).toISOString();
var d180 = new Date(now.getTime() - 180*24*60*60*1000).toISOString();
var d365 = new Date(now.getTime() - 365*24*60*60*1000).toISOString();
var counts = await Promise.all([
client.from('blp_wa_log').select('id', {count:'exact', head:true}).lt('created_at', d30),
client.from('blp_wa_log').select('id', {count:'exact', head:true}).lt('created_at', d90),
client.from('blp_wa_log').select('id', {count:'exact', head:true}).lt('created_at', d180),
client.from('blp_wa_log').select('id', {count:'exact', head:true}).lt('created_at', d365)
]);
var c30 = counts[0].count || 0;
var c90 = counts[1].count || 0;
var c180 = counts[2].count || 0;
var c365 = counts[3].count || 0;
if(typeof openSheet === 'function'){
openSheet('🗑️ Hapus Log WA',
''
+ '⚠️ Aksi permanent. Log yang dihapus tidak bisa dikembalikan. Total log saat ini: '+totalCount.toLocaleString('id-ID')+' baris.'
+ '
'
+ 'Pilih periode log yang akan dihapus:
'
+ ''
+ (c30>0 ? '
'
+ '📅 Hapus log >30 hari '
+ ''+c30.toLocaleString('id-ID')+' baris '
+ ' ' : '')
+ (c90>0 ? '
'
+ '📅 Hapus log >90 hari '
+ ''+c90.toLocaleString('id-ID')+' baris '
+ ' ' : '')
+ (c180>0 ? '
'
+ '📅 Hapus log >180 hari '
+ ''+c180.toLocaleString('id-ID')+' baris '
+ ' ' : '')
+ (c365>0 ? '
'
+ '📅 Hapus log >1 tahun '
+ ''+c365.toLocaleString('id-ID')+' baris '
+ ' ' : '')
+ (c30===0 && c90===0 && c180===0 && c365===0 ? '
Semua log masih dalam 30 hari terakhir, tidak perlu cleanup.
' : '')
+ '
'
+ ' Hapus SEMUA log ('+totalCount.toLocaleString('id-ID')+' baris) '
+ '
'
+ '
'
+ '💡 Tip: Hapus log lama secara berkala (mis. tiap 3 bulan) untuk menjaga performa & hemat storage Supabase.
');
}
};
// Eksekusi cleanup berdasarkan periode hari
window.waLogCleanup = async function(days){
if(!_wa_canFull()){ alert('Hanya Owner/Admin'); return; }
var cutoffDate = new Date(Date.now() - days*24*60*60*1000).toISOString();
var humanDate = new Date(Date.now() - days*24*60*60*1000).toLocaleDateString('id-ID', {day:'numeric',month:'long',year:'numeric'});
if(!confirm('Hapus semua log WA yang dibuat SEBELUM '+humanDate+'?\n\nAksi ini tidak bisa dibatalkan.')) return;
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client) return;
if(typeof _wa_toast === 'function') _wa_toast('Menghapus log...','info');
try{
// Hitung dulu berapa yang akan dihapus
var preCount = await client.from('blp_wa_log').select('id', {count:'exact', head:true}).lt('created_at', cutoffDate);
var deleteCount = preCount.count || 0;
// Eksekusi delete
var r = await client.from('blp_wa_log').delete().lt('created_at', cutoffDate);
if(r.error){
if(typeof _wa_toast === 'function') _wa_toast('❌ Gagal: '+r.error.message,'error');
else alert('Gagal: '+r.error.message);
return;
}
// Audit log
if(typeof audit === 'function'){
audit('cleanup_wa_log', 'wa_log', 'period_'+days+'d', 'Hapus '+deleteCount.toLocaleString('id-ID')+' log WA (>'+days+' hari, sebelum '+humanDate+')', {periode:days+' hari', cutoff:cutoffDate, deletedCount:deleteCount});
}
if(typeof _wa_toast === 'function') _wa_toast('✅ '+deleteCount.toLocaleString('id-ID')+' log dihapus','success');
else alert('✅ '+deleteCount.toLocaleString('id-ID')+' log berhasil dihapus.');
if(typeof closeOverlay === 'function') closeOverlay();
// Refresh log box
setTimeout(function(){ if(typeof waLogRefresh==='function') waLogRefresh(); }, 300);
}catch(e){
if(typeof _wa_toast === 'function') _wa_toast('❌ Error: '+e.message,'error');
else alert('Error: '+e.message);
}
};
// Hapus SEMUA log (nuclear option) — perlu ketik konfirmasi
window.waLogCleanupAll = async function(totalCount){
if(!_wa_canFull()){ alert('Hanya Owner/Admin'); return; }
var konfirmasi = prompt('⚠️ HAPUS SEMUA LOG WA?\n\nTotal: '+totalCount.toLocaleString('id-ID')+' baris akan DIHAPUS PERMANEN dari Supabase.\n\nKetik HAPUS SEMUA (huruf besar) untuk melanjutkan:');
if(konfirmasi !== 'HAPUS SEMUA'){ alert('Dibatalkan — tidak ada log yang dihapus.'); return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
if(!client) return;
if(typeof _wa_toast === 'function') _wa_toast('Menghapus semua log...','info');
try{
// Pakai delete with neq impossible row id, atau filter selalu true
var r = await client.from('blp_wa_log').delete().gte('created_at', '1970-01-01');
if(r.error){
if(typeof _wa_toast === 'function') _wa_toast('❌ Gagal: '+r.error.message,'error');
return;
}
if(typeof audit === 'function'){
audit('cleanup_wa_log_all', 'wa_log', 'all', '🔥 HAPUS SEMUA log WA ('+totalCount.toLocaleString('id-ID')+' baris)', {deletedCount:totalCount});
}
if(typeof _wa_toast === 'function') _wa_toast('✅ Semua log dihapus','success');
else alert('✅ Semua log berhasil dihapus.');
if(typeof closeOverlay === 'function') closeOverlay();
setTimeout(function(){ if(typeof waLogRefresh==='function') waLogRefresh(); }, 300);
}catch(e){
if(typeof _wa_toast === 'function') _wa_toast('❌ Error: '+e.message,'error');
}
};
window.waLogRefresh = async function(){
var box = document.getElementById('wa_log_box');
if(!box) return;
var filter = document.getElementById('wa_log_filter')?.value || '';
var statsEl = document.getElementById('wa_log_stats');
box.innerHTML = 'Memuat...
';
try{
if(typeof blpSupabase === 'undefined' || !blpSupabase.online){ box.innerHTML = 'Offline
'; return; }
var client = blpSupabase.client || (blpSupabase._initClient && await blpSupabase._initClient());
// Tampilkan total count untuk transparency storage usage
if(statsEl && _wa_canFull()){
try{
var totalRes = await client.from('blp_wa_log').select('id', {count:'exact', head:true});
var total = totalRes.count || 0;
var sizeKB = Math.round(total * 0.3); // rough estimate: ~300 byte/row
var sizeStr = sizeKB > 1024 ? (sizeKB/1024).toFixed(1)+' MB' : sizeKB+' KB';
statsEl.innerHTML = 'Total log tersimpan: '+total.toLocaleString('id-ID')+' baris (~'+sizeStr+')';
}catch(e){ statsEl.innerHTML = ''; }
}
var q = client.from('blp_wa_log').select('created_at,kind,target,success,sent_by,cust_id,message').order('created_at',{ascending:false}).limit(50);
if(filter) q = q.eq('kind', filter);
if(_wa_canMarketing() && !_wa_canFull()){
var myCustIds = _wa_myCustomers().map(function(c){return c.id;});
if(!myCustIds.length){ box.innerHTML = 'Belum ada customer.
'; return; }
q = q.in('cust_id', myCustIds);
}
var r = await q;
if(r.error){
if(r.error.code==='42P01') box.innerHTML = 'Tabel belum dibuat.
';
else box.innerHTML = ''+r.error.message+'
';
return;
}
if(!r.data || !r.data.length){ box.innerHTML = 'Belum ada log.
'; return; }
var html = '';
r.data.forEach(function(row){
var t = new Date(row.created_at);
var icon = row.success ? '✅' : '❌';
var msgPrev = (row.message||'').slice(0,80);
html += ''
+ '
'
+ '
'+icon+'
'
+ '
'
+ '
'+(row.kind||'-')+' → '+row.target+'
'
+ '
'+t.toLocaleString('id-ID')+(row.sent_by?(' • '+_wa_escHtml(row.sent_by)):'')+'
'
+ (msgPrev ? '
"'+_wa_escHtml(msgPrev)+'"
' : '')
+ '
'
+ '
'
+ '
';
});
box.innerHTML = html;
}catch(e){ box.innerHTML = ''+e.message+'
'; }
};
// ────────────── UI HANDLERS ──────────────
window.waUiSave = async function(){
try{
if(typeof _wa_canFull==='function' && !_wa_canFull()){ alert('Hanya Owner/Admin'); return; }
var g = function(id){ return document.getElementById(id); };
var patch = {};
// Connection
if(g('wa_token')) patch.token = (g('wa_token').value||'').trim();
if(g('wa_sender')) patch.sender = (g('wa_sender').value||'').trim();
if(g('wa_enabled')) patch.enabled = !!g('wa_enabled').checked;
// AI
if(g('wa_ai_enabled')) patch.ai_enabled = !!g('wa_ai_enabled').checked;
patch.ai_provider = window._waConfig.ai_provider;
if(g('wa_claude_key')) patch.claude_key = (g('wa_claude_key').value||'').trim();
if(g('wa_claude_model')) patch.claude_model = g('wa_claude_model').value;
if(g('wa_claude_ocr_model')) patch.claude_ocr_model = g('wa_claude_ocr_model').value;
if(g('wa_openai_key')) patch.openai_key = (g('wa_openai_key').value||'').trim();
if(g('wa_openai_model')) patch.openai_model = g('wa_openai_model').value;
if(g('wa_gemini_key')) patch.gemini_key = (g('wa_gemini_key').value||'').trim();
if(g('wa_gemini_model')) patch.gemini_model = g('wa_gemini_model').value;
if(g('wa_temp')) patch.ai_temperature = parseFloat(g('wa_temp').value);
if(g('wa_maxt')) patch.ai_max_tokens = parseInt(g('wa_maxt').value, 10);
if(g('wa_ai_prompt')) patch.ai_system_prompt = g('wa_ai_prompt').value;
// Knowledge
if(g('wa_kb_company')) patch.kb_company_profile = g('wa_kb_company').value;
if(g('wa_kb_info')) patch.kb_general_info = g('wa_kb_info').value;
if(g('wa_kb_products')) patch.kb_use_products = !!g('wa_kb_products').checked;
// Persona
if(g('wa_bot_name')) patch.bot_name = g('wa_bot_name').value;
patch.bot_style = window._waConfig.bot_style;
if(g('wa_bot_lang')) patch.bot_lang = g('wa_bot_lang').value;
if(g('wa_use_emoji')) patch.bot_use_emoji = !!g('wa_use_emoji').checked;
if(g('wa_bot_sig')) patch.bot_signature = g('wa_bot_sig').value;
if(g('wa_only_biz')) patch.autoreply_only_business = !!g('wa_only_biz').checked;
// Working hours
if(g('wa_wh_enabled')) patch.wh_enabled = !!g('wa_wh_enabled').checked;
if(g('wa_wh_start')) patch.wh_start = g('wa_wh_start').value;
if(g('wa_wh_end')) patch.wh_end = g('wa_wh_end').value;
if(g('wa_wh_msg')) patch.wh_offhours_msg = g('wa_wh_msg').value;
// Welcome
if(g('wa_welcome_en')) patch.welcome_enabled = !!g('wa_welcome_en').checked;
if(g('wa_welcome_msg')) patch.welcome_msg = g('wa_welcome_msg').value;
// Automation
if(g('wa_days')){
var d = (g('wa_days').value||'').split(',').map(function(s){return parseInt(s.trim(),10);}).filter(function(n){return !isNaN(n);});
patch.reminder_days = d.length ? d : [3,1,0];
}
if(g('wa_notif_sc')) patch.notif_sc_created = !!g('wa_notif_sc').checked;
if(g('wa_notif_sk')) patch.notif_siap_kirim = !!g('wa_notif_sk').checked;
if(g('wa_tpl_reminder')) patch.tpl_reminder = g('wa_tpl_reminder').value;
if(g('wa_tpl_sc')) patch.tpl_sc_created = g('wa_tpl_sc').value;
if(g('wa_tpl_sk')) patch.tpl_siap_kirim = g('wa_tpl_sk').value;
// Notif Otomatis ke Customer (Deploy 1 — Foundation)
if(g('wa_notif_bayar')) patch.notif_konfirmasi_bayar_enabled = !!g('wa_notif_bayar').checked;
if(g('wa_tpl_bayar')) patch.tpl_konfirmasi_bayar = g('wa_tpl_bayar').value;
if(g('wa_notif_dp')) patch.notif_minta_dp_enabled = !!g('wa_notif_dp').checked;
if(g('wa_dp_days')) patch.minta_dp_after_days = parseInt(g('wa_dp_days').value, 10) || 2;
if(g('wa_tpl_dp')) patch.tpl_minta_dp = g('wa_tpl_dp').value;
if(g('wa_notif_kanban')) patch.notif_kanban_status_enabled = !!g('wa_notif_kanban').checked;
// Kanban per-tahap toggles & deskripsi
try{
var kanbanTahap = {};
Array.prototype.forEach.call(document.querySelectorAll('.wa_kanban_tahap_chk'), function(el){
var tk = el.getAttribute('data-tahap'); if(tk) kanbanTahap[tk] = !!el.checked;
});
if(Object.keys(kanbanTahap).length) patch.kanban_notif_tahap = kanbanTahap;
var kanbanDesc = {};
Array.prototype.forEach.call(document.querySelectorAll('.wa_kanban_desc_inp'), function(el){
var tk = el.getAttribute('data-tahap'); if(tk) kanbanDesc[tk] = el.value || '';
});
if(Object.keys(kanbanDesc).length) patch.kanban_desc_tahap = kanbanDesc;
}catch(e){}
if(g('wa_tpl_kanban')) patch.tpl_kanban_status = g('wa_tpl_kanban').value;
if(g('wa_notif_reorder')) patch.notif_reorder_enabled = !!g('wa_notif_reorder').checked;
if(g('wa_reorder_pct')) patch.reorder_skip_threshold_pct = parseInt(g('wa_reorder_pct').value, 10) || 30;
if(g('wa_reorder_min')) patch.reorder_min_history = parseInt(g('wa_reorder_min').value, 10) || 3;
if(g('wa_tpl_reorder')) patch.tpl_reorder_reminder = g('wa_tpl_reorder').value;
if(g('wa_notif_ultah')) patch.notif_greeting_ultah_enabled = !!g('wa_notif_ultah').checked;
if(g('wa_notif_anniv')) patch.notif_greeting_anniv_enabled = !!g('wa_notif_anniv').checked;
if(g('wa_tpl_ultah')) patch.tpl_greeting_ultah = g('wa_tpl_ultah').value;
if(g('wa_tpl_anniv')) patch.tpl_greeting_anniv = g('wa_tpl_anniv').value;
// Auto-Reminder Lengkap (Phase 4)
if(g('wa_ar_enabled')) patch.auto_reminder_enabled = !!g('wa_ar_enabled').checked;
if(g('wa_ar_job')) patch.reminder_job_enabled = !!g('wa_ar_job').checked;
if(g('wa_ar_job_days')){
var dj = (g('wa_ar_job_days').value||'').split(',').map(function(s){return parseInt(s.trim(),10);}).filter(function(n){return !isNaN(n);});
patch.reminder_job_days = dj.length ? dj : [3,1,0];
}
if(g('wa_ar_job_tpl')) patch.tpl_reminder_job = g('wa_ar_job_tpl').value;
if(g('wa_ar_crm')) patch.reminder_crm_enabled = !!g('wa_ar_crm').checked;
if(g('wa_ar_crm_idle')) patch.reminder_crm_days_idle = parseInt(g('wa_ar_crm_idle').value, 10) || 5;
if(g('wa_ar_crm_tpl')) patch.tpl_reminder_crm = g('wa_ar_crm_tpl').value;
if(g('wa_ar_notif_owner')) patch.reminder_notif_owner = !!g('wa_ar_notif_owner').checked;
// Doc Send Mode
var docModeRadio = document.querySelector('input[name="wa_doc_mode"]:checked');
if(docModeRadio) patch.doc_send_mode = docModeRadio.value;
if(g('wa_ho_mode')) patch.handover_mode = g('wa_ho_mode').value;
if(g('wa_ho_kw')){
patch.handover_keywords = g('wa_ho_kw').value.split(',').map(function(s){return s.trim();}).filter(Boolean);
}
if(g('wa_ho_ex')) patch.handover_msg_existing = g('wa_ho_ex').value;
if(g('wa_ho_new')) patch.handover_msg_new = g('wa_ho_new').value;
if(g('wa_ho_notify')) patch.handover_notify_mkt = g('wa_ho_notify').value;
if(typeof _wa_toast==='function') _wa_toast('Menyimpan...','info');
var res = await waConfigSave(patch);
if(res && res.ok){
if(typeof _wa_toast==='function') _wa_toast('✅ Tersimpan','ok');
else alert('✅ Tersimpan');
} else {
var em=(res&&res.err)?res.err:'Gagal menyimpan (no response)';
if(typeof _wa_toast==='function') _wa_toast('❌ '+em,'warn');
else alert('❌ '+em);
}
}catch(e){
console.error('[waUiSave] error:', e);
alert('❌ Error saat simpan: '+(e.message||e));
}
};
window.waUiTest = async function(){
if(!_wa_canFull()){ alert('Hanya Owner/Admin'); return; }
var token = (document.getElementById('wa_token')?.value || '').trim();
if(!token){ alert('Isi & Simpan token Fonnte dulu'); return; }
var hp = prompt('Nomor HP test (08...):');
if(!hp) return;
window._waConfig.token = token;
var res = await fonnteSend(hp, '🤖 Test WA Bot My BLP\n\nIntegrasi Fonnte sudah jalan ✅', {kind:'test', bypassRole:true});
if(res.ok) alert('✅ Test berhasil!');
else alert('❌ Gagal: '+res.err);
};
window.waRunReminderNow = async function(){
if(!_wa_canFull()){ alert('Hanya Owner/Admin'); return; }
if(!confirm('Jalankan reminder sekarang?')) return;
_wa_toast('Memproses...','info');
await waAutoReminderRun({verbose:true});
};
window.waGenerateRemindersNow = async function(){
if(!_wa_canFull()){ alert('Hanya Owner/Admin'); return; }
if(!confirm('Generate auto-reminder berdasarkan data hari ini?\n\nHasil masuk antrian WA (blp_wa_queue) dan dikirim oleh Edge Function.')) return;
_wa_toast('Generate reminder…','info');
var r = await waGenerateReminders({verbose:true});
if(r.ok){
console.log('Reminder summary:', r);
} else {
_wa_toast('Gagal: '+(r.err||'unknown'),'warn');
}
};
// ────────────── INIT ──────────────
window.waBotInit = async function(){
// ── Force-attach BLP refs ke window (fix Safari/WebKit const top-level scoping) ──
// const top-level di WebKit tidak otomatis ter-attach ke window object.
// Ini bikin window.DB_CUSTOMER selalu undefined meski DB_CUSTOMER (closure) ada isinya.
try{
if(typeof DB_CUSTOMER !== 'undefined' && DB_CUSTOMER && !window.DB_CUSTOMER) window.DB_CUSTOMER = DB_CUSTOMER;
if(typeof DB_SUPPLIER !== 'undefined' && DB_SUPPLIER && !window.DB_SUPPLIER) window.DB_SUPPLIER = DB_SUPPLIER;
if(typeof DB_VENDOR !== 'undefined' && DB_VENDOR && !window.DB_VENDOR) window.DB_VENDOR = DB_VENDOR;
if(typeof JOBS !== 'undefined' && JOBS && !window.JOBS) window.JOBS = JOBS;
if(typeof DB_KARYAWAN !== 'undefined' && DB_KARYAWAN && !window.DB_KARYAWAN) window.DB_KARYAWAN = DB_KARYAWAN;
if(typeof DB_PIUTANG_JOB !== 'undefined' && DB_PIUTANG_JOB && !window.DB_PIUTANG_JOB) window.DB_PIUTANG_JOB = DB_PIUTANG_JOB;
}catch(e){ console.warn('[WA] attach BLP refs gagal:', e); }
await waConfigLoadWithRetry();
try{
if(_wa_canFull() && window._waConfig.enabled && window._waConfig.token){
var lastRun = localStorage.getItem('blp_wa_reminder_lastrun');
var todayStr = new Date().toISOString().slice(0,10);
if(lastRun !== todayStr){
setTimeout(function(){
waAutoReminderRun({verbose:false}).then(function(res){
localStorage.setItem('blp_wa_reminder_lastrun', todayStr);
});
}, 5000);
}
// Phase 4 — Auto-Reminder Lengkap: trigger sekali per hari, terpisah dari piutang reminder
if(window._waConfig.auto_reminder_enabled){
var lastRunAR = localStorage.getItem('blp_wa_autoreminder_lastrun');
if(lastRunAR !== todayStr){
setTimeout(function(){
if(typeof waGenerateReminders === 'function'){
waGenerateReminders({verbose:false}).then(function(res){
if(res && res.ok) localStorage.setItem('blp_wa_autoreminder_lastrun', todayStr);
console.log('[WA] Auto-reminder Phase 4:', res);
});
}
}, 8000); // 3 detik setelah piutang reminder, biar tidak race
}
}
}
}catch(e){}
};
if(typeof window !== 'undefined'){
if(document.readyState === 'complete' || document.readyState === 'interactive'){
setTimeout(window.waBotInit, 1500);
} else {
window.addEventListener('DOMContentLoaded', function(){ setTimeout(window.waBotInit, 1500); });
}
// Auto-retry config load saat koneksi internet pulih
window.addEventListener('online', function(){
if(!window._waConfigLoaded){
console.log('[WA] online event → trigger config load retry');
setTimeout(function(){ waConfigLoadWithRetry(); }, 1000);
}
});
}
})();
/* ════════════════════════════════════════════════════════════════════════
WA BOT PRO — ADDONS (v20260618-1700)
Tambahan untuk fitur n8n migration:
- Default n8n system prompt (auto-load saat first time)
- Super Admin whitelist + PIN
- Order Lookup toggle (filter by HP)
- Hot Prospect threshold + notify marketing
════════════════════════════════════════════════════════════════════════ */
(function(){
'use strict';
if(typeof window === 'undefined') return;
// ────────────── DEFAULT N8N PROMPT (auto-inject saat pertama install) ──────────────
window._waN8nDefaultPrompt = `INSTRUKSI KHUSUS — DETEKSI CEK ORDER:
Tambahkan tag [CEK_DATA] di AWAL balasan HANYA jika customer jelas-jelas menanyakan STATUS PESANAN EXISTING mereka:
- Menyebut nomor SC (SC-0101, CEK 0101)
- Bertanya "pesanan saya sudah sampai mana", "progress order saya", "status pengerjaan"
- Bertanya tentang tagihan/invoice order yang sudah ada
- Bertanya "barang saya kapan selesai/dikirim"
JANGAN tambahkan [CEK_DATA] untuk:
- Inquiry harga produk baru
- Tanya-tanya tentang produk BLP secara umum
- Pertanyaan teknis tentang spesifikasi produk
- Customer menyebut nama produk dalam konteks mau ORDER BARU
- Pertanyaan umum tentang BLP
LARANGAN KERAS:
- JANGAN PERNAH menyebut format nomor SC atau contoh SC-XXXX dalam percakapan
- JANGAN PERNAH mengarahkan customer untuk mencari nomor kontrak mereka
- JANGAN PERNAH menyebutkan bahwa sistem bisa cek data order
- Jika customer tidak menyebut SC sendiri, anggap itu pertanyaan umum
---
DATA CUSTOMER SAAT INI AKAN DIKIRIM DI AWAL PESAN. Gunakan untuk panggil "Kak [Nama]" jika nama diketahui, atau "Kakak" jika belum.
Kamu adalah asisten bisnis PT BLP yang membantu customer. Jawab dengan ramah, singkat, dan profesional dalam Bahasa Indonesia.
ATURAN PERCAKAPAN PENTING:
- Jika customer hanya mengirim sapaan singkat (Halo, Pagi, Sore, Tes, dll) → balas SINGKAT maksimal 1 kalimat
- Jika tahu nama: "Selamat pagi Kak Rudi, ada yang bisa saya bantu? 😊🙏🏻"
- Jika belum tahu nama: "Halo Kakak, ada yang bisa saya bantu? 😊🙏🏻"
- Jika customer mengajukan pertanyaan jelas → baru balas lengkap dan informatif
- JANGAN balas sapaan dengan paragraf panjang atau daftar pertanyaan
- JANGAN gunakan pembuka "Terima kasih sudah menghubungi" jika sudah ada riwayat — langsung jawab
- SELALU gunakan emoticon "😊🙏🏻" di setiap balasan
- Jika pesan PERTAMA → sapa hangat, perkenalkan diri sebagai asisten BLP, tanyakan nama customer, dari kota mana, kebutuhan kemasan apa, berapa rencana pengadaan
- Panggil "Kak [Nama]" jika tahu, "Kakak" jika belum
- Jangan tanya ulang info yang sudah diketahui
KAPAN ESCALATE KE MARKETING:
JIKA customer menanyakan:
- Harga deal spesifik atau quotation tertulis
- Mau order/pesan/beli sekarang
- Butuh PO atau dokumen formal
- Pertanyaan teknis sangat spesifik (warna, dimensi custom, finishing)
MAKA balas: "ESCALATE: [alasan singkat]" di baris pertama, lalu intro 1-2 kalimat.
Jika konsultasi umum, jawab langsung TANPA escalate.
---
KONTEKS PIC (KRITIS):
Sistem akan inject baris "[PIC_CONTEXT] Pengirim teridentifikasi: dari ()" jika nomor pengirim sudah terdaftar sebagai PIC di database.
ATURAN PIC CONTEXT:
- JIKA ada baris [PIC_CONTEXT] di awal pesan:
- SAPA langsung dengan nama PIC yang tertera (contoh: "Halo Bu Winda 😊🙏🏻")
- SEBUTKAN nama perusahaan/entity di sapaan pertama (contoh: "Halo Bu Winda dari PT. SUBUR ABADI 😊🙏🏻, ada yang bisa kami bantu?")
- JANGAN treat sebagai prospek baru — ini kontak yang SUDAH terdaftar
- JANGAN tanya nama, perusahaan, atau kota — sudah tahu
- Sesuaikan tone dengan tipe entity:
* customer → ramah, asumsikan dia mau follow-up order atau tanya progress
* supplier → profesional, mungkin dia konfirmasi delivery/payment
* vendor → singkat, kemungkinan koordinasi pengiriman
- JIKA TIDAK ada baris [PIC_CONTEXT]:
- Treat sebagai prospek/kontak baru seperti biasa
- Ikuti aturan sapaan default di atas`;
// ────────────── AUTO-INJECT default kalau prompt kosong ──────────────
window.waPromptApplyN8nDefault = async function(){
try{
if(typeof _wa_canFull==='function' && !_wa_canFull()){ alert('Hanya Owner/Admin'); return; }
if(!confirm('Ganti AI System Prompt dengan template N8N yang sudah teruji?\nPrompt lama akan ditimpa.')) return;
var el = document.getElementById('wa_ai_prompt');
if(el) el.value = window._waN8nDefaultPrompt;
if(typeof waConfigSave==='function'){
var res = await waConfigSave({ai_system_prompt: window._waN8nDefaultPrompt});
if(res && res.ok){
if(typeof _wa_toast==='function') _wa_toast('✅ Template N8N diaplikasikan','ok');
else if(typeof toast==='function') toast('✅ Template N8N diaplikasikan','ok');
else alert('✅ Template N8N diaplikasikan');
} else {
var errMsg=(res&&res.err)?res.err:'Gagal menyimpan';
if(typeof _wa_toast==='function') _wa_toast('❌ '+errMsg,'warn');
else alert('❌ '+errMsg);
}
} else {
alert('❌ Fungsi save belum siap. Coba refresh app.');
}
}catch(e){
console.error('[waPromptApplyN8nDefault]', e);
alert('Error: '+(e.message||e));
}
};
// ════════════════════════════════════════════════════════════════════
// SUPER ADMIN MGMT — whitelist HP + PIN
// ════════════════════════════════════════════════════════════════════
function _wa_normLc(s){ return String(s||'').trim().toLowerCase(); }
function _wa_escHtml(s){
return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
// Default whitelist & PIN (sama dengan n8n)
if(!window._waConfig.super_admin_hps) window._waConfig.super_admin_hps = ['6281555480510', '6285607007007'];
if(!window._waConfig.bot_toggle_pin) window._waConfig.bot_toggle_pin = '1234';
if(!window._waConfig.order_lookup_enabled) window._waConfig.order_lookup_enabled = true;
if(!window._waConfig.hot_prospect_qty) window._waConfig.hot_prospect_qty = 10000;
if(!window._waConfig.hot_prospect_target_mkt) window._waConfig.hot_prospect_target_mkt = '';
if(typeof window._waConfig.hot_prospect_mkt_hp === 'undefined') window._waConfig.hot_prospect_mkt_hp = '';
if(!window._waConfig.hot_prospect_msg) window._waConfig.hot_prospect_msg = '🔥 *HOT PROSPECT*\n\nCustomer: {cust_nama}\nNo HP: {cust_hp}\nPerusahaan: {cust_company}\nKebutuhan: {kebutuhan}\nRencana Order: *{qty} lembar*\nTopik: {topik}\n\nMohon segera follow up ya 🙏';
// ────────────── SCREEN: Super Admin Tab ──────────────
window._waTabSuperAdmin = function(c){
var hps = (c.super_admin_hps||[]).join('\n');
return `
👑 Super Admin via WhatsApp
Nomor whitelist berikut bisa kirim perintah ke bot via WA:
• BOT ON {PIN} → aktifkan bot
• BOT OFF {PIN} → matikan bot
• Pertanyaan apa saja (cek omzet, profit, ulang tahun customer) → AI jawab dengan akses data internal
Whitelist Nomor HP (1 nomor per baris, format 628xx)
PIN Toggle Bot (4 digit)
PIN ini wajib disertakan saat kirim BOT ON/OFF. Contoh: BOT OFF 1234
SIMPAN SUPER ADMIN
📋 Order Lookup
Izinkan customer cek status order via WA
AI akan kasih info SC saat customer sebut nomor SC-XXXX
🔒 Keamanan: Customer hanya bisa cek SC yang nomor HP-nya cocok dengan HP pengirim WA. Customer A tidak bisa cek order Customer B walau tau nomor SC-nya.
🎯 Template Prompt N8N (Recommended)
System prompt n8n Anda sudah teruji di produksi. Klik tombol di bawah untuk apply template ini ke AI System Prompt (tab AI Engine).
Apply Template N8N
`;
};
// ────────────── Override _waTabAi → tambah preset button ──────────────
// Wait until original loaded, then patch the save handler to capture new fields
var originalSave = window.waUiSave;
if(originalSave){
window.waUiSave = async function(){
var g = function(id){ return document.getElementById(id); };
// Capture super admin fields first
var extras = {};
if(g('wa_sa_hps')){
extras.super_admin_hps = g('wa_sa_hps').value.split('\n').map(function(s){return s.trim().replace(/[^0-9]/g,'');}).filter(function(s){return s.length>=10;});
}
if(g('wa_sa_pin')) extras.bot_toggle_pin = (g('wa_sa_pin').value||'').trim();
if(g('wa_ol_enabled')) extras.order_lookup_enabled = !!g('wa_ol_enabled').checked;
if(g('wa_hp_qty')) extras.hot_prospect_qty = parseInt(g('wa_hp_qty').value, 10) || 10000;
if(g('wa_hp_mkt')) extras.hot_prospect_target_mkt = g('wa_hp_mkt').value;
if(g('wa_hp_mkt_hp')) extras.hot_prospect_mkt_hp = (g('wa_hp_mkt_hp').value||'').replace(/[^0-9]/g,'');
if(g('wa_hp_msg')) extras.hot_prospect_msg = g('wa_hp_msg').value;
// Apply ke config sebelum original save
Object.assign(window._waConfig, extras);
// Call original
await originalSave();
};
}
// ────────────── Patch waBotHubScreen untuk inject tab Super Admin ──────────────
var origScreen = window.waBotHubScreen;
if(origScreen){
window.waBotHubScreen = function(){
// If current tab is super_admin, render addon
// NB: _wa_canFull tidak accessible di IIFE addon ini (scope terpisah dari module utama).
// Pakai check langsung ke CU.role.
var _curRole = String(((typeof CU!=='undefined' && CU && CU.role)) || '').toLowerCase().trim();
var _isFullAccess = (_curRole==='owner' || _curRole==='admin' || _curRole==='superadmin' || _curRole==='super_admin');
if(window._waTab === 'super_admin' && _isFullAccess){
try{
var c = window._waConfig;
var header = `
Super Admin Settings
Whitelist HP, PIN, Order Lookup, Hot Prospect
`;
var backBtn = ' Kembali ke Dashboard ';
var backBtnFloating = ' Dashboard ';
if(typeof window._waTabSuperAdmin !== 'function'){
return header + backBtn + '⚠️ Tab Super Admin belum siap (fungsi tidak ada). Refresh app dan coba lagi.
' + backBtnFloating;
}
return header + backBtn + window._waTabSuperAdmin(c) + backBtn + backBtnFloating;
}catch(eSA){
console.error('[Super Admin tab] render error:', eSA);
return '⚠️ Tab Super Admin gagal render Error: '+(eSA.message||eSA)+' Kembali
';
}
}
// Render tab original (Super Admin sudah jadi proper sub-tab di Setup grup, tidak perlu inject button ke dashboard)
return origScreen.apply(this, arguments);
};
}
console.log('[WA Addons] loaded — Super Admin, Hot Prospect, Order Lookup, N8N preset');
})();
// ╔══════════════════════════════════════════════════════════════════════════╗
// ║ MENU VALUASI PERUSAHAAN — Investment Banker Style ║
// ║ Module by Claude · v1.10.85 ║
// ║ Access: owner (default), grant per-user via MODUL_LIST ║
// ║ Storage: shared via blp_app_state.valuasiConfig ║
// ║ Pure additive — TIDAK menyentuh hitungNeraca / DOC_FONT_FORCE ║
// ╚══════════════════════════════════════════════════════════════════════════╝
(function(){
'use strict';
// ────────────────────────────────────────────────────────────────────────
// 1. CONFIG DEFAULT (persisted di DB_SETTINGS.valuasiConfig)
// ────────────────────────────────────────────────────────────────────────
const VAL_DEFAULT = {
// Cap table — pemegang saham
capTable: [
// {id, nama, lembar, role:'Founder'|'Komisaris'|'Investor'|'Karyawan', warna}
],
totalLembar: 0, // total lembar saham beredar (sesuai akta)
nominalPerLembar: 1000000, // nominal per lembar di akta (default Rp 1jt)
// Asumsi proyeksi (5 tahun)
growthY1Y2: 25, // % growth tahun 1 → 2
growthTaperY5: 10, // % growth di tahun 5 (linear taper)
discountRate: 15, // % WACC untuk DCF
terminalGrowth: 4, // % terminal growth (perpetuity)
taxRate: 22, // % corporate tax (PPh Badan Indonesia)
// Multiples (3 skenario)
ebitdaMultKons: 4, // konservatif
ebitdaMultMod: 6, // moderat
ebitdaMultOpt: 8, // optimis
perKons: 6,
perMod: 10,
perOpt: 14,
// Bobot 3 metode untuk fair value (harus jumlah = 100)
bobotNAV: 25,
bobotMultiple: 45,
bobotDCF: 30,
// Annualization
bulanOperasi: 0, // 0 = auto detect dari data transaksi
};
function valGetConfig(){
try{
if(!window.DB_SETTINGS) window.DB_SETTINGS = {};
if(!DB_SETTINGS.valuasiConfig){
DB_SETTINGS.valuasiConfig = JSON.parse(JSON.stringify(VAL_DEFAULT));
}
// Merge defaults (untuk migration kalau ada field baru)
const cfg = DB_SETTINGS.valuasiConfig;
Object.keys(VAL_DEFAULT).forEach(k=>{
if(cfg[k]===undefined) cfg[k] = JSON.parse(JSON.stringify(VAL_DEFAULT[k]));
});
return cfg;
}catch(e){ return JSON.parse(JSON.stringify(VAL_DEFAULT)); }
}
async function valSaveConfig(){
try{
// Use persistSave (standard pattern) — it handles autoSave + cloud push
if(typeof persistSave==='function'){
persistSave();
} else if(typeof autoSave==='function'){
autoSave();
} else if(typeof saveLocal==='function'){
saveLocal();
}
}catch(e){ console.warn('valSaveConfig:', e); }
}
// Helper: format rupiah singkat
function _vFmtR(v){
const av = Math.abs(v||0); const sign = v<0?'-':'';
if(av>=1e12) return sign+'Rp '+(av/1e12).toFixed(2)+' T';
if(av>=1e9) return sign+'Rp '+(av/1e9).toFixed(2)+' M';
if(av>=1e6) return sign+'Rp '+(av/1e6).toFixed(1)+' jt';
if(av>=1e3) return sign+'Rp '+Math.round(av/1e3)+' rb';
return sign+'Rp '+Math.round(av);
}
function _vFmtRFull(v){
return 'Rp '+Math.round(v||0).toLocaleString('id-ID');
}
// Versi dengan tooltip: tampil singkat, hover/tap reveal angka penuh.
// Pakai class _vTip + title (HTML5 native tooltip untuk desktop, JS handler untuk mobile).
function _vFmtRTip(v){
const short = _vFmtR(v);
const full = _vFmtRFull(v);
if(short === full) return short; // angka kecil sudah ditampilkan penuh
return `${short} `;
}
// Mobile tap handler — show toast/floating bubble dengan angka penuh
if(typeof window._vTipShow !== 'function'){
window._vTipShow = function(el, evt){
try{
evt && evt.stopPropagation && evt.stopPropagation();
const full = el.getAttribute('data-full') || el.title || '';
if(!full) return;
// Remove existing bubble
document.querySelectorAll('._vTipBubble').forEach(b=>b.remove());
const bubble = document.createElement('div');
bubble.className = '_vTipBubble';
bubble.textContent = full;
bubble.style.cssText = 'position:fixed;z-index:99999;background:#26215C;color:#fff;padding:8px 14px;border-radius:8px;font-size:13px;font-weight:700;font-family:inherit;box-shadow:0 8px 24px rgba(0,0,0,.3);pointer-events:none;white-space:nowrap;transform:translate(-50%,-100%);margin-top:-8px';
const rect = el.getBoundingClientRect();
bubble.style.left = (rect.left + rect.width/2) + 'px';
bubble.style.top = rect.top + 'px';
document.body.appendChild(bubble);
setTimeout(()=>{ try{ bubble.remove(); }catch(e){} }, 2500);
}catch(e){}
};
}
function _vFmtPct(v){ return ((v||0)*100).toFixed(1)+'%'; }
function _vFmtPctRaw(v){ return (v||0).toFixed(1)+'%'; }
// ────────────────────────────────────────────────────────────────────────
// 2. CORE CALCULATIONS — Tarik data dari modul existing (read-only)
// ────────────────────────────────────────────────────────────────────────
// Deteksi bulan operasi (transaksi pertama → sekarang)
function _vBulanOperasi(){
try{
const cfg = valGetConfig();
if(cfg.bulanOperasi && cfg.bulanOperasi>0) return cfg.bulanOperasi;
// Auto-detect: dari DB_KAS transaksi pertama
const rows = (DB_KAS||[]).filter(k=>k && k.tgl && !k.deletedAt).sort((a,b)=>(a.tgl||'').localeCompare(b.tgl||''));
if(!rows.length) return 1;
const tglAwal = new Date(String(rows[0].tgl).slice(0,10));
const today = new Date();
const monthDiff = (today.getFullYear()-tglAwal.getFullYear())*12 + (today.getMonth()-tglAwal.getMonth());
return Math.max(1, monthDiff+1);
}catch(e){ return 1; }
}
// Ambil snapshot finansial dari hitungNeraca + plTotal
function _vFinSnapshot(){
try{
const n = (typeof hitungNeraca==='function') ? hitungNeraca() : {};
const pl = (typeof plTotal==='function') ? plTotal() : {};
const bulan = _vBulanOperasi();
// EBITDA = Laba Bersih + Pajak + Penyusutan + Amortisasi (+ Bunga, kalau ada)
const labaBersih = pl.labaBersih || 0;
const pajak = pl.pajakBadan || 0;
const penyusutan = pl.bebanPenyusutan || 0;
const amortisasi = pl.bebanAmortisasi || 0;
// Bunga pinjaman (kalau ada di kas — kategori BUNGA_PINJAMAN atau sejenis)
let bunga = 0;
try{
bunga = (DB_KAS||[]).filter(k=>k && /BUNGA|INTEREST/i.test(k.kategori||'') && !k.deletedAt)
.reduce((s,k)=>s+(+k.keluar||0),0);
}catch(e){}
const ebitda = labaBersih + pajak + penyusutan + amortisasi + bunga;
// Pendapatan & HPP & gross profit
const revenue = pl.totalPendapatan || 0;
const hpp = pl.totalHPP || 0;
const grossProfit = revenue - hpp;
const labaSebelumPajak = pl.labaSebelumPajak || 0;
// Annualization: data partial → tahun penuh
const annualizer = 12 / Math.max(1, bulan);
return {
bulan,
// Posisi neraca (point-in-time, tidak diannualize)
totalAset: n.totalAktiva||0,
totalKewajiban: n.totalKewajiban||0,
totalEkuitas: n.totalEkuitas||0,
kasBank: n.kasBank||0,
piutang: n.piutang||0,
persediaan: n.persediaan||0,
hutangSupplier: n.hutangSupplier||0,
hutangPinjaman: n.hutangPinjaman||0,
asetTetapNetto: n.asetTetapNetto||0,
// P&L periode aktual
revenue, hpp, grossProfit, labaSebelumPajak, labaBersih,
penyusutan, amortisasi, bunga, pajak, ebitda,
// P&L annualized (proyeksi tahun penuh)
revenueY: revenue * annualizer,
ebitdaY: ebitda * annualizer,
labaBersihY: labaBersih * annualizer,
// Margin
marginKotor: revenue>0 ? grossProfit/revenue : 0,
marginEbitda: revenue>0 ? ebitda/revenue : 0,
marginBersih: revenue>0 ? labaBersih/revenue : 0,
annualizer
};
}catch(e){
console.warn('vFinSnapshot:', e);
return { bulan:1, totalAset:0, totalKewajiban:0, totalEkuitas:0, revenue:0, ebitda:0, labaBersih:0, revenueY:0, ebitdaY:0, labaBersihY:0, annualizer:12 };
}
}
// METODE 1: NAV (Asset-Based)
function _vValuasiNAV(snap){
return {
value: snap.totalEkuitas,
label: 'Net Asset Value (NAV)',
desc: 'Total Aset dikurangi Total Kewajiban. Mencerminkan "nilai buku" perusahaan.',
detail: [
['Total Aset', snap.totalAset],
['(−) Total Kewajiban', -snap.totalKewajiban],
['Net Asset Value', snap.totalEkuitas]
]
};
}
// METODE 2: Multiple of EBITDA (3 skenario)
function _vValuasiMultiple(snap, cfg){
const ebitdaY = snap.ebitdaY;
const labaY = snap.labaBersihY;
// 3 skenario EBITDA
const evKons = ebitdaY * cfg.ebitdaMultKons;
const evMod = ebitdaY * cfg.ebitdaMultMod;
const evOpt = ebitdaY * cfg.ebitdaMultOpt;
// Equity Value = Enterprise Value - Net Debt
const netDebt = (snap.hutangPinjaman||0) - (snap.kasBank||0);
const eqKons = Math.max(0, evKons - netDebt);
const eqMod = Math.max(0, evMod - netDebt);
const eqOpt = Math.max(0, evOpt - netDebt);
// PER-based
const perKons = Math.max(0, labaY * cfg.perKons);
const perMod = Math.max(0, labaY * cfg.perMod);
const perOpt = Math.max(0, labaY * cfg.perOpt);
// Average dari moderate EBITDA & PER moderate
const avgModerate = (eqMod + perMod) / 2;
return {
value: avgModerate,
label: 'Earnings Multiple (EBITDA & PER)',
desc: 'Penilaian berdasarkan profit × pengali industri. Standar valuasi M&A.',
ebitdaY, labaY, netDebt,
ebitda: { kons:eqKons, mod:eqMod, opt:eqOpt, multKons:cfg.ebitdaMultKons, multMod:cfg.ebitdaMultMod, multOpt:cfg.ebitdaMultOpt },
per: { kons:perKons, mod:perMod, opt:perOpt, multKons:cfg.perKons, multMod:cfg.perMod, multOpt:cfg.perOpt }
};
}
// METODE 3: DCF (Discounted Cash Flow) — 5 tahun + terminal
function _vValuasiDCF(snap, cfg){
const fcfY0 = snap.ebitdaY * (1 - cfg.taxRate/100); // simplistic: EBITDA after tax sebagai FCF proxy
// Growth schedule: Y1→Y2 = growthY1Y2, taper linear ke growthTaperY5 di Y5
const g1 = cfg.growthY1Y2/100;
const g5 = cfg.growthTaperY5/100;
const growths = [g1, g1*0.85, g1*0.65 + g5*0.35, g1*0.35 + g5*0.65, g5];
const r = cfg.discountRate/100;
const tg = cfg.terminalGrowth/100;
const projections = [];
let fcf = fcfY0;
let pvSum = 0;
for(let t=1; t<=5; t++){
fcf = fcf * (1 + growths[t-1]);
const discount = Math.pow(1+r, t);
const pv = fcf / discount;
pvSum += pv;
projections.push({ year:t, fcf, growth:growths[t-1], discount, pv });
}
// Terminal value (Gordon Growth)
const fcfY6 = fcf * (1 + tg);
const terminalValue = fcfY6 / (r - tg);
const terminalPV = terminalValue / Math.pow(1+r, 5);
const enterpriseValue = pvSum + terminalPV;
const netDebt = (snap.hutangPinjaman||0) - (snap.kasBank||0);
const equityValue = Math.max(0, enterpriseValue - netDebt);
return {
value: equityValue,
label: 'Discounted Cash Flow (DCF)',
desc: 'Proyeksi arus kas 5 tahun + terminal value, di-diskonto ke nilai sekarang.',
fcfY0, projections, terminalValue, terminalPV, enterpriseValue, netDebt, equityValue,
assumptions: { r, tg, growths }
};
}
// FAIR VALUE: weighted average dari 3 metode
function _vFairValue(nav, mult, dcf, cfg){
const sumBobot = cfg.bobotNAV + cfg.bobotMultiple + cfg.bobotDCF;
if(sumBobot<=0) return { value:0, range:{low:0,high:0} };
const wNav = cfg.bobotNAV/sumBobot;
const wMul = cfg.bobotMultiple/sumBobot;
const wDcf = cfg.bobotDCF/sumBobot;
const fair = (nav.value*wNav) + (mult.value*wMul) + (dcf.value*wDcf);
// Range: ambil min & max dari semua skenario
const vals = [
nav.value,
mult.ebitda?.kons||0, mult.ebitda?.mod||0, mult.ebitda?.opt||0,
mult.per?.kons||0, mult.per?.mod||0, mult.per?.opt||0,
dcf.value
].filter(v=>v>0);
const low = vals.length ? Math.min(...vals) : 0;
const high = vals.length ? Math.max(...vals) : 0;
return { value:fair, range:{low, high}, weights:{nav:wNav, mult:wMul, dcf:wDcf} };
}
// KEY RATIOS
function _vKeyRatios(snap){
const ekuitas = snap.totalEkuitas || 1;
const aset = snap.totalAset || 1;
const piutangPersediaan = (snap.piutang||0) + (snap.persediaan||0);
const hutangSupplier = snap.hutangSupplier || 1;
const currentAssets = (snap.kasBank||0) + (snap.piutang||0) + (snap.persediaan||0);
const currentLiab = (snap.hutangSupplier||0); // rough proxy
return {
ROE: snap.labaBersihY / Math.max(1, snap.totalEkuitas),
ROA: snap.labaBersihY / Math.max(1, snap.totalAset),
DER: snap.totalKewajiban / Math.max(1, snap.totalEkuitas),
currentRatio: currentAssets / Math.max(1, currentLiab),
assetTurnover: snap.revenueY / Math.max(1, snap.totalAset),
grossMargin: snap.marginKotor,
ebitdaMargin: snap.marginEbitda,
netMargin: snap.marginBersih
};
}
// ────────────────────────────────────────────────────────────────────────
// 3. CAP TABLE HELPERS
// ────────────────────────────────────────────────────────────────────────
const VAL_PALETTE = ['#534AB7','#E07020','#1D9E75','#1A6FA8','#B89358','#C0392B','#7F77DD','#0F6E56','#EF9F27','#475569'];
function _vCapTableEnriched(cfg, valuasiPerLembar){
const total = cfg.totalLembar || (cfg.capTable||[]).reduce((s,p)=>s+(+p.lembar||0),0) || 1;
return (cfg.capTable||[]).map((p,i)=>({
...p,
pct: (p.lembar||0) / total,
nilai: (p.lembar||0) * valuasiPerLembar,
warna: p.warna || VAL_PALETTE[i % VAL_PALETTE.length]
}));
}
// Self-contained modal helper (no dependency on global showModal/closeModal)
function _valShowModal(innerHtml){
_valCloseModal();
const wrap = document.createElement('div');
wrap.id = '_valModal';
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(28,21,80,.55);backdrop-filter:blur(4px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:20px';
wrap.onclick = function(e){ if(e.target===wrap) _valCloseModal(); };
wrap.innerHTML = innerHtml;
document.body.appendChild(wrap);
}
function _valCloseModal(){
const el = document.getElementById('_valModal');
if(el) el.remove();
}
window.valCloseModal = _valCloseModal;
// Modal: tambah/edit pemegang saham
window.valEditPemegangModal = function(idx){
const cfg = valGetConfig();
const isEdit = idx!==undefined && idx>=0 && idx
${isEdit?'Edit':'Tambah'} Pemegang Saham
${isEdit?` Hapus `:''}
Batal
Simpan
`;
_valShowModal(html);
};
window.valSavePemegang = async function(idx){
const cfg = valGetConfig();
const nama = (document.getElementById('valPmName').value||'').trim();
const lembar = +document.getElementById('valPmLbr').value || 0;
const role = document.getElementById('valPmRole').value || 'Founder';
const warna = document.getElementById('valPmWarna').value || VAL_PALETTE[0];
if(!nama){ alert('Nama harus diisi'); return; }
if(lembar<=0){ alert('Jumlah lembar harus > 0'); return; }
const newPm = { id:'pm_'+Date.now()+'_'+Math.random().toString(36).slice(2,7), nama, lembar, role, warna };
if(idx>=0){
newPm.id = cfg.capTable[idx].id || newPm.id;
cfg.capTable[idx] = newPm;
} else {
cfg.capTable.push(newPm);
}
await valSaveConfig();
_valCloseModal();
if(typeof render==='function') render('valuasi');
if(typeof showToast==='function') showToast('✅ Pemegang saham tersimpan','green');
};
window.valDelPemegang = async function(idx){
if(!confirm('Hapus pemegang saham ini?')) return;
const cfg = valGetConfig();
cfg.capTable.splice(idx,1);
await valSaveConfig();
_valCloseModal();
if(typeof render==='function') render('valuasi');
if(typeof showToast==='function') showToast('Pemegang saham dihapus','red');
};
// Modal: setup akta (total lembar saham + nominal)
window.valSetupAktaModal = function(){
const cfg = valGetConfig();
const html = ``;
_valShowModal(html);
};
window.valSaveAkta = async function(){
const cfg = valGetConfig();
cfg.totalLembar = +document.getElementById('valTotLbr').value || 0;
cfg.nominalPerLembar = +document.getElementById('valNomLbr').value || 0;
cfg.bulanOperasi = +document.getElementById('valBlnOp').value || 0;
await valSaveConfig();
_valCloseModal();
if(typeof render==='function') render('valuasi');
if(typeof showToast==='function') showToast('✅ Setup akta tersimpan','green');
};
// Modal: edit assumptions (multiples, growth, discount)
window.valEditAsumsiModal = function(){
const cfg = valGetConfig();
const html = `
Asumsi Valuasi
Saran benchmark industri kemasan plastik Indonesia (TRST, IGAR, AKPI di IDX):
• EBITDA Multiple: Konservatif 4× · Moderat 6× · Optimis 8× (dengan DLOM ~25% untuk private)
• PER: Konservatif 6× · Moderat 10× · Optimis 14×
• WACC (Discount Rate): 12-18% untuk SME Indonesia
• Terminal Growth: 3-5% (selaras GDP growth)
Multiple of EBITDA (3 Skenario)
${[['Konservatif','ebitdaMultKons','#C0392B'],['Moderat','ebitdaMultMod','#534AB7'],['Optimis','ebitdaMultOpt','#1D9E75']].map(([lb,k,c])=>`
${lb}
×
`).join('')}
Price/Earnings Ratio (PER, 3 Skenario)
${[['Konservatif','perKons','#C0392B'],['Moderat','perMod','#534AB7'],['Optimis','perOpt','#1D9E75']].map(([lb,k,c])=>`
${lb}
×
`).join('')}
DCF Assumptions
Growth Y1→Y2 (%)
Growth Y5 (taper, %)
Discount Rate / WACC (%)
Terminal Growth (%)
Corporate Tax Rate (%)
Bobot Metode untuk Fair Value (jumlah harus 100%)
${[['NAV','bobotNAV'],['Multiple','bobotMultiple'],['DCF','bobotDCF']].map(([lb,k])=>`
${lb}
%
`).join('')}
Reset Default
Batal
Simpan
`;
_valShowModal(html);
};
window.valSaveAsumsi = async function(){
const cfg = valGetConfig();
const fields = ['ebitdaMultKons','ebitdaMultMod','ebitdaMultOpt','perKons','perMod','perOpt','growthY1Y2','growthTaperY5','discountRate','terminalGrowth','taxRate','bobotNAV','bobotMultiple','bobotDCF'];
fields.forEach(f=>{
const el = document.getElementById('val_'+f);
if(el) cfg[f] = +el.value || 0;
});
// Validasi bobot
const sumBobot = cfg.bobotNAV + cfg.bobotMultiple + cfg.bobotDCF;
if(Math.abs(sumBobot-100) > 0.5){
if(!confirm('Total bobot = '+sumBobot+'% (idealnya 100%). Lanjut simpan?')) return;
}
await valSaveConfig();
_valCloseModal();
if(typeof render==='function') render('valuasi');
if(typeof showToast==='function') showToast('✅ Asumsi tersimpan','green');
};
window.valResetAsumsi = async function(){
if(!confirm('Reset semua asumsi ke nilai default?')) return;
const cfg = valGetConfig();
const keep = { capTable:cfg.capTable, totalLembar:cfg.totalLembar, nominalPerLembar:cfg.nominalPerLembar, bulanOperasi:cfg.bulanOperasi };
DB_SETTINGS.valuasiConfig = Object.assign(JSON.parse(JSON.stringify(VAL_DEFAULT)), keep);
await valSaveConfig();
_valCloseModal();
if(typeof render==='function') render('valuasi');
if(typeof showToast==='function') showToast('Asumsi di-reset ke default','blue');
};
// ────────────────────────────────────────────────────────────────────────
// 4. MAIN SCREEN: valuasiScreen()
// ────────────────────────────────────────────────────────────────────────
window.valuasiScreen = function(){
// Akses guard: owner default, atau admin/marketing yang di-grant
try{
if(typeof CU!=='undefined' && CU){
const role = CU.role||'';
if(role==='customer'){
return `Menu ini tidak tersedia untuk customer.
`;
}
// Owner bypass; non-owner cek canAccessModul
if(role!=='owner' && typeof canAccessModul==='function' && !canAccessModul('valuasi')){
return `Anda tidak punya akses ke menu Valuasi Perusahaan. Hubungi Owner untuk memberi akses.
`;
}
}
}catch(e){}
const cfg = valGetConfig();
const snap = _vFinSnapshot();
const nav = _vValuasiNAV(snap);
const mult = _vValuasiMultiple(snap, cfg);
const dcf = _vValuasiDCF(snap, cfg);
const fair = _vFairValue(nav, mult, dcf, cfg);
const ratios = _vKeyRatios(snap);
const totalLembar = cfg.totalLembar || (cfg.capTable||[]).reduce((s,p)=>s+(+p.lembar||0),0) || 0;
const nilaiPerLembar = totalLembar>0 ? fair.value/totalLembar : 0;
const nilaiPerLembarLow = totalLembar>0 ? fair.range.low/totalLembar : 0;
const nilaiPerLembarHigh = totalLembar>0 ? fair.range.high/totalLembar : 0;
const capTable = _vCapTableEnriched(cfg, nilaiPerLembar).sort((a,b)=>(b.lembar||0)-(a.lembar||0));
// ── Section 1: HEADER + EXECUTIVE SUMMARY ──
const headerHtml = `
VALUATION REPORT
${(DB_SETTINGS&&DB_SETTINGS.namaPerusahaan)||'PT. Bintang Lima Plastindo'}
Per ${new Date().toLocaleDateString('id-ID',{day:'2-digit',month:'long',year:'numeric'})} · ${snap.bulan} bulan operasi · Annualized
Akta
Asumsi
Export PDF
Fair Value Estimate
${_vFmtRTip(fair.value)}
Range: ${_vFmtRTip(fair.range.low)} — ${_vFmtRTip(fair.range.high)}
Per Lembar Saham
${totalLembar>0?_vFmtRFull(nilaiPerLembar):'Belum ada lembar '}
${totalLembar>0?totalLembar.toLocaleString('id-ID')+' lembar beredar':'Setup sekarang '}
Revenue (Annualized)
${_vFmtRTip(snap.revenueY)}
EBITDA Margin: ${_vFmtPct(snap.marginEbitda)}
`;
// ── Section 2: 3 METODE VALUASI (cards) ──
const metodeHtml = `
${_vFmtRTip(nav.value)}
${nav.desc}
${nav.detail.map((d,i)=>`${d[0]} ${_vFmtRTip(d[1])} `).join('')}
Metode 2
Earnings Multiple
Bobot ${cfg.bobotMultiple}%
${_vFmtRTip(mult.value)}
EV/EBITDA + PER (rata-rata skenario moderat)
${[['Kons','kons','#C0392B'],['Mod','mod','#534AB7'],['Opt','opt','#1D9E75']].map(([lb,k,c])=>`
${lb} · ${cfg['ebitdaMult'+(lb==='Kons'?'Kons':lb==='Mod'?'Mod':'Opt')]}× EBITDA
${_vFmtRTip(mult.ebitda[k])}
`).join('')}
${[['Kons','kons','#C0392B'],['Mod','mod','#534AB7'],['Opt','opt','#1D9E75']].map(([lb,k,c])=>`
PER ${cfg['per'+(lb==='Kons'?'Kons':lb==='Mod'?'Mod':'Opt')]}×
${_vFmtRTip(mult.per[k])}
`).join('')}
Annualized EBITDA: ${_vFmtRTip(mult.ebitdaY)} · Net Debt: ${_vFmtRTip(mult.netDebt)}
${_vFmtRTip(dcf.value)}
${dcf.desc}
Tahun
FCF
PV
${dcf.projections.map(p=>`
Y${p.year} (+${_vFmtPctRaw(p.growth*100)})
${_vFmtRTip(p.fcf)}
${_vFmtRTip(p.pv)}
`).join('')}
Terminal Value (PV)
${_vFmtRTip(dcf.terminalPV)}
Enterprise Value
${_vFmtRTip(dcf.enterpriseValue)}
WACC: ${cfg.discountRate}% · Terminal Growth: ${cfg.terminalGrowth}% · Tax: ${cfg.taxRate}%
`;
// ── Section 3: CAP TABLE ──
const capTableHtml = `
Cap Table — Pemegang Saham
${(capTable||[]).length} pemegang saham · ${totalLembar.toLocaleString('id-ID')} lembar beredar
Tambah Pemegang
${capTable.length===0 ? `
Belum ada pemegang saham terdaftar. Klik Tambah Pemegang untuk mulai mengisi cap table sesuai akta perusahaan.
` : `
${_vRenderDonutChart(capTable, totalLembar)}
PEMEGANG
LEMBAR
%
NILAI
${capTable.map((p,i)=>{
const realIdx = (cfg.capTable||[]).findIndex(x=>x.id===p.id);
return `
${(p.lembar||0).toLocaleString('id-ID')}
${_vFmtPct(p.pct)}
${_vFmtRTip(p.nilai)}
`;
}).join('')}
TOTAL
${totalLembar.toLocaleString('id-ID')}
100%
${_vFmtRTip(capTable.reduce((s,p)=>s+(p.nilai||0),0))}
`}
`;
// ── Section 4: FINANCIAL HIGHLIGHTS ──
const finHighlights = [
['Revenue (annualized)', snap.revenueY, '#534AB7', 'ti-trending-up'],
['Gross Profit (annualized)', snap.grossProfit*snap.annualizer, '#1D9E75', 'ti-chart-bar'],
['EBITDA (annualized)', snap.ebitdaY, '#E07020', 'ti-coin'],
['Laba Bersih (annualized)', snap.labaBersihY, '#1A6FA8', 'ti-cash'],
['Total Aset', snap.totalAset, '#475569', 'ti-building'],
['Total Ekuitas', snap.totalEkuitas, '#7F77DD', 'ti-scale'],
];
const finHighlightsHtml = `
Financial Highlights
${finHighlights.map(([lb,v,c,ic])=>`
`).join('')}
`;
// ── Section 5: KEY RATIOS ──
const ratiosList = [
['ROE (Return on Equity)', _vFmtPct(ratios.ROE), ratios.ROE>=0.15?'#1D9E75':ratios.ROE>=0.05?'#E07020':'#C0392B', 'Profit per Rupiah ekuitas pemilik. >15% = sangat baik.'],
['ROA (Return on Assets)', _vFmtPct(ratios.ROA), ratios.ROA>=0.08?'#1D9E75':ratios.ROA>=0.03?'#E07020':'#C0392B', 'Efisiensi penggunaan aset menghasilkan laba. >8% = sehat.'],
['Debt-to-Equity (DER)', ratios.DER.toFixed(2)+'×', ratios.DER<=1?'#1D9E75':ratios.DER<=2?'#E07020':'#C0392B', 'Rasio utang vs modal. <1× = konservatif. >2× = leverage tinggi.'],
['Current Ratio', ratios.currentRatio.toFixed(2)+'×', ratios.currentRatio>=1.5?'#1D9E75':ratios.currentRatio>=1?'#E07020':'#C0392B', 'Likuiditas jangka pendek. >1.5× = aman.'],
['Asset Turnover', ratios.assetTurnover.toFixed(2)+'×', ratios.assetTurnover>=1?'#1D9E75':ratios.assetTurnover>=0.5?'#E07020':'#C0392B', 'Revenue per Rupiah aset. >1× = sangat produktif.'],
['Gross Margin', _vFmtPct(ratios.grossMargin), ratios.grossMargin>=0.25?'#1D9E75':ratios.grossMargin>=0.15?'#E07020':'#C0392B', 'Margin kotor. Industri kemasan plastik 15-30%.'],
['EBITDA Margin', _vFmtPct(ratios.ebitdaMargin), ratios.ebitdaMargin>=0.15?'#1D9E75':ratios.ebitdaMargin>=0.08?'#E07020':'#C0392B', 'Profit operasional. >15% = unggul di industri kemasan.'],
['Net Margin', _vFmtPct(ratios.netMargin), ratios.netMargin>=0.1?'#1D9E75':ratios.netMargin>=0.04?'#E07020':'#C0392B', 'Margin bersih. >10% = sangat baik untuk manufaktur.'],
];
const ratiosHtml = `
Key Financial Ratios
${ratiosList.map(([lb,v,c,desc])=>`
`).join('')}
`;
// ── Section 6: DISCLAIMER ──
const disclaimerHtml = `
Disclaimer: Valuasi ini adalah estimasi internal berbasis data finansial perusahaan, asumsi growth & multiple yang dapat diatur (Asumsi), dan metodologi standar industri (NAV, EBITDA Multiple, DCF). Bukan opini valuasi formal dari KAP/jasa appraisal bersertifikat. Untuk keperluan resmi (M&A, IPO, audit, jaminan kredit), gunakan jasa Penilai Publik (KJPP) bersertifikat OJK. Annualization Y1 didasarkan pada data ${snap.bulan} bulan operasi terkini.
`;
return `
${headerHtml}
${metodeHtml}
${capTableHtml}
${finHighlightsHtml}
${ratiosHtml}
${disclaimerHtml}
`;
};
// Donut chart SVG sederhana
function _vRenderDonutChart(capTable, total){
if(!capTable || capTable.length===0 || total<=0) return 'Belum ada data
';
const cx=100, cy=100, r=80, rIn=50;
let acc = 0;
const arcs = capTable.map(p=>{
const frac = (p.lembar||0)/total;
const startAngle = acc * 2 * Math.PI - Math.PI/2;
acc += frac;
const endAngle = acc * 2 * Math.PI - Math.PI/2;
const x1 = cx + r*Math.cos(startAngle);
const y1 = cy + r*Math.sin(startAngle);
const x2 = cx + r*Math.cos(endAngle);
const y2 = cy + r*Math.sin(endAngle);
const x3 = cx + rIn*Math.cos(endAngle);
const y3 = cy + rIn*Math.sin(endAngle);
const x4 = cx + rIn*Math.cos(startAngle);
const y4 = cy + rIn*Math.sin(startAngle);
const largeArc = frac>0.5 ? 1 : 0;
const path = `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${x3} ${y3} A ${rIn} ${rIn} 0 ${largeArc} 0 ${x4} ${y4} Z`;
return ` `;
}).join('');
return `
${arcs}
TOTAL LEMBAR
${total.toLocaleString('id-ID')}
`;
}
// ────────────────────────────────────────────────────────────────────────
// 5. PDF EXPORT — Full Prospectus
// ────────────────────────────────────────────────────────────────────────
window.lapValuasiPerusahaan = async function(){
try{
// Load libs if not already
if(typeof window.html2canvas!=='function' || !window.jspdf){
if(typeof loadLib==='function'){
await loadLib('https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js');
await loadLib('https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js');
}
}
if(typeof window.html2canvas!=='function' || !window.jspdf){
alert('Gagal memuat library PDF. Cek koneksi.');
return;
}
if(typeof showToast==='function') showToast('⏳ Generating Valuation Report (8-12 detik)...','blue');
const cfg = valGetConfig();
const snap = _vFinSnapshot();
const nav = _vValuasiNAV(snap);
const mult = _vValuasiMultiple(snap, cfg);
const dcf = _vValuasiDCF(snap, cfg);
const fair = _vFairValue(nav, mult, dcf, cfg);
const ratios = _vKeyRatios(snap);
const totalLembar = cfg.totalLembar || (cfg.capTable||[]).reduce((s,p)=>s+(+p.lembar||0),0) || 0;
const nilaiPerLembar = totalLembar>0 ? fair.value/totalLembar : 0;
const capTable = _vCapTableEnriched(cfg, nilaiPerLembar).sort((a,b)=>(b.lembar||0)-(a.lembar||0));
const today = new Date().toLocaleDateString('id-ID',{day:'2-digit',month:'long',year:'numeric'});
const npmName = (DB_SETTINGS&&DB_SETTINGS.namaPerusahaan)||'PT. Bintang Lima Plastindo';
const ratiosArr = [
['ROE', _vFmtPct(ratios.ROE)],
['ROA', _vFmtPct(ratios.ROA)],
['Debt-to-Equity', ratios.DER.toFixed(2)+'×'],
['Current Ratio', ratios.currentRatio.toFixed(2)+'×'],
['Asset Turnover', ratios.assetTurnover.toFixed(2)+'×'],
['Gross Margin', _vFmtPct(ratios.grossMargin)],
['EBITDA Margin', _vFmtPct(ratios.ebitdaMargin)],
['Net Margin', _vFmtPct(ratios.netMargin)],
];
// Render full prospectus HTML (DOC_FONT_FORCE = Roboto + 5 fallbacks)
const FONT_STACK = "'Roboto','Helvetica Neue',Helvetica,Arial,'DejaVu Sans',sans-serif";
const html = `
CONFIDENTIAL
VALUATION REPORT
2026
${npmName.toUpperCase()}
Industri Kemasan Plastik Fleksibel & Karung Laminasi
Voza Tower Lt. 20 · Jl. Mayjen HR. Muhammad No. 31 Putat Gede, Sukomanunggal, Surabaya 60189
PERIODE PENILAIAN
Per ${today}
Internal Management Valuation · Methodology: NAV + EV/EBITDA + DCF
— Prepared via My BLP Internal System —
${npmName.replace(/^PT\.\s*/,'').toUpperCase()}
Voza Tower Lt.20 - Jl. Mayjen HR. Muhammad No.31 Putat Gede, Sukomanunggal, Surabaya 60189 +6285 79078 3333 · www.bintanglimaplastindo.com
EXECUTIVE SUMMARY
Ringkasan Hasil Valuasi · Per ${today}
Fair Value Estimate (Weighted Average)
${_vFmtRFull(fair.value)}
(${_vFmtR(fair.value)})
Range: ${_vFmtR(fair.range.low)} — ${_vFmtR(fair.range.high)} · Detail di bawah
Nilai per Lembar Saham
${totalLembar>0?_vFmtRFull(nilaiPerLembar):'Belum di-setup'}
Total Lembar Beredar
${totalLembar>0?totalLembar.toLocaleString('id-ID'):'-'}
Ringkasan 3 Metode Valuasi
Metode Nilai Bobot Kontribusi
Net Asset Value (NAV) ${_vFmtRFull(nav.value)} ${cfg.bobotNAV}% ${_vFmtRFull(nav.value*fair.weights.nav)}
Earnings Multiple (EBITDA & PER) ${_vFmtRFull(mult.value)} ${cfg.bobotMultiple}% ${_vFmtRFull(mult.value*fair.weights.mult)}
Discounted Cash Flow (DCF, 5Y) ${_vFmtRFull(dcf.value)} ${cfg.bobotDCF}% ${_vFmtRFull(dcf.value*fair.weights.dcf)}
FAIR VALUE (Weighted Average) — 100% ${_vFmtRFull(fair.value)}
Highlights Operasional
Periode Operasi ${snap.bulan} bulan Revenue (Annualized) ${_vFmtRFull(snap.revenueY)}
EBITDA (Annualized) ${_vFmtRFull(snap.ebitdaY)} EBITDA Margin ${_vFmtPct(snap.marginEbitda)}
Laba Bersih (Annualized) ${_vFmtRFull(snap.labaBersihY)} Net Margin ${_vFmtPct(snap.marginBersih)}
Total Aset ${_vFmtRFull(snap.totalAset)} Total Ekuitas ${_vFmtRFull(snap.totalEkuitas)}
Catatan: Angka "Annualized" diproyeksikan dari ${snap.bulan} bulan data aktual × ${snap.annualizer.toFixed(2)}. Untuk perusahaan tahun pertama, angka ini bersifat run-rate estimate, bukan realisasi tahun penuh.
Hal. 1
${npmName.replace(/^PT\.\s*/,'').toUpperCase()}
Valuation Report · Per ${today}
METODE 1 — NET ASSET VALUE (NAV)
Asset-Based Approach · Konservatif (Floor Value)
Hasil Valuasi NAV
Total Aset dikurangi Total Kewajiban
${_vFmtRFull(nav.value)}
Mencerminkan nilai buku (book value) perusahaan. Cocok sebagai dasar minimum (floor) — investor tidak akan membayar kurang dari nilai aset bersih yang likuid.
Rumus: NAV = Total Aset − Total Kewajiban
NAV = ${_vFmtRFull(snap.totalAset)} − ${_vFmtRFull(snap.totalKewajiban)} = ${_vFmtRFull(snap.totalEkuitas)}
Breakdown Posisi Neraca
AKTIVA
Kas & Bank ${_vFmtRFull(snap.kasBank)}
Piutang Usaha ${_vFmtRFull(snap.piutang)}
Persediaan ${_vFmtRFull(snap.persediaan)}
Aset Tetap (Netto) ${_vFmtRFull(snap.asetTetapNetto)}
Aset Lain-lain ${_vFmtRFull(snap.totalAset - snap.kasBank - snap.piutang - snap.persediaan - snap.asetTetapNetto)}
TOTAL AKTIVA ${_vFmtRFull(snap.totalAset)}
PASIVA — Kewajiban
Hutang Supplier ${_vFmtRFull(snap.hutangSupplier)}
Hutang Pinjaman ${_vFmtRFull(snap.hutangPinjaman)}
Kewajiban Lain-lain ${_vFmtRFull(snap.totalKewajiban - snap.hutangSupplier - snap.hutangPinjaman)}
TOTAL KEWAJIBAN ${_vFmtRFull(snap.totalKewajiban)}
TOTAL EKUITAS = NAV ${_vFmtRFull(snap.totalEkuitas)}
Hal. 2
${npmName.replace(/^PT\.\s*/,'').toUpperCase()}
Valuation Report · Per ${today}
METODE 2 — EARNINGS MULTIPLE
EV/EBITDA & PER · Market-Based Approach
Hasil Valuasi Multiple (rata-rata skenario moderat)
EV/EBITDA + PER Average
${_vFmtRFull(mult.value)}
Standar valuasi M&A — mengalikan profit kunci dengan pengali industri. Benchmark IDX kemasan plastik (TRST, IGAR, AKPI) dengan diskon DLOM ~25% untuk perusahaan privat.
EV/EBITDA: Enterprise Value = EBITDA × Multiple
EBITDA Annualized = ${_vFmtRFull(mult.ebitdaY)}
Equity Value = EV − Net Debt (${_vFmtRFull(mult.netDebt)})
EV/EBITDA — 3 Skenario
KONSERVATIF · ${cfg.ebitdaMultKons}×
${_vFmtRFull(mult.ebitda.kons)}
MODERAT · ${cfg.ebitdaMultMod}×
${_vFmtRFull(mult.ebitda.mod)}
OPTIMIS · ${cfg.ebitdaMultOpt}×
${_vFmtRFull(mult.ebitda.opt)}
PER (Price/Earnings Ratio): Equity Value = Net Income × PER
Net Income Annualized = ${_vFmtRFull(mult.labaY)}
PER — 3 Skenario
KONSERVATIF · ${cfg.perKons}×
${_vFmtRFull(mult.per.kons)}
MODERAT · ${cfg.perMod}×
${_vFmtRFull(mult.per.mod)}
OPTIMIS · ${cfg.perOpt}×
${_vFmtRFull(mult.per.opt)}
Benchmark Industri Kemasan Plastik Indonesia
Skenario EV/EBITDA PER Profil
Konservatif 4-5× 6-8× Private sale cepat, akuisisi distress
Moderat 6-7× 10-12× Investor strategis, Series A/B
Optimis 8-10× 14-16× Pre-IPO, akuisisi multinasional
Referensi: TRST (Trias Sentosa), IGAR (Champion Pacific), AKPI (Argha Karya). Untuk perusahaan privat, EV public dikurangi Discount for Lack of Marketability (DLOM) 20-30%.
Hal. 3
${npmName.replace(/^PT\.\s*/,'').toUpperCase()}
Valuation Report · Per ${today}
METODE 3 — DISCOUNTED CASH FLOW (DCF)
Income-Based Approach · 5-Year Projection + Terminal Value
Hasil Valuasi DCF
Net Present Value (NPV) of Future Cash Flows
${_vFmtRFull(dcf.value)}
Metode paling fundamental — memproyeksikan arus kas masa depan & mendiskontonya ke nilai sekarang. Standar emas valuasi perusahaan growth-stage.
FCF Awal (Y0): EBITDA × (1 − Tax Rate)
FCF Y0 = ${_vFmtRFull(snap.ebitdaY)} × (1 − ${cfg.taxRate}%) = ${_vFmtRFull(dcf.fcfY0)}
Proyeksi 5 Tahun
Tahun Growth FCF Discount Factor Present Value
${dcf.projections.map(p=>`
Tahun ${p.year}
+${_vFmtPctRaw(p.growth*100)}
${_vFmtRFull(p.fcf)}
${p.discount.toFixed(3)}
${_vFmtRFull(p.pv)}
`).join('')}
Sum of PV (5 Years) ${_vFmtRFull(dcf.projections.reduce((s,p)=>s+p.pv,0))}
Terminal Value (Gordon Growth, g=${cfg.terminalGrowth}%) ${_vFmtRFull(dcf.terminalValue)}
Terminal Value (PV) ${_vFmtRFull(dcf.terminalPV)}
ENTERPRISE VALUE ${_vFmtRFull(dcf.enterpriseValue)}
(−) Net Debt ${_vFmtRFull(dcf.netDebt)}
EQUITY VALUE (DCF) ${_vFmtRFull(dcf.equityValue)}
DCF Assumptions
Growth Y1 → Y2 ${cfg.growthY1Y2}% Discount Rate (WACC) ${cfg.discountRate}%
Growth Taper Y5 ${cfg.growthTaperY5}% Terminal Growth ${cfg.terminalGrowth}%
Corporate Tax Rate ${cfg.taxRate}% Periode Proyeksi 5 Tahun + Terminal
Discount rate (WACC) merefleksikan biaya modal & risiko perusahaan. SME Indonesia: 12-18%. Terminal growth (3-5%) selaras dengan GDP growth jangka panjang.
Hal. 4
${npmName.replace(/^PT\.\s*/,'').toUpperCase()}
Valuation Report · Per ${today}
KEY RATIOS & CAP TABLE
Kesehatan Finansial & Struktur Kepemilikan
Rasio Keuangan Kunci
${[
['ROE (Return on Equity)', _vFmtPct(ratios.ROE), ratios.ROE>=0.15?'green':ratios.ROE>=0.05?'amber':'red'],
['ROA (Return on Assets)', _vFmtPct(ratios.ROA), ratios.ROA>=0.08?'green':ratios.ROA>=0.03?'amber':'red'],
['Debt-to-Equity (DER)', ratios.DER.toFixed(2)+'×', ratios.DER<=1?'green':ratios.DER<=2?'amber':'red'],
['Current Ratio', ratios.currentRatio.toFixed(2)+'×', ratios.currentRatio>=1.5?'green':ratios.currentRatio>=1?'amber':'red'],
['Asset Turnover', ratios.assetTurnover.toFixed(2)+'×', ratios.assetTurnover>=1?'green':ratios.assetTurnover>=0.5?'amber':'red'],
['Gross Margin', _vFmtPct(ratios.grossMargin), ratios.grossMargin>=0.25?'green':ratios.grossMargin>=0.15?'amber':'red'],
['EBITDA Margin', _vFmtPct(ratios.ebitdaMargin), ratios.ebitdaMargin>=0.15?'green':ratios.ebitdaMargin>=0.08?'amber':'red'],
['Net Margin', _vFmtPct(ratios.netMargin), ratios.netMargin>=0.1?'green':ratios.netMargin>=0.04?'amber':'red'],
].map(([lb,v,c])=>`
`).join('')}
Cap Table — Struktur Pemegang Saham
${capTable.length===0 ? `
Cap table belum di-setup. Silakan input data pemegang saham via menu Valuasi.
` : `
# Pemegang Saham Lembar % Nilai
${capTable.map((p,i)=>`
${i+1}
${(p.lembar||0).toLocaleString('id-ID')}
${_vFmtPct(p.pct)}
${_vFmtRFull(p.nilai)}
`).join('')}
TOTAL ${totalLembar.toLocaleString('id-ID')} 100% ${_vFmtRFull(capTable.reduce((s,p)=>s+(p.nilai||0),0))}
Nilai per lembar dihitung dari Fair Value ÷ Total Lembar Beredar = ${_vFmtRFull(fair.value)} ÷ ${totalLembar.toLocaleString('id-ID')} = ${_vFmtRFull(nilaiPerLembar)}/lembar
`}
Hal. 5
${npmName.replace(/^PT\.\s*/,'').toUpperCase()}
Valuation Report · Per ${today}
KESIMPULAN & CATATAN PENTING
Summary & Disclosure
Kesimpulan Valuasi
Berdasarkan analisis tiga metode valuasi standar (Net Asset Value, Earnings Multiple, dan Discounted Cash Flow),
${npmName} diestimasi memiliki Fair Value sebesar ${_vFmtRFull(fair.value)} per ${today},
dengan range valuasi ${_vFmtRFull(fair.range.low)} hingga ${_vFmtRFull(fair.range.high)} tergantung skenario yang digunakan.
${totalLembar>0?`
Dengan total ${totalLembar.toLocaleString('id-ID')} lembar saham beredar sesuai akta perusahaan,
nilai per lembar saham diestimasi sebesar ${_vFmtRFull(nilaiPerLembar)} .
`:''}
Valuasi ini didasarkan pada data finansial ${snap.bulan} bulan operasi yang di-annualisasi menjadi proyeksi setahun penuh,
menggunakan benchmark industri kemasan plastik Indonesia (TRST, IGAR, AKPI) yang dilisting di IDX,
dengan penyesuaian Discount for Lack of Marketability (DLOM) untuk perusahaan privat.
Faktor Pendukung & Risiko
Faktor Pendukung Risiko & Catatan
• Margin EBITDA ${_vFmtPct(snap.marginEbitda)} ${snap.marginEbitda>=0.15?'(unggul)':snap.marginEbitda>=0.08?'(sehat)':'(perlu perbaikan)'}
• DER ${ratios.DER.toFixed(2)}× ${ratios.DER<=1?'(leverage konservatif)':ratios.DER<=2?'(leverage moderat)':'(leverage tinggi)'}
• ROE ${_vFmtPct(ratios.ROE)} ${ratios.ROE>=0.15?'(sangat baik)':ratios.ROE>=0.05?'(wajar)':'(perlu perbaikan)'}
• Asset Turnover ${ratios.assetTurnover.toFixed(2)}× ${ratios.assetTurnover>=1?'(produktif)':'(perlu optimasi)'}
• Data ${snap.bulan} bulan — proyeksi tahun penuh bersifat estimatif
• Fluktuasi harga bahan baku (HDPE, OPP) belum di-faktor in
• DCF sangat sensitif terhadap WACC & terminal growth
• Tidak termasuk valuasi intangibles (brand, customer base, IP)
Metodologi
1. Net Asset Value (NAV): Total Aset minus Total Kewajiban dari Neraca per tanggal penilaian. Bobot: ${cfg.bobotNAV}% .
2. Earnings Multiple: Rata-rata dari (a) Enterprise Value = EBITDA Annualized × Multiple, dikurangi Net Debt; dan (b) Equity Value = Net Income Annualized × PER. Menggunakan skenario moderat. Bobot: ${cfg.bobotMultiple}% .
3. Discounted Cash Flow (DCF): Proyeksi Free Cash Flow 5 tahun (growth taper dari Y1 ${cfg.growthY1Y2}% ke Y5 ${cfg.growthTaperY5}%) + Terminal Value (Gordon Growth, g=${cfg.terminalGrowth}%), didiskonto dengan WACC ${cfg.discountRate}%. Bobot: ${cfg.bobotDCF}% .
DISCLAIMER PENTING:
Laporan valuasi ini disusun secara internal oleh manajemen ${npmName} untuk keperluan perencanaan strategis & monitoring kinerja perusahaan.
Laporan ini BUKAN opini valuasi formal dari Kantor Jasa Penilai Publik (KJPP) bersertifikat OJK dan TIDAK dapat digunakan untuk:
(i) penawaran umum perdana (IPO),
(ii) transaksi M&A formal,
(iii) jaminan kredit,
(iv) sengketa hukum,
(v) keperluan perpajakan formal,
atau (vi) laporan audit.
Untuk keperluan resmi tersebut, gunakan jasa Penilai Publik bersertifikat MAPPI/KJPP yang akan melakukan due diligence menyeluruh dan menerbitkan Laporan Penilaian sesuai SPI (Standar Penilaian Indonesia).
Manajemen ${npmName} tidak bertanggung jawab atas penggunaan laporan ini di luar konteks internal yang disebutkan.
Hal. 6
`;
// Multi-page render: use manual per-page canvas loop (not _pdfRenderHD which is for single body)
const iframe = document.createElement('iframe');
iframe.style.cssText = 'position:fixed;left:-9999px;top:0;width:210mm;height:600mm;border:none;opacity:0;pointer-events:none';
document.body.appendChild(iframe);
iframe.contentDocument.open();
iframe.contentDocument.write(html);
iframe.contentDocument.close();
await new Promise(r=>setTimeout(r,1000));
const pages = iframe.contentDocument.querySelectorAll('.page');
const {jsPDF} = window.jspdf;
const pdf = new jsPDF({orientation:'portrait', unit:'mm', format:'a4', compress:true});
for(let i=0; i0) pdf.addPage();
pdf.addImage(imgData, 'JPEG', 0, 0, 210, 297);
}
document.body.removeChild(iframe);
pdf.save(`Valuation-Report-${npmName.replace(/\W/g,'_')}-${today.replace(/\s/g,'-')}.pdf`);
if(typeof showToast==='function') showToast('✅ Valuation Report PDF tersimpan','green');
}catch(err){
console.error('lapValuasiPerusahaan:', err);
alert('Error generate PDF: '+(err.message||err));
}
};
// ────────────────────────────────────────────────────────────────────────
// 6. REGISTER ke SCREENS, MODUL_LIST, DASH owner/admin
// ────────────────────────────────────────────────────────────────────────
function valRegister(){
try{
// Register screen
if(typeof SCREENS==='object' && SCREENS){
SCREENS.valuasi = window.valuasiScreen;
}
// Register di MODUL_LIST (kalau belum ada)
if(typeof MODUL_LIST!=='undefined' && Array.isArray(MODUL_LIST)){
if(!MODUL_LIST.find(m=>m.key==='valuasi')){
MODUL_LIST.push({key:'valuasi', label:'Valuasi Perusahaan', grup:'Keuangan & Pajak'});
}
}
// Register di MODUL_DEFAULT.owner (kalau belum ada)
if(typeof MODUL_DEFAULT!=='undefined' && MODUL_DEFAULT && Array.isArray(MODUL_DEFAULT.owner)){
if(!MODUL_DEFAULT.owner.includes('valuasi')){
MODUL_DEFAULT.owner.push('valuasi');
}
}
// Register tile di DASH.owner & DASH.admin (di section Keuangan, sebelum laporanpdf)
if(typeof DASH!=='undefined' && DASH){
['owner','admin'].forEach(role=>{
const arr = DASH[role];
if(!Array.isArray(arr)) return;
// Cek apakah sudah ada
if(arr.some(it=>Array.isArray(it) && it[0]==='valuasi')) return;
// Cari index laporanpdf
const lpIdx = arr.findIndex(it=>Array.isArray(it) && it[0]==='laporanpdf');
const tile = ['valuasi','ti-presentation-analytics','Valuasi','tc-purple'];
if(lpIdx>=0){
arr.splice(lpIdx, 0, tile);
} else {
arr.push(tile);
}
});
}
// Register topbar caption (kalau ada SCREEN_META atau sejenis)
try{
if(typeof TAB_CAPTIONS!=='undefined' && TAB_CAPTIONS){
TAB_CAPTIONS.valuasi = {t:'Valuasi Perusahaan', s:'Estimasi nilai perusahaan & cap table'};
}
}catch(e){}
console.log('[Valuasi Module] registered v1.10.85');
}catch(e){
console.warn('valRegister error:', e);
}
}
// Run registration sekarang & juga saat DOMContentLoaded (safety net)
valRegister();
if(document.readyState==='loading'){
document.addEventListener('DOMContentLoaded', valRegister);
}
})();