Pinnacle Personal Assistant

Command Centre

Your single control surface for clients, finance, diary, demos and provisioning.

Pinnacle
Pinnacle Command Centre
T
Tiaan
Today

Good day, Tiaan


Connecting…
0
👥
Clients
CRM & client records
0
🎯
Leads
Pipeline & prospects
💳
Finance
Invoices & bookkeeping
📅
Diary
Calendar & schedule
📄
Documents
Files & storage
0
🚀
Onboarding
New client setup
⚙️
Provisioning
Client services
🌐
Website
Site & marketing
📊
Reports
Analytics & insights
🔧
Settings
Preferences & config
🏎️
Dealer Demo
Dashboard preview
Active Clients
 
Monthly Revenue
 
Open Leads
 
Overdue Invoices
 
Scheduled
Today
Click a day to view scheduled events
Notes

Action Required

Pipeline

Recent Activity

(leads || []).forEach(l => { items.push(`
New lead — ${escapeHtml(l.business_name || 'Unknown')}
${l.source || 'manual'} · ${fmtAgo(l.created_at)}
`); }); (demos || []).forEach(d => { items.push(`
Demo request — ${escapeHtml(d.business || d.name || 'Prospect')}
${d.preferred_date || 'No date set'}${d.deposit_paid ? ' · Deposit paid ✓' : ''}
`); }); (overdueInv || []).forEach(inv => { items.push(`
!
Overdue — ${escapeHtml(inv.invoice_number || '?')}
${ZAR(inv.amount_incl_vat)} · Due ${inv.due_date || '—'}
`); }); list.innerHTML = items.length ? items.join('') : '
All caught up — nothing requires action right now.
'; } async function loadPipeline() { const { data: clients } = await SB.from('master_clients').select('id, business_name, pipeline_stage'); const STAGES = ['Lead','Quoted','Signed','Onboarding','Active','Maintenance','At-risk','Closed']; const grouped = {}; STAGES.forEach(s => { grouped[s] = 0; }); (clients || []).forEach(c => { const s = c.pipeline_stage || 'Lead'; if (grouped[s] !== undefined) grouped[s]++; else grouped['Lead']++; }); const total = Object.values(grouped).reduce((a, b) => a + b, 0) || 1; const el = $('pipeline'); const active = STAGES.filter(s => grouped[s] > 0); if (!active.length) { el.innerHTML = '
No clients yet.
'; return; } const RISK = ['At-risk']; el.innerHTML = active.map(stage => { const count = grouped[stage]; const pct = Math.min(100, Math.round((count / total) * 100)); const isRisk = RISK.includes(stage); return `
${stage} ${count}
`; }).join(''); } async function loadActivity() { const { data } = await SB.from('activity_feed').select('*').order('created_at', { ascending: false }).limit(20); const feed = $('feed'); const items = data || []; if (!items.length) { feed.innerHTML = '
No activity yet — actions will appear here.
'; return; } feed.innerHTML = items.map(item => `
${item.description || ''}
${fmtAgo(item.created_at)}
`).join(''); } /* ============ PROVISIONING MODULE ============ */ let provCache = []; document.querySelector('.tab[data-tab="provisioning"]').addEventListener('click', () => { setTimeout(loadProvisioning, 50); }); // Prov tab switching (scoped to provisioning page only) document.querySelectorAll('#prov-tabs .fin-tab').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('#prov-tabs .fin-tab').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const tab = btn.dataset.provTab; $('prov-content-pending').style.display = tab === 'pending' ? 'block' : 'none'; $('prov-content-done').style.display = tab === 'done' ? 'block' : 'none'; }); }); async function loadProvisioning() { const { data, error } = await SB.from('master_clients') .select('*') .eq('pipeline_stage', 'Active') .order('business_name'); if (error) { $('prov-pending-tbody').innerHTML = `
Error: ${error.message}
`; return; } provCache = data || []; const pending = provCache.filter(c => !c.provisioned); const done = provCache.filter(c => c.provisioned); $('prov-summary').textContent = `${pending.length} ${pending.length === 1 ? 'client needs' : 'clients need'} provisioning · ${done.length} already provisioned`; $('prov-pending-tbody').innerHTML = !pending.length ? `
All active clients are provisioned — nothing to do.
` : pending.map(c => { const pkgs = (c.packages || []); return `
${escapeHtml(c.business_name || '—')}
${escapeHtml(c.contact_name || '')}${c.contact_email ? ' · ' + escapeHtml(c.contact_email) : ''}
${pkgs.map(p => `${PKG_LABELS[p] || p}`).join('') || ''}
${Number(c.monthly_value) ? 'R ' + Number(c.monthly_value).toLocaleString('en-ZA') : '—'} ${c.contract_end || '—'} `; }).join(''); $('clients-tbody').querySelectorAll('tr[data-client-id]').forEach(tr => { tr.addEventListener('click', () => openClientModal(clientsCache.find(c => c.id === tr.dataset.clientId))); }); } document.querySelector('.tab[data-tab="clients"]').addEventListener('click', () => { setTimeout(loadClients, 50); }); $('clients-search').addEventListener('input', e => { clientsFilter.search = e.target.value; renderClientsTable(); }); $('clients-stage-filter').addEventListener('change', e => { clientsFilter.stage = e.target.value; renderClientsTable(); }); /* ===== Client Modal ===== */ const clientModal = $('client-modal'); const clientForm = $('client-form'); let editingClient = null; function openClientModal(record) { editingClient = record || null; $('cf-id').value = record ? record.id : ''; $('cf-business').value = record ? (record.business_name || '') : ''; $('cf-contact').value = record ? (record.contact_name || '') : ''; $('cf-email').value = record ? (record.contact_email || '') : ''; $('cf-phone').value = record ? (record.contact_phone || '') : ''; $('cf-stage').value = record ? (record.pipeline_stage || 'Lead') : 'Lead'; $('cf-monthly').value = record ? (record.monthly_value || 0) : 0; const pkgs = record ? (record.packages || []) : []; document.querySelectorAll('input[name="pkg"]').forEach(cb => { cb.checked = pkgs.includes(cb.value); cb.parentElement.classList.toggle('checked', cb.checked); }); $('cf-contract-start').value = record ? (record.contract_start || '') : ''; $('cf-contract-end').value = record ? (record.contract_end || '') : ''; $('cf-setup-paid').checked = record ? !!record.setup_fee_paid : false; const branding = record && record.branding ? record.branding : {}; $('cf-reg-no').value = branding.registration_number || ''; $('cf-vat').value = branding.vat_number || ''; $('cf-color').value = branding.primary_color || ''; $('cf-logo-url').value = branding.logo_url || ''; $('cf-banking').value = branding.banking || ''; $('cf-notes').value = record ? (record.notes || '') : ''; $('client-modal-title').innerHTML = record ? 'Edit Client' : 'New Client'; $('cf-delete').style.display = record ? 'inline-flex' : 'none'; clientModal.classList.add('show'); setTimeout(() => $('cf-business').focus(), 50); } function closeClientModal() { clientModal.classList.remove('show'); editingClient = null; } document.querySelectorAll('input[name="pkg"]').forEach(cb => { cb.addEventListener('change', () => cb.parentElement.classList.toggle('checked', cb.checked)); }); $('client-modal-close').addEventListener('click', closeClientModal); $('cf-cancel').addEventListener('click', closeClientModal); clientModal.addEventListener('click', e => { if (e.target === clientModal) closeClientModal(); }); clientForm.addEventListener('submit', async e => { e.preventDefault(); $('cf-save').disabled = true; $('cf-save').textContent = 'Saving…'; const pkgs = Array.from(document.querySelectorAll('input[name="pkg"]:checked')).map(cb => cb.value); const branding = { registration_number: $('cf-reg-no').value.trim() || null, vat_number: $('cf-vat').value.trim() || null, primary_color: $('cf-color').value.trim() || null, logo_url: $('cf-logo-url').value.trim() || null, banking: $('cf-banking').value.trim() || null }; const payload = { business_name: $('cf-business').value.trim(), contact_name: $('cf-contact').value.trim() || null, contact_email: $('cf-email').value.trim() || null, contact_phone: $('cf-phone').value.trim() || null, pipeline_stage: $('cf-stage').value, monthly_value: Number($('cf-monthly').value) || 0, packages: pkgs, contract_start: $('cf-contract-start').value || null, contract_end: $('cf-contract-end').value || null, setup_fee_paid: $('cf-setup-paid').checked, branding, notes: $('cf-notes').value.trim() || null, updated_at: new Date().toISOString() }; let err; const id = $('cf-id').value; if (id) { ({ error: err } = await SB.from('master_clients').update(payload).eq('id', id)); } else { const { error: insErr, data: ins } = await SB.from('master_clients').insert(payload).select().single(); err = insErr; if (!err && ins) { await SB.from('activity_feed').insert({ type: 'client', description: `New client added — ${escapeHtml(payload.business_name)}`, related_id: ins.id, related_table: 'master_clients' }); } } $('cf-save').disabled = false; $('cf-save').textContent = 'Save'; if (err) { alert('Save failed: ' + err.message); return; } closeClientModal(); loadClients(); if (typeof loadKPIs === 'function') loadKPIs(); }); $('cf-delete').addEventListener('click', async () => { const id = $('cf-id').value; if (!id) return; if (!confirm('Delete this client? This cannot be undone.')) return; const { error } = await SB.from('master_clients').delete().eq('id', id); if (error) { alert('Delete failed: ' + error.message); return; } closeClientModal(); loadClients(); loadKPIs(); }); // + New Client buttons document.querySelectorAll('[data-action="new-client"]').forEach(btn => { btn.addEventListener('click', () => openClientModal(null)); }); /* ============ LEADS PIPELINE ============ */ let leadsCache = []; let leadsFilter = { search: '', status: '', source: '' }; async function loadLeads() { const { data, error } = await SB.from('leads').select('*').order('created_at', { ascending: false }); if (error) { $('leads-tbody').innerHTML = `
Error: ${error.message}
`; return; } leadsCache = data || []; renderLeadsTable(); } function renderLeadsTable() { const f = leadsFilter; const filtered = leadsCache.filter(l => { const s = !f.search || (l.business_name || '').toLowerCase().includes(f.search.toLowerCase()) || (l.contact_name || '').toLowerCase().includes(f.search.toLowerCase()); const st = !f.status || l.status === f.status; const sr = !f.source || l.source === f.source; return s && st && sr; }); $('leads-count').textContent = `${filtered.length} ${filtered.length === 1 ? 'lead' : 'leads'}`; const open = leadsCache.filter(l => !['converted','lost'].includes(l.status)).length; const newThisWeek = leadsCache.filter(l => l.status === 'new' && (Date.now() - new Date(l.created_at).getTime())/86400000 <= 7).length; $('leads-summary').textContent = `${open} open · ${newThisWeek} new this week`; if (!filtered.length) { $('leads-tbody').innerHTML = `
${leadsCache.length ? 'No leads match your filter.' : 'No leads yet — click + New Lead to add one.'}
`; return; } const STATUS_BADGE = { 'new': 'New', 'contacted': 'Contacted', 'quoted': 'Quoted', 'converted': 'Converted', 'lost': 'Lost' }; $('leads-tbody').innerHTML = filtered.map(l => `
${escapeHtml(l.business_name || '—')}
${escapeHtml(l.contact_name || '')}${l.contact_email ? ' · ' + escapeHtml(l.contact_email) : ''}
${escapeHtml((l.source || 'manual').toUpperCase())} ${STATUS_BADGE[l.status] || '' + (l.status || 'new') + ''} ${escapeHtml((l.message || '').slice(0, 80))} ${fmtAgo(l.created_at)} `).join(''); $('leads-tbody').querySelectorAll('tr[data-lead-id]').forEach(tr => { tr.addEventListener('click', () => openLeadModal(leadsCache.find(l => l.id === tr.dataset.leadId))); }); } document.querySelector('.tab[data-tab="leads"]').addEventListener('click', () => setTimeout(loadLeads, 50)); $('leads-search').addEventListener('input', e => { leadsFilter.search = e.target.value; renderLeadsTable(); }); $('leads-status-filter').addEventListener('change', e => { leadsFilter.status = e.target.value; renderLeadsTable(); }); $('leads-source-filter').addEventListener('change', e => { leadsFilter.source = e.target.value; renderLeadsTable(); }); /* ===== Lead Modal ===== */ const leadModal = $('lead-modal'); let editingLead = null; function openLeadModal(record) { editingLead = record || null; $('lf-id').value = record ? record.id : ''; $('lf-business').value = record ? (record.business_name || '') : ''; $('lf-contact').value = record ? (record.contact_name || '') : ''; $('lf-email').value = record ? (record.contact_email || '') : ''; $('lf-phone').value = record ? (record.contact_phone || '') : ''; $('lf-source').value = record ? (record.source || 'manual') : 'manual'; $('lf-status').value = record ? (record.status || 'new') : 'new'; $('lf-message').value = record ? (record.message || '') : ''; $('lead-modal-title').innerHTML = record ? 'Edit Lead' : 'New Lead'; $('lf-delete').style.display = record ? 'inline-flex' : 'none'; leadModal.classList.add('show'); setTimeout(() => $('lf-business').focus(), 50); } function closeLeadModal() { leadModal.classList.remove('show'); editingLead = null; } $('lead-modal-close').addEventListener('click', closeLeadModal); $('lf-cancel').addEventListener('click', closeLeadModal); leadModal.addEventListener('click', e => { if (e.target === leadModal) closeLeadModal(); }); $('lead-form').addEventListener('submit', async e => { e.preventDefault(); $('lf-save').disabled = true; $('lf-save').textContent = 'Saving…'; const payload = { business_name: $('lf-business').value.trim(), contact_name: $('lf-contact').value.trim() || null, contact_email: $('lf-email').value.trim() || null, contact_phone: $('lf-phone').value.trim() || null, source: $('lf-source').value, status: $('lf-status').value, message: $('lf-message').value.trim() || null }; let err; const id = $('lf-id').value; if (id) { ({ error: err } = await SB.from('leads').update(payload).eq('id', id)); } else { const { error: insErr, data: ins } = await SB.from('leads').insert(payload).select().single(); err = insErr; if (!err && ins) { await SB.from('activity_feed').insert({ type: 'lead', description: `New lead — ${escapeHtml(payload.business_name)}`, related_id: ins.id, related_table: 'leads' }); } } $('lf-save').disabled = false; $('lf-save').textContent = 'Save'; if (err) { alert('Save failed: ' + err.message); return; } closeLeadModal(); loadLeads(); loadKPIs(); }); $('lf-delete').addEventListener('click', async () => { const id = $('lf-id').value; if (!id) return; if (!confirm('Delete this lead?')) return; const { error } = await SB.from('leads').delete().eq('id', id); if (error) { alert('Delete failed: ' + error.message); return; } closeLeadModal(); loadLeads(); loadKPIs(); }); // Schedule follow-up → creates a diary entry tomorrow at 09:00 $('lf-act-diary').addEventListener('click', async () => { if (!editingLead) { alert('Save the lead first.'); return; } const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); const { error } = await SB.from('diary_entries').insert({ entry_date: ymd(tomorrow), start_time: '09:00', end_time: '09:30', title: `Follow up — ${editingLead.business_name}`, description: `Lead follow-up.\n${editingLead.contact_email ? 'Email: ' + editingLead.contact_email + '\n' : ''}${editingLead.contact_phone ? 'Phone: ' + editingLead.contact_phone + '\n' : ''}${editingLead.message ? '\nMessage: ' + editingLead.message : ''}`, category: 'client' }); if (error) { alert('Diary save failed: ' + error.message); return; } await SB.from('leads').update({ status: 'contacted' }).eq('id', editingLead.id); alert('Follow-up scheduled for tomorrow at 09:00 — see Diary tab. Lead status moved to "Contacted".'); closeLeadModal(); loadLeads(); renderDiary('home'); renderDiary('diary'); }); // Convert to Client → creates master_clients row, marks lead converted $('lf-act-convert').addEventListener('click', async () => { if (!editingLead) { alert('Save the lead first.'); return; } if (!confirm('Convert this lead to a client?')) return; const { data: client, error: cErr } = await SB.from('master_clients').insert({ business_name: editingLead.business_name, contact_name: editingLead.contact_name, contact_email: editingLead.contact_email, contact_phone: editingLead.contact_phone, pipeline_stage: 'Signed', notes: editingLead.message ? `Converted from lead.\nOriginal message: ${editingLead.message}` : 'Converted from lead.' }).select().single(); if (cErr) { alert('Convert failed: ' + cErr.message); return; } await SB.from('leads').update({ status: 'converted', converted_to_client_id: client.id, closed_at: new Date().toISOString() }).eq('id', editingLead.id); await SB.from('activity_feed').insert({ type: 'client', description: `Lead converted to client — ${escapeHtml(client.business_name)}`, related_id: client.id, related_table: 'master_clients' }); alert(`Converted! "${client.business_name}" is now in your CRM with pipeline stage "Signed". Open the Clients tab to add packages, branding, and contract details.`); closeLeadModal(); loadLeads(); loadClients(); loadKPIs(); }); // + New Lead buttons document.querySelectorAll('[data-action="new-lead"]').forEach(btn => { btn.addEventListener('click', () => openLeadModal(null)); }); /* ============ WEBSITE LEADS INBOX ============ */ async function loadWebsiteLeads() { const { data } = await SB .from('website_leads') .select('*') .is('converted_to_lead_id', null) .order('created_at', { ascending: false }); const tbody = $('wl-tbody'); const rows = data || []; $('wl-count').textContent = rows.length + ' unprocessed'; tbody.innerHTML = ''; if (!rows.length) { const tr = document.createElement('tr'); const td = document.createElement('td'); td.colSpan = 5; td.className = 'empty'; const msg = document.createElement('div'); msg.className = 'msg'; msg.textContent = 'No unprocessed website leads — all caught up.'; td.appendChild(msg); tr.appendChild(td); tbody.appendChild(tr); return; } rows.forEach(w => { const tr = document.createElement('tr'); // Name / Business const tdName = document.createElement('td'); const biz = document.createElement('div'); biz.className = 'biz'; biz.textContent = w.name || '—'; const bizMeta = document.createElement('div'); bizMeta.className = 'biz-meta'; bizMeta.textContent = w.business || ''; tdName.appendChild(biz); tdName.appendChild(bizMeta); // Contact const tdContact = document.createElement('td'); tdContact.style.cssText = 'font-size:12px;color:var(--ink-soft)'; tdContact.textContent = (w.email || '—') + (w.phone ? ' · ' + w.phone : ''); // Message snippet const tdMsg = document.createElement('td'); tdMsg.style.cssText = 'font-size:12px;color:var(--ink-soft);max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'; tdMsg.textContent = (w.message || '').slice(0, 90); // Received const tdAge = document.createElement('td'); tdAge.style.cssText = 'font-size:11px;color:var(--ink-mute)'; tdAge.textContent = fmtAgo(w.created_at); // Action button const tdAct = document.createElement('td'); const btn = document.createElement('button'); btn.className = 'btn btn-ghost'; btn.style.cssText = 'padding:5px 12px;font-size:10.5px'; btn.textContent = '+ Add to Leads'; btn.addEventListener('click', async () => { btn.disabled = true; btn.textContent = 'Adding…'; const payload = { business_name: w.business || w.name || 'Website Enquiry', contact_name: w.name || null, contact_email: w.email || null, contact_phone: w.phone || null, source: 'website', status: 'new', message: w.message || null }; const { data: lead, error: lErr } = await SB.from('leads').insert(payload).select().single(); if (lErr) { alert('Failed: ' + lErr.message); btn.disabled = false; btn.textContent = '+ Add to Leads'; return; } await SB.from('website_leads').update({ converted_to_lead_id: lead.id, read_at: new Date().toISOString() }).eq('id', w.id); await SB.from('activity_feed').insert({ type: 'lead', description: 'Website lead imported — ' + escapeHtml(payload.business_name) + '', related_id: lead.id, related_table: 'leads' }); loadWebsiteLeads(); loadLeads(); loadKPIs(); }); tdAct.appendChild(btn); tr.appendChild(tdName); tr.appendChild(tdContact); tr.appendChild(tdMsg); tr.appendChild(tdAge); tr.appendChild(tdAct); tbody.appendChild(tr); }); } // Load website inbox whenever the Leads tab opens (supplements existing handler) document.querySelector('.tab[data-tab="leads"]').addEventListener('click', () => { setTimeout(loadWebsiteLeads, 80); }, true); /* ============ SETTINGS ============ */ let settingsCache = null; async function loadSettings() { const { data, error } = await SB.from('settings').select('*').eq('id', 1).maybeSingle(); if (error) { console.error('settings load', error); return; } settingsCache = data || {}; $('set-business-name').value = data?.business_name || 'Pinnacle PA'; $('set-email').value = data?.contact_email || 'info@pinnaclepa.biz'; $('set-phone').value = data?.contact_phone || '076 919 0182'; const banking = data?.banking || {}; $('set-bank-name').value = banking.bank_name || ''; $('set-bank-holder').value = banking.account_holder || ''; $('set-bank-acc').value = banking.account_number || ''; $('set-bank-branch').value = banking.branch_code || ''; $('set-bank-type').value = banking.account_type || 'cheque'; $('set-vat-reg').checked = !!data?.vat_registered; $('set-vat-no').value = data?.vat_number || ''; $('set-hours-start').value = (data?.working_hours_start || '08:00:00').slice(0,5); $('set-hours-end').value = (data?.working_hours_end || '17:00:00').slice(0,5); // Demo URL is stored inside banking jsonb under a misc key (no schema change needed) const misc = (data?.banking && data.banking.__misc) || {}; $('set-demo-url').value = misc.demo_url || ''; } document.querySelector('.tab[data-tab="settings"]').addEventListener('click', () => setTimeout(loadSettings, 50)); document.querySelectorAll('.settings-nav button[data-set-pane]').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.settings-nav button').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const pane = btn.dataset.setPane; document.querySelectorAll('[data-set-content]').forEach(d => d.style.display = d.dataset.setContent === pane ? 'block' : 'none'); }); }); $('settings-form').addEventListener('submit', async e => { e.preventDefault(); $('set-save').disabled = true; $('set-save').textContent = 'Saving…'; const banking = { bank_name: $('set-bank-name').value.trim() || null, account_holder: $('set-bank-holder').value.trim() || null, account_number: $('set-bank-acc').value.trim() || null, branch_code: $('set-bank-branch').value.trim() || null, account_type: $('set-bank-type').value, __misc: { demo_url: $('set-demo-url').value.trim() || null } }; const payload = { id: 1, business_name: $('set-business-name').value.trim() || 'Pinnacle PA', contact_email: $('set-email').value.trim() || null, contact_phone: $('set-phone').value.trim() || null, banking, vat_registered: $('set-vat-reg').checked, vat_number: $('set-vat-no').value.trim() || null, working_hours_start: $('set-hours-start').value || '08:00', working_hours_end: $('set-hours-end').value || '17:00', updated_at: new Date().toISOString() }; const { error } = await SB.from('settings').upsert(payload); $('set-save').disabled = false; $('set-save').textContent = 'Save Changes'; if (error) { $('set-status').textContent = 'Error: ' + error.message; return; } $('set-status').textContent = 'Saved · ' + new Date().toLocaleTimeString('en-ZA',{hour:'2-digit',minute:'2-digit'}); settingsCache = payload; setTimeout(() => $('set-status').textContent = '', 4000); }); /* ============ ONBOARDING ============ */ async function loadOnboarding() { const { data: clients } = await SB.from('master_clients').select('*').eq('pipeline_stage', 'Onboarding').order('business_name'); const list = $('onb-list'); if (!clients || !clients.length) { $('onb-summary').textContent = '0 clients in onboarding'; list.innerHTML = `
Empty

No clients in onboarding


Move a client's pipeline stage to "Onboarding" in the Clients tab to start their checklist.

`; return; } $('onb-summary').textContent = `${clients.length} ${clients.length === 1 ? 'client' : 'clients'} in onboarding`; // Fetch all steps in one call const ids = clients.map(c => c.id); const { data: allSteps } = await SB.from('onboarding_steps').select('*').in('client_id', ids).order('step_order'); const { data: allPacks } = await SB.from('welcome_packs').select('*').in('client_id', ids); const stepsByClient = {}; (allSteps || []).forEach(s => { (stepsByClient[s.client_id] = stepsByClient[s.client_id] || []).push(s); }); const packsByClient = {}; (allPacks || []).forEach(p => { (packsByClient[p.client_id] = packsByClient[p.client_id] || []).push(p); }); list.innerHTML = clients.map(c => { const steps = stepsByClient[c.id] || []; const done = steps.filter(s => s.completed).length; const total = steps.length || 6; const pct = total ? Math.round(done / total * 100) : 0; const packs = packsByClient[c.id] || []; const initialPack = packs.find(p => p.pack_type === 'initial'); const completionPack = packs.find(p => p.pack_type === 'completion'); const stepsHtml = steps.map(s => `
`).join('') || '

No steps yet — move client to Onboarding stage to auto-create the checklist.

'; return `
${escapeHtml(c.business_name)}
${escapeHtml(c.contact_name || '')}${c.contact_email ? ' · ' + escapeHtml(c.contact_email) : ''}${c.contact_phone ? ' · ' + escapeHtml(c.contact_phone) : ''}
${pct}%
${stepsHtml}
`; }).join(''); // Set notes textarea values safely (never via innerHTML) const stepMap = {}; (allSteps || []).forEach(s => { stepMap[s.id] = s; }); list.querySelectorAll('textarea[data-step-notes-id]').forEach(ta => { const s = stepMap[ta.dataset.stepNotesId]; if (s) ta.value = s.notes || ''; let timer; ta.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(async () => { await SB.from('onboarding_steps') .update({ notes: ta.value.trim() || null }) .eq('id', ta.dataset.stepNotesId); }, 700); }); }); // Wire up checkbox toggles list.querySelectorAll('input[type="checkbox"][data-step-id]').forEach(cb => { cb.addEventListener('change', async e => { const id = cb.dataset.stepId; const completed = cb.checked; await SB.from('onboarding_steps').update({ completed, completed_at: completed ? new Date().toISOString() : null }).eq('id', id); loadOnboarding(); }); }); // Wire actions list.querySelectorAll('[data-onb-action]').forEach(btn => { btn.addEventListener('click', async () => { const action = btn.dataset.onbAction; const cid = btn.dataset.onbClientId; if (action === 'welcome-initial' || action === 'welcome-completion') { const pack_type = action === 'welcome-initial' ? 'initial' : 'completion'; const client = clients.find(x => x.id === cid); const subject = pack_type === 'initial' ? `Welcome to Pinnacle PA — ${client.business_name}` : `Your Pinnacle PA system is live — ${client.business_name}`; const body = pack_type === 'initial' ? `Hi ${(client.contact_name || 'there').split(' ')[0]},\n\nWelcome to Pinnacle PA. To set up your account please reply with the following:\n\n • Company registration number\n • VAT number (if registered)\n • Banking details (bank, account number, branch code, type)\n • Logo (attach as PNG/SVG)\n • Brand colour preference (if any)\n • Signatory name + ID\n\nOnce we have the above, your system will be live within 5 working days.\n\nKind regards,\nTiaan\nPinnacle Personal Assistant\ninfo@pinnaclepa.biz · 076 919 0182` : `Hi ${(client.contact_name || 'there').split(' ')[0]},\n\nGreat news — your Pinnacle PA system is fully set up and live for ${client.business_name}.\n\nLogin details and a quick orientation will follow shortly. Please review the system at your convenience and let me know if anything needs adjustment.\n\nThank you for choosing Pinnacle PA.\n\nKind regards,\nTiaan\nPinnacle Personal Assistant\ninfo@pinnaclepa.biz · 076 919 0182`; await SB.from('welcome_packs').upsert({ client_id: cid, pack_type, status: 'sent', sent_at: new Date().toISOString(), delivery_method: 'email_pdf' }, { onConflict: 'id' }); // Open email if (client.contact_email) { window.location.href = `mailto:${encodeURIComponent(client.contact_email)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; } loadOnboarding(); } if (action === 'mark-active') { if (!confirm('Move this client to Active stage? This will close out the onboarding workflow.')) return; await SB.from('master_clients').update({ pipeline_stage: 'Active' }).eq('id', cid); await SB.from('activity_feed').insert({ type: 'client', description: `${escapeHtml(clients.find(x=>x.id===cid).business_name)} moved to Active`, related_id: cid, related_table: 'master_clients' }); loadOnboarding(); loadClients(); loadKPIs(); } }); }); } document.querySelector('.tab[data-tab="onboarding"]').addEventListener('click', () => setTimeout(loadOnboarding, 50)); /* ============ FINANCE ============ */ const SERVICE_LABELS = { WBS: 'Websites', DDS: 'Dealer Dashboard', QIN: 'Quotes + Invoices', PAY: 'Payroll', OCR: 'OCR Capture', BUS: 'Business Suite', CON: 'Consulting', MNT: 'Maintenance' }; async function loadFinance() { const yyyymm = new Date().toISOString().slice(0,7); const [{ data: invoices }, { data: expenses }] = await Promise.all([ SB.from('invoices').select('*, master_clients(business_name)').order('issue_date', { ascending: false }), SB.from('expenses').select('*').order('expense_date', { ascending: false }) ]); // Metrics const monthInvoices = (invoices || []).filter(i => (i.issue_date || '').startsWith(yyyymm)); const incomeMonth = monthInvoices.filter(i => i.status === 'paid').reduce((a,b) => a + Number(b.amount_incl_vat || 0), 0); const outstandingMonth = monthInvoices.filter(i => i.status !== 'paid' && i.status !== 'cancelled').reduce((a,b) => a + Number(b.amount_incl_vat || 0), 0); const monthExpenses = (expenses || []).filter(e => (e.expense_date || '').startsWith(yyyymm)); const expenseMonth = monthExpenses.reduce((a,b) => a + Number(b.amount_total || 0), 0); const profit = incomeMonth - expenseMonth; $('m-income').textContent = ZAR(incomeMonth); $('m-income-sub').textContent = `${monthInvoices.filter(i => i.status === 'paid').length} paid this month`; $('m-expense').textContent = ZAR(expenseMonth); $('m-expense-sub').textContent = `${monthExpenses.length} expenses logged`; $('m-profit').textContent = ZAR(profit); $('m-outstanding').textContent = ZAR(outstandingMonth); $('m-outstanding-sub').textContent = `${monthInvoices.filter(i => i.status !== 'paid' && i.status !== 'cancelled').length} unpaid`; $('finance-summary').textContent = `${(invoices || []).length} invoices total · ${(expenses || []).length} expenses logged`; // Invoice rows const STATUS_BADGE = { 'draft': 'Draft', 'sent': 'Sent', 'paid': 'Paid', 'overdue': 'Overdue', 'cancelled': 'Cancelled' }; $('invoices-tbody').innerHTML = (invoices || []).length ? invoices.map(i => ` ${escapeHtml(i.invoice_number)} ${escapeHtml(i.master_clients?.business_name || '—')} ${i.issue_date || '—'} ${i.due_date || '—'} ${ZAR(i.amount_incl_vat)} ${STATUS_BADGE[i.status] || i.status} `).join('') : `
No invoices yet — click + Invoice to create your first.
`; $('invoices-tbody').querySelectorAll('tr[data-invoice-id]').forEach(tr => { tr.addEventListener('click', () => openInvoiceModal(invoices.find(x => x.id === tr.dataset.invoiceId))); }); // Expense rows $('expenses-tbody').innerHTML = (expenses || []).length ? expenses.map(e => ` ${e.expense_date} ${escapeHtml(e.vendor || '—')} ${escapeHtml((e.category || 'general').toUpperCase())} ${escapeHtml((e.description || '').slice(0,80))} ${ZAR(e.amount_total)} `).join('') : `
No expenses yet — click + Expense.
`; $('expenses-tbody').querySelectorAll('tr[data-expense-id]').forEach(tr => { tr.addEventListener('click', () => openExpenseModal(expenses.find(x => x.id === tr.dataset.expenseId))); }); } document.querySelector('.tab[data-tab="finance"]').addEventListener('click', () => setTimeout(loadFinance, 50)); document.querySelectorAll('.fin-tab').forEach(t => { t.addEventListener('click', () => { document.querySelectorAll('.fin-tab').forEach(x => x.classList.remove('active')); t.classList.add('active'); document.querySelectorAll('[data-fin-content]').forEach(d => d.style.display = d.dataset.finContent === t.dataset.finTab ? 'block' : 'none'); }); }); /* ===== Invoice Modal ===== */ const invoiceModal = $('invoice-modal'); async function openInvoiceModal(record) { // Populate client dropdown const { data: clients } = await SB.from('master_clients').select('id, business_name, client_code, packages').order('business_name'); $('inv-client').innerHTML = '' + (clients || []).map(c => ``).join(''); $('inv-id').value = record ? record.id : ''; $('inv-issue').value = record ? record.issue_date : ymd(new Date()); $('inv-due').value = record ? (record.due_date || '') : ''; $('inv-amount').value = record ? record.amount_excl_vat : ''; $('inv-vat').value = record ? record.vat_amount : 0; $('inv-status').value = record ? record.status : 'sent'; $('inv-notes').value = record ? (record.notes || '') : ''; $('inv-service').value = record && record.service_code ? record.service_code : 'WBS'; $('inv-modal-title').innerHTML = record ? 'Edit Invoice' : 'New Invoice'; $('inv-delete').style.display = record ? 'inline-flex' : 'none'; $('inv-ref').value = record ? record.invoice_number : ''; recalcInvoiceTotals(); if (!record) generateInvoiceRef(); invoiceModal.classList.add('show'); } async function generateInvoiceRef() { const opt = $('inv-client').selectedOptions[0]; if (!opt || !opt.value) { $('inv-ref').value = 'PPA-' + $('inv-service').value + '-???-????'; return; } let code = opt.dataset.code || ''; if (!code) { // Auto-assign: count of existing clients + 1 const { count } = await SB.from('master_clients').select('id', { count: 'exact', head: true }); code = String((count || 0)).padStart(3, '0'); } // Find next invoice sequence for this client + service const { data: existing } = await SB.from('invoices').select('invoice_number').like('invoice_number', `PPA-${$('inv-service').value}-${code}-%`); const seqs = (existing || []).map(i => parseInt((i.invoice_number || '').split('-').pop(), 10)).filter(n => !isNaN(n)); const next = (seqs.length ? Math.max(...seqs) : 0) + 1; $('inv-ref').value = `PPA-${$('inv-service').value}-${code}-${String(next).padStart(4, '0')}`; } function recalcInvoiceTotals() { const amount = Number($('inv-amount').value) || 0; const vatOn = settingsCache && settingsCache.vat_registered; const vat = vatOn ? Math.round(amount * 0.15 * 100) / 100 : 0; $('inv-vat').value = vat.toFixed(2); $('inv-total').value = ZAR(amount + vat); } $('inv-client').addEventListener('change', generateInvoiceRef); $('inv-service').addEventListener('change', generateInvoiceRef); $('inv-amount').addEventListener('input', recalcInvoiceTotals); $('inv-modal-close').addEventListener('click', () => invoiceModal.classList.remove('show')); $('inv-cancel').addEventListener('click', () => invoiceModal.classList.remove('show')); invoiceModal.addEventListener('click', e => { if (e.target === invoiceModal) invoiceModal.classList.remove('show'); }); $('invoice-form').addEventListener('submit', async e => { e.preventDefault(); $('inv-save').disabled = true; $('inv-save').textContent = 'Saving…'; const amount = Number($('inv-amount').value) || 0; const vat = Number($('inv-vat').value) || 0; const payload = { invoice_number: $('inv-ref').value, client_id: $('inv-client').value || null, service_code: $('inv-service').value, issue_date: $('inv-issue').value, due_date: $('inv-due').value || null, amount_excl_vat: amount, vat_amount: vat, status: $('inv-status').value, notes: $('inv-notes').value.trim() || null }; let err; const id = $('inv-id').value; if (id) ({ error: err } = await SB.from('invoices').update(payload).eq('id', id)); else ({ error: err } = await SB.from('invoices').insert(payload)); $('inv-save').disabled = false; $('inv-save').textContent = 'Save'; if (err) { alert('Save failed: ' + err.message); return; } invoiceModal.classList.remove('show'); loadFinance(); loadKPIs(); loadActivity(); }); $('inv-delete').addEventListener('click', async () => { const id = $('inv-id').value; if (!id) return; if (!confirm('Delete this invoice?')) return; const { error } = await SB.from('invoices').delete().eq('id', id); if (error) { alert('Delete failed: ' + error.message); return; } invoiceModal.classList.remove('show'); loadFinance(); loadKPIs(); }); $('fin-new-invoice').addEventListener('click', () => openInvoiceModal(null)); /* ===== Expense Modal ===== */ const expenseModal = $('expense-modal'); function openExpenseModal(record) { $('exp-id').value = record ? record.id : ''; $('exp-date').value = record ? record.expense_date : ymd(new Date()); $('exp-category').value = record ? (record.category || 'general') : 'general'; $('exp-vendor').value = record ? (record.vendor || '') : ''; $('exp-reference').value = record ? (record.reference || '') : ''; $('exp-amount').value = record ? record.amount : ''; $('exp-vat').value = record ? record.vat_amount : 0; $('exp-description').value = record ? (record.description || '') : ''; $('exp-modal-title').innerHTML = record ? 'Edit Expense' : 'New Expense'; $('exp-delete').style.display = record ? 'inline-flex' : 'none'; expenseModal.classList.add('show'); } $('exp-modal-close').addEventListener('click', () => expenseModal.classList.remove('show')); $('exp-cancel').addEventListener('click', () => expenseModal.classList.remove('show')); expenseModal.addEventListener('click', e => { if (e.target === expenseModal) expenseModal.classList.remove('show'); }); $('expense-form').addEventListener('submit', async e => { e.preventDefault(); $('exp-save').disabled = true; $('exp-save').textContent = 'Saving…'; const payload = { expense_date: $('exp-date').value, category: $('exp-category').value, vendor: $('exp-vendor').value.trim() || null, reference: $('exp-reference').value.trim() || null, amount: Number($('exp-amount').value) || 0, vat_amount: Number($('exp-vat').value) || 0, description: $('exp-description').value.trim() || null }; let err; const id = $('exp-id').value; if (id) ({ error: err } = await SB.from('expenses').update(payload).eq('id', id)); else ({ error: err } = await SB.from('expenses').insert(payload)); $('exp-save').disabled = false; $('exp-save').textContent = 'Save'; if (err) { alert('Save failed: ' + err.message); return; } expenseModal.classList.remove('show'); loadFinance(); }); $('exp-delete').addEventListener('click', async () => { const id = $('exp-id').value; if (!id) return; if (!confirm('Delete this expense?')) return; const { error } = await SB.from('expenses').delete().eq('id', id); if (error) { alert('Delete failed: ' + error.message); return; } expenseModal.classList.remove('show'); loadFinance(); }); $('fin-new-expense').addEventListener('click', () => openExpenseModal(null)); // Pre-load settings on auth so VAT toggle is known SB.auth.getSession().then(({ data: { session } }) => { if (session) loadSettings(); }); // Also pre-load in dev mode (no auth) loadSettings(); /* ============ DEMO TAB ============ */ function loadDemoTab() { const url = settingsCache && settingsCache.banking && settingsCache.banking.__misc && settingsCache.banking.__misc.demo_url; const frame = $('demo-frame'); const empty = $('demo-empty'); if (url) { empty.style.display = 'none'; frame.src = url + (url.includes('?') ? '&' : '?') + 'pcc_demo_session=' + Date.now(); } else { empty.style.display = 'flex'; frame.src = 'about:blank'; } } document.querySelector('.tab[data-tab="demo"]').addEventListener('click', () => setTimeout(async () => { await loadSettings(); loadDemoTab(); }, 50)); $('demo-refresh').addEventListener('click', () => loadDemoTab()); $('demo-open-new').addEventListener('click', () => { const url = settingsCache && settingsCache.banking && settingsCache.banking.__misc && settingsCache.banking.__misc.demo_url; if (url) window.open(url, '_blank'); }); $('demo-go-settings').addEventListener('click', () => { document.querySelector('.tab[data-tab="settings"]').click(); setTimeout(() => document.querySelector('.settings-nav button[data-set-pane="diary"]').click(), 100); }); /* ============ REPORTS ============ */ function getRange(rangeKey) { const now = new Date(); let from, to = ymd(now); if (rangeKey === 'thismonth') { from = ymd(new Date(now.getFullYear(), now.getMonth(), 1)); } else if (rangeKey === 'lastmonth') { from = ymd(new Date(now.getFullYear(), now.getMonth() - 1, 1)); to = ymd(new Date(now.getFullYear(), now.getMonth(), 0)); } else if (rangeKey === 'ytd') { from = ymd(new Date(now.getFullYear(), 0, 1)); } else { from = '2000-01-01'; } return { from, to }; } async function loadReports() { const range = getRange($('rep-range').value); $('reports-summary').textContent = `Period: ${range.from} → ${range.to}`; const [{ data: invoices }, { data: expenses }, { data: clients }, { data: leads }] = await Promise.all([ SB.from('invoices').select('*, master_clients(business_name)').gte('issue_date', range.from).lte('issue_date', range.to), SB.from('expenses').select('*').gte('expense_date', range.from).lte('expense_date', range.to), SB.from('master_clients').select('*'), SB.from('leads').select('*').gte('created_at', range.from) ]); const incomePaid = (invoices || []).filter(i => i.status === 'paid').reduce((a,b) => a + Number(b.amount_incl_vat || 0), 0); const incomeOutstanding = (invoices || []).filter(i => i.status !== 'paid' && i.status !== 'cancelled').reduce((a,b) => a + Number(b.amount_incl_vat || 0), 0); const totalExpense = (expenses || []).reduce((a,b) => a + Number(b.amount_total || 0), 0); const profit = incomePaid - totalExpense; const margin = incomePaid > 0 ? Math.round(profit / incomePaid * 100) : 0; $('r-income').textContent = ZAR(incomePaid); $('r-income-sub').textContent = `${(invoices || []).length} invoices · ${ZAR(incomeOutstanding)} outstanding`; $('r-expense').textContent = ZAR(totalExpense); $('r-expense-sub').textContent = `${(expenses || []).length} entries`; $('r-profit').textContent = ZAR(profit); $('r-margin-sub').textContent = incomePaid > 0 ? `${margin}% margin` : 'No income yet'; const totalLeads = (leads || []).length; const converted = (leads || []).filter(l => l.status === 'converted').length; const cRate = totalLeads > 0 ? Math.round(converted / totalLeads * 100) : 0; $('r-conversion').textContent = totalLeads > 0 ? `${cRate}%` : '—'; $('r-conversion-sub').textContent = `${converted} of ${totalLeads} leads`; // Revenue by service line const byService = {}; (invoices || []).filter(i => i.status === 'paid').forEach(i => { const sc = i.service_code || 'OTHER'; byService[sc] = (byService[sc] || 0) + Number(i.amount_incl_vat || 0); }); const svcRows = Object.entries(byService).sort((a,b) => b[1] - a[1]); $('r-by-service').innerHTML = svcRows.length ? svcRows.map(([code, amt]) => `
${code} — ${SERVICE_LABELS[code] || code}${ZAR(amt)}
`).join('') : `
No paid invoices in this period.
`; // Top clients const byClient = {}; (invoices || []).filter(i => i.status === 'paid').forEach(i => { const name = i.master_clients?.business_name || 'Unknown'; byClient[name] = (byClient[name] || 0) + Number(i.amount_incl_vat || 0); }); const clientRows = Object.entries(byClient).sort((a,b) => b[1] - a[1]).slice(0, 8); $('r-top-clients').innerHTML = clientRows.length ? clientRows.map(([name, amt]) => `
${escapeHtml(name)}${ZAR(amt)}
`).join('') : `
No revenue yet.
`; // Expenses by category const byCat = {}; (expenses || []).forEach(e => { byCat[e.category || 'general'] = (byCat[e.category || 'general'] || 0) + Number(e.amount_total || 0); }); const catRows = Object.entries(byCat).sort((a,b) => b[1] - a[1]); $('r-by-expense').innerHTML = catRows.length ? catRows.map(([cat, amt]) => `
${escapeHtml(cat.replace(/_/g, ' '))}${ZAR(amt)}
`).join('') : `
No expenses logged.
`; // Pipeline snapshot const stages = ['Lead','Quoted','Signed','Onboarding','Active','Maintenance','At-risk','Closed']; const pipelineCounts = {}; stages.forEach(s => pipelineCounts[s] = 0); (clients || []).forEach(c => { if (pipelineCounts[c.pipeline_stage] !== undefined) pipelineCounts[c.pipeline_stage]++; }); $('r-pipeline').innerHTML = stages.map(s => `
${s}${pipelineCounts[s]}
`).join(''); } document.querySelector('.tab[data-tab="reports"]').addEventListener('click', () => setTimeout(loadReports, 50)); $('rep-range').addEventListener('change', loadReports); /* ============ DOCUMENTS / TEMPLATES ============ */ const DOC_TYPE_LABELS = { invoice: 'Invoice', quote: 'Quote', debit_order: 'Debit Order', welcome_pack: 'Welcome Pack', welcome_letter: 'Welcome Letter (Completion)', service_agreement: 'Service Agreement', service_brief: 'Service Brief / Scope' }; // ---- Pre-loaded quick-send PDF documents ---- const PRESET_DOCS = [ { file: 'docs/Pinnacle_PA_Service_Agreement_v2.pdf', eyebrow: 'Legal', name: 'Service Agreement', desc: 'Standard service agreement covering scope of work, terms and responsibilities.', subject: 'Pinnacle PA – Service Agreement', body: 'Hi,\n\nPlease find attached our Service Agreement for your review and signature.\n\nOnce signed, please return a copy to info@pinnaclepa.biz or WhatsApp it to 076 919 0182.\n\nKind regards,\nTiaan Dames\nPinnacle PA\n076 919 0182 · info@pinnaclepa.biz' }, { file: 'docs/Pinnacle_PA_Debit_Order_Authorisation_v2.pdf', eyebrow: 'Finance', name: 'Debit Order Authorisation', desc: 'Monthly debit order mandate for seamless recurring billing.', subject: 'Pinnacle PA – Debit Order Authorisation', body: 'Hi,\n\nPlease complete and sign the attached Debit Order Authorisation to set up your monthly billing.\n\nReturn to info@pinnaclepa.biz or WhatsApp 076 919 0182.\n\nKind regards,\nTiaan Dames\nPinnacle PA\n076 919 0182 · info@pinnaclepa.biz' }, { file: 'docs/Pinnacle_PA_Quotation_Template_v2.pdf', eyebrow: 'Sales', name: 'Quotation', desc: 'Formal quotation for new client proposals — fill before sending.', subject: 'Pinnacle PA – Your Quotation', body: 'Hi,\n\nPlease find attached your quotation from Pinnacle PA. This quote is valid for 14 days.\n\nFeel free to contact me with any questions.\n\nKind regards,\nTiaan Dames\nPinnacle PA\n076 919 0182 · info@pinnaclepa.biz' }, { file: 'docs/Pinnacle_PA_Website_Signup_Form_v2.pdf', eyebrow: 'Onboarding', name: 'Website Signup Form', desc: 'Client intake form for the website management & invoice service.', subject: 'Pinnacle PA – Website Signup Form', body: 'Hi,\n\nWelcome aboard! Please complete the attached signup form to get your website and invoice services activated.\n\nReturn to info@pinnaclepa.biz or WhatsApp 076 919 0182.\n\nKind regards,\nTiaan Dames\nPinnacle PA\n076 919 0182 · info@pinnaclepa.biz' }, { file: 'docs/Pinnacle_PA_Dealer_Dashboard_Signup_v2.pdf', eyebrow: 'Dealer', name: 'Dealer Dashboard Signup', desc: 'Onboarding form for Pinnacle Dealer Dashboard clients.', subject: 'Pinnacle PA – Dealer Dashboard Signup', body: 'Hi,\n\nExciting news — your Dealer Dashboard is ready to set up! Please complete the attached signup form.\n\nReturn to info@pinnaclepa.biz or WhatsApp 076 919 0182 and we\'ll have you live within 24 hours.\n\nKind regards,\nTiaan Dames\nPinnacle PA\n076 919 0182 · info@pinnaclepa.biz' }, { file: 'docs/Pinnacle_PA_Finance_Application_Form_v1.pdf', eyebrow: 'Finance', name: 'Finance Application Form', desc: 'Client application form for dealership finance products.', subject: 'Pinnacle PA – Finance Application Form', body: 'Hi,\n\nPlease find attached the Finance Application Form. Complete all sections and return with supporting documents.\n\nReturn to info@pinnaclepa.biz or WhatsApp 076 919 0182.\n\nKind regards,\nTiaan Dames\nPinnacle PA\n076 919 0182 · info@pinnaclepa.biz' }, { file: 'docs/Pinnacle_PA_Terms_and_Conditions_v2.pdf', eyebrow: 'Legal', name: 'Terms & Conditions', desc: 'Full terms and conditions governing all Pinnacle PA services.', subject: 'Pinnacle PA – Terms & Conditions', body: 'Hi,\n\nPlease find attached our Terms & Conditions for your records.\n\nKind regards,\nTiaan Dames\nPinnacle PA\n076 919 0182 · info@pinnaclepa.biz' } ]; function renderPresetDocs() { const svgPdf = ``; const svgDl = ``; const svgMail = ``; $('preset-docs-grid').innerHTML = PRESET_DOCS.map(d => { const encodedSubj = encodeURIComponent(d.subject); const encodedBody = encodeURIComponent(d.body); const mailtoHref = `mailto:?subject=${encodedSubj}&body=${encodedBody}`; return `
${svgPdf} ${d.eyebrow}
${d.name}
${d.desc}
${svgDl} Download ${svgMail} Email to Client
`; }).join(''); } async function loadDocuments() { renderPresetDocs(); const { data, error } = await SB.from('document_templates').select('*').order('template_name'); const templatesLabel = $('templates-label'); if (error) { $('docs-grid').innerHTML = `

Error

${error.message}

`; return; } const docs = data || []; if (!docs.length) { templatesLabel.style.display = 'none'; $('docs-grid').innerHTML = ''; return; } templatesLabel.style.display = ''; $('docs-grid').innerHTML = docs.map(d => `
${escapeHtml(DOC_TYPE_LABELS[d.template_type] || d.template_type)}
${escapeHtml(d.template_name)}
${escapeHtml((d.content || '').slice(0, 140) || 'No content yet — click to add.')}
${d.active ? 'Active' : 'Inactive'} · Updated ${fmtAgo(d.updated_at || d.created_at)}
`).join(''); $('docs-grid').querySelectorAll('.doc-card').forEach(card => { card.addEventListener('click', () => openDocModal(docs.find(x => x.id === card.dataset.docId))); }); } document.querySelector('.tab[data-tab="documents"]').addEventListener('click', () => setTimeout(loadDocuments, 50)); const docModal = $('doc-modal'); function openDocModal(record) { $('d-id').value = record ? record.id : ''; $('d-name').value = record ? record.template_name : ''; $('d-type').value = record ? record.template_type : 'invoice'; $('d-content').value = record ? (record.content || '') : ''; $('d-active').checked = record ? !!record.active : true; $('doc-modal-title').innerHTML = record ? 'Edit Template' : 'New Template'; $('d-delete').style.display = record ? 'inline-flex' : 'none'; docModal.classList.add('show'); setTimeout(() => $('d-name').focus(), 50); } $('doc-modal-close').addEventListener('click', () => docModal.classList.remove('show')); $('d-cancel').addEventListener('click', () => docModal.classList.remove('show')); docModal.addEventListener('click', e => { if (e.target === docModal) docModal.classList.remove('show'); }); $('doc-form').addEventListener('submit', async e => { e.preventDefault(); $('d-save').disabled = true; $('d-save').textContent = 'Saving…'; const payload = { template_name: $('d-name').value.trim(), template_type: $('d-type').value, content: $('d-content').value, active: $('d-active').checked, updated_at: new Date().toISOString() }; let err; const id = $('d-id').value; if (id) ({ error: err } = await SB.from('document_templates').update(payload).eq('id', id)); else ({ error: err } = await SB.from('document_templates').insert(payload)); $('d-save').disabled = false; $('d-save').textContent = 'Save'; if (err) { alert('Save failed: ' + err.message); return; } docModal.classList.remove('show'); loadDocuments(); }); $('d-delete').addEventListener('click', async () => { const id = $('d-id').value; if (!id) return; if (!confirm('Delete this template?')) return; const { error } = await SB.from('document_templates').delete().eq('id', id); if (error) { alert('Delete failed: ' + error.message); return; } docModal.classList.remove('show'); loadDocuments(); }); document.querySelectorAll('[data-action="new-doc"]').forEach(btn => { btn.addEventListener('click', () => openDocModal(null)); }); /* ============ BANK RECON ============ */ let reconParsed = []; // current parsed transactions awaiting save function parseCSV(text) { // Robust enough for common SA bank exports — handles quoted fields, comma delimiter const rows = []; let cur = '', row = [], inQuote = false; for (let i = 0; i < text.length; i++) { const c = text[i], n = text[i+1]; if (inQuote) { if (c === '"' && n === '"') { cur += '"'; i++; } else if (c === '"') inQuote = false; else cur += c; } else { if (c === '"') inQuote = true; else if (c === ',') { row.push(cur); cur = ''; } else if (c === '\n') { row.push(cur); rows.push(row); row = []; cur = ''; } else if (c === '\r') { /* skip */ } else cur += c; } } if (cur || row.length) { row.push(cur); rows.push(row); } return rows.filter(r => r.some(x => x && x.trim())); } function detectColumns(headers) { const lower = headers.map(h => (h || '').toLowerCase().trim()); const find = patterns => lower.findIndex(h => patterns.some(p => h.includes(p))); return { date: find(['date', 'transaction date', 'posting date']), desc: find(['description', 'narrative', 'details', 'transaction']), ref: find(['reference', 'ref', 'transaction reference']), amount: find(['amount', 'value']), moneyIn: find(['money in', 'credit', 'deposit', 'debit/credit']), moneyOut:find(['money out', 'debit']) }; } function findRefInText(text) { if (!text) return null; // Match PPA-XXX-XXX-XXXX (or PPA-WBS-001-0001 style with optional spaces/separators) const m = text.toUpperCase().match(/PPA[-\s]*[A-Z]{3}[-\s]*\d{2,4}[-\s]*\d{2,5}/); if (!m) return null; return m[0].replace(/\s+/g, '-').replace(/--+/g, '-'); } function parseAmount(s) { if (!s) return 0; const cleaned = String(s).replace(/[^\d.-]/g, ''); const n = parseFloat(cleaned); return isNaN(n) ? 0 : n; } function parseDate(s) { if (!s) return null; s = String(s).trim(); // Try ISO if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10); // Try DD/MM/YYYY or DD-MM-YYYY (SA standard) const m = s.match(/^(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{2,4})/); if (m) { let [, d, mo, y] = m; if (y.length === 2) y = '20' + y; return `${y}-${String(mo).padStart(2,'0')}-${String(d).padStart(2,'0')}`; } return null; } async function processCSV(file) { const text = await file.text(); const rows = parseCSV(text); if (rows.length < 2) { alert('CSV looks empty.'); return; } const headers = rows[0]; const dataRows = rows.slice(1); const cols = detectColumns(headers); if (cols.date === -1 || (cols.amount === -1 && cols.moneyIn === -1)) { alert('Could not detect required columns. CSV needs at least: a Date column and an Amount (or Money In) column.'); return; } // Fetch existing invoices for matching const { data: invoices } = await SB.from('invoices').select('id, invoice_number, amount_incl_vat, status, client_id'); const invByRef = {}; (invoices || []).forEach(i => { invByRef[(i.invoice_number || '').toUpperCase()] = i; }); reconParsed = dataRows.map(r => { const date = parseDate(r[cols.date]); const desc = r[cols.desc] || ''; const refField = cols.ref >= 0 ? r[cols.ref] : ''; const amt = cols.amount >= 0 ? parseAmount(r[cols.amount]) : (parseAmount(r[cols.moneyIn]) - parseAmount(r[cols.moneyOut] >= 0 ? r[cols.moneyOut] : 0)); const detectedRef = findRefInText(refField) || findRefInText(desc); const matchedInvoice = detectedRef ? invByRef[detectedRef.toUpperCase()] : null; return { date, desc, ref: detectedRef, amount: amt, direction: amt >= 0 ? 'in' : 'out', status: matchedInvoice ? 'matched' : 'unmatched', matched_invoice_id: matchedInvoice ? matchedInvoice.id : null, matched_invoice_number: matchedInvoice ? matchedInvoice.invoice_number : null, matched_amount: matchedInvoice ? Number(matchedInvoice.amount_incl_vat || 0) : null, matched_status: matchedInvoice ? matchedInvoice.status : null }; }).filter(t => t.date); // skip rows we couldn't parse a date from renderReconTable(); } function renderReconTable() { const matched = reconParsed.filter(t => t.status === 'matched').length; const unmatched = reconParsed.filter(t => t.status === 'unmatched').length; const ignored = reconParsed.filter(t => t.status === 'ignored').length; $('recon-stat-m').textContent = `✓ ${matched} matched`; $('recon-stat-u').textContent = `⚠ ${unmatched} unmatched`; $('recon-stat-i').textContent = `⊘ ${ignored} ignored`; $('recon-results').style.display = 'block'; $('recon-tbody').innerHTML = reconParsed.map((t, i) => { let matchHtml; if (t.status === 'matched') { const amtMatch = Math.abs(t.amount - t.matched_amount) < 0.01; matchHtml = `✓ ${escapeHtml(t.matched_invoice_number)}${t.matched_status === 'paid' ? ' · already paid' : ''}${amtMatch ? '' : ' ⚠ amount mismatch'}`; } else if (t.status === 'ignored') { matchHtml = `⊘ ignored `; } else { matchHtml = `⚠ no match `; } return ` ${t.date} ${escapeHtml((t.desc || '').slice(0, 60))} ${t.ref || '—'} ${ZAR(t.amount)} ${matchHtml} `; }).join(''); $('recon-tbody').querySelectorAll('button[data-recon-act]').forEach(btn => { btn.addEventListener('click', () => { const idx = Number(btn.dataset.idx); const act = btn.dataset.reconAct; if (act === 'ignore') reconParsed[idx].status = 'ignored'; if (act === 'reset') reconParsed[idx].status = reconParsed[idx].matched_invoice_id ? 'matched' : 'unmatched'; renderReconTable(); }); }); } async function applyRecon() { if (!reconParsed.length) return; if (!confirm(`Apply this import? Will mark ${reconParsed.filter(t=>t.status==='matched').length} matched invoices as paid and save the import for audit.`)) return; $('recon-apply').disabled = true; $('recon-apply').textContent = 'Applying…'; // Create import record const matched = reconParsed.filter(t => t.status === 'matched'); const unmatched = reconParsed.filter(t => t.status === 'unmatched'); const { data: imp, error: iErr } = await SB.from('bank_imports').insert({ filename: 'csv_import_' + new Date().toISOString().slice(0,10), rows_total: reconParsed.length, rows_matched: matched.length, rows_unmatched: unmatched.length }).select().single(); if (iErr) { alert('Import save failed: ' + iErr.message); $('recon-apply').disabled = false; return; } // Save all transactions const txnRows = reconParsed.map(t => ({ import_id: imp.id, txn_date: t.date, description: t.desc, reference: t.ref, amount: t.amount, direction: t.direction, status: t.status, matched_invoice_id: t.status === 'matched' ? t.matched_invoice_id : null })); await SB.from('bank_transactions').insert(txnRows); // Mark matched invoices as paid for (const t of matched) { await SB.from('invoices').update({ status: 'paid', paid_at: new Date().toISOString() }).eq('id', t.matched_invoice_id); } await SB.from('activity_feed').insert({ type: 'system', description: `Bank recon: ${matched.length} invoices marked paid · ${unmatched.length} unmatched` }); alert(`Done. ${matched.length} invoices marked paid. ${unmatched.length} transactions saved as unmatched for review.`); reconParsed = []; $('recon-apply').disabled = false; $('recon-apply').textContent = 'Mark Matched Invoices Paid'; $('recon-results').style.display = 'none'; loadFinance(); loadReconHistory(); loadKPIs(); loadActivity(); } async function loadReconHistory() { const { data } = await SB.from('bank_imports').select('*').order('imported_at', { ascending: false }).limit(10); if (!data || !data.length) { $('recon-history-list').textContent = 'No imports yet.'; return; } $('recon-history-list').innerHTML = data.map(h => `
${escapeHtml(h.filename || '—')} · ${new Date(h.imported_at).toLocaleString('en-ZA')}
${h.rows_matched} matched ${h.rows_unmatched} unmatched
`).join(''); } // Wire up $('recon-pick-btn').addEventListener('click', () => $('recon-file').click()); $('recon-drop').addEventListener('click', e => { if (e.target.id === 'recon-drop' || e.target.tagName === 'P' || e.target.tagName === 'H3') $('recon-file').click(); }); $('recon-file').addEventListener('change', e => { if (e.target.files[0]) processCSV(e.target.files[0]); }); $('recon-drop').addEventListener('dragover', e => { e.preventDefault(); $('recon-drop').classList.add('dragover'); }); $('recon-drop').addEventListener('dragleave', () => $('recon-drop').classList.remove('dragover')); $('recon-drop').addEventListener('drop', e => { e.preventDefault(); $('recon-drop').classList.remove('dragover'); if (e.dataTransfer.files[0]) processCSV(e.dataTransfer.files[0]); }); $('recon-clear').addEventListener('click', () => { reconParsed = []; $('recon-results').style.display = 'none'; $('recon-file').value = ''; }); $('recon-apply').addEventListener('click', applyRecon); // Load history when Recon tab clicked document.querySelector('.fin-tab[data-fin-tab="recon"]').addEventListener('click', () => setTimeout(loadReconHistory, 50)); /* ============ DEMO BOOKING SCHEDULER ============ */ const demoModal = $('demo-modal'); let currentDemo = null; // holds the demo_booking record while modal open const PRODUCT_LABELS = { dealer_dashboard: 'Dealer Dashboard', quotes_invoices: 'Quotes + Invoices Portal', payroll: 'Payroll System', ocr_capture: 'OCR Invoice Capture' }; function fmtDateTime(date, time) { if (!date) return 'TBD'; const d = new Date(date + 'T' + (time || '00:00')); const dStr = d.toLocaleDateString('en-ZA', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); const tStr = time ? ' at ' + time.slice(0,5) : ''; return dStr + tStr; } function buildEmail(kind, demo, scheduledDate, scheduledTime) { const product = PRODUCT_LABELS[demo.product_interested] || (demo.product_interested || 'Pinnacle PA service'); const clientName = (demo.name || demo.business || 'there').split(' ')[0]; const reqStr = fmtDateTime(demo.preferred_date, demo.preferred_time); const newStr = fmtDateTime(scheduledDate, scheduledTime); let subject, body; if (kind === 'reschedule') { subject = `Demo rescheduled — Pinnacle PA · ${product}`; body = `Hi ${clientName}, Thank you for booking a ${product} demo with Pinnacle PA. I have a small scheduling conflict at the time you originally requested (${reqStr}). Could we move the demo to: ${newStr} If this new slot works for you, please reply to confirm. If it does not suit, let me know which time works better and I will accommodate. The demo takes about 30–45 minutes. I will send the meeting link closer to the time. Looking forward to showing you what Pinnacle PA can do for ${demo.business || 'your business'}. Kind regards, Tiaan Pinnacle Personal Assistant info@pinnaclepa.biz · 076 919 0182`; } else { subject = `Demo confirmed — Pinnacle PA · ${product}`; body = `Hi ${clientName}, Thank you for booking a ${product} demo with Pinnacle PA. Confirming our session: ${newStr} The demo takes about 30–45 minutes. I will send the meeting link closer to the time. If anything changes on your side, just reply to this email. Looking forward to it. Kind regards, Tiaan Pinnacle Personal Assistant info@pinnaclepa.biz · 076 919 0182`; } return { subject, body }; } function renderEmailPreview(targetEl, to, subject, body) { targetEl.innerHTML = `
To${escapeHtml(to || '—')}
Subject${escapeHtml(subject)}
${escapeHtml(body)}
`; } function buildMailto(to, subject, body) { return `mailto:${encodeURIComponent(to || '')}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; } async function openDemoModal(demoId) { const { data, error } = await SB.from('demo_bookings').select('*').eq('id', demoId).single(); if (error || !data) { alert('Could not load booking: ' + (error && error.message)); return; } currentDemo = data; $('dm-id').value = data.id; $('dm-business').innerHTML = escapeHtml(data.business || data.name || '—'); $('dm-meta').textContent = [data.name, data.email, data.phone].filter(Boolean).join(' · '); $('dm-product').textContent = 'Interested in: ' + (PRODUCT_LABELS[data.product_interested] || data.product_interested || 'TBD'); $('dm-requested').textContent = fmtDateTime(data.preferred_date, data.preferred_time); const todayStr = ymd(new Date()); $('dm-date').value = data.preferred_date || todayStr; $('dm-time').value = (data.preferred_time || '09:00').slice(0,5); $('dm-duration').value = 60; $('dm-notes').value = ''; await checkConflictAndUpdatePreview(); demoModal.classList.add('show'); } async function checkConflictAndUpdatePreview() { if (!currentDemo) return; const date = $('dm-date').value; const time = $('dm-time').value; if (!date || !time) return; // Conflict check — any diary entry at same date/start time const { data: conflicts } = await SB.from('diary_entries').select('id, title, start_time, end_time').eq('entry_date', date); const newStart = time; const newEnd = (() => { const [h, m] = time.split(':').map(Number); const dur = Number($('dm-duration').value) || 60; const end = new Date(); end.setHours(h, m + dur, 0, 0); return String(end.getHours()).padStart(2,'0') + ':' + String(end.getMinutes()).padStart(2,'0'); })(); const overlapping = (conflicts || []).filter(c => { const cs = (c.start_time || '00:00').slice(0,5); const ce = (c.end_time || cs).slice(0,5); return !(ce <= newStart || cs >= newEnd); }); const conflictEl = $('dm-conflict'); if (overlapping.length) { conflictEl.style.display = 'block'; conflictEl.innerHTML = `Diary conflict: "${escapeHtml(overlapping[0].title)}" at ${(overlapping[0].start_time||'').slice(0,5)}. Pick a different slot or proceed knowing this time is double-booked.`; } else { conflictEl.style.display = 'none'; } // Decide if reschedule notice or confirmation notice const isReschedule = (date !== currentDemo.preferred_date) || (time !== ((currentDemo.preferred_time || '').slice(0,5))); if (isReschedule) { $('dm-reschedule').style.display = 'block'; $('dm-confirmation').style.display = 'none'; const { subject, body } = buildEmail('reschedule', currentDemo, date, time); renderEmailPreview($('dm-email-preview'), currentDemo.email, subject, body); } else { $('dm-reschedule').style.display = 'none'; $('dm-confirmation').style.display = 'block'; const { subject, body } = buildEmail('confirm', currentDemo, date, time); renderEmailPreview($('dm-confirm-preview'), currentDemo.email, subject, body); } } function closeDemoModal() { demoModal.classList.remove('show'); currentDemo = null; } ['dm-date','dm-time','dm-duration'].forEach(id => $(id).addEventListener('change', checkConflictAndUpdatePreview)); $('demo-modal-close').addEventListener('click', closeDemoModal); $('dm-cancel').addEventListener('click', closeDemoModal); demoModal.addEventListener('click', e => { if (e.target === demoModal) closeDemoModal(); }); $('dm-email-btn').addEventListener('click', () => { if (!currentDemo) return; const date = $('dm-date').value, time = $('dm-time').value; const isReschedule = (date !== currentDemo.preferred_date) || (time !== ((currentDemo.preferred_time || '').slice(0,5))); const { subject, body } = buildEmail(isReschedule ? 'reschedule' : 'confirm', currentDemo, date, time); window.location.href = buildMailto(currentDemo.email, subject, body); }); $('demo-form').addEventListener('submit', async e => { e.preventDefault(); if (!currentDemo) return; $('dm-save').disabled = true; $('dm-save').textContent = 'Saving…'; const date = $('dm-date').value, time = $('dm-time').value; const dur = Number($('dm-duration').value) || 60; const [h, m] = time.split(':').map(Number); const endDate = new Date(); endDate.setHours(h, m + dur, 0, 0); const endTime = String(endDate.getHours()).padStart(2,'0') + ':' + String(endDate.getMinutes()).padStart(2,'0'); // 1) Create diary entry const title = `Demo — ${currentDemo.business || currentDemo.name || 'Prospect'}`; const { error: dErr } = await SB.from('diary_entries').insert({ entry_date: date, start_time: time, end_time: endTime, title, description: `${PRODUCT_LABELS[currentDemo.product_interested] || currentDemo.product_interested || 'Demo'} · ${currentDemo.email || ''}\n\n${$('dm-notes').value}`.trim(), category: 'client' }); // 2) Update demo_booking status const { error: bErr } = await SB.from('demo_bookings').update({ status: 'scheduled', scheduled_at: date + 'T' + time + ':00', notes: ((currentDemo.notes ? currentDemo.notes + '\n' : '') + ($('dm-notes').value || '')).trim() || null }).eq('id', currentDemo.id); // 3) Activity feed entry await SB.from('activity_feed').insert({ type: 'demo', description: `Demo scheduled — ${escapeHtml(currentDemo.business || currentDemo.name || 'Prospect')} · ${date} ${time}` }); $('dm-save').disabled = false; $('dm-save').textContent = 'Confirm Schedule'; if (dErr || bErr) { alert('Error: ' + (dErr || bErr).message); return; } // 4) If time was rescheduled, auto-open mailto: for client notification const isReschedule = (date !== currentDemo.preferred_date) || (time !== ((currentDemo.preferred_time || '').slice(0,5))); if (isReschedule && currentDemo.email) { const { subject, body } = buildEmail('reschedule', currentDemo, date, time); setTimeout(() => { window.location.href = buildMailto(currentDemo.email, subject, body); }, 300); } closeDemoModal(); // Refresh affected widgets loadActions(); renderDiary('home'); renderDiary('diary'); loadActivity(); }); async function loadActions() { const [ { data: leads }, { data: demos }, { data: overdueInv } ] = await Promise.all([ SB.from('leads').select('id, business_name, source, created_at').eq('status', 'new').order('created_at', { ascending: false }).limit(3), SB.from('demo_bookings').select('id, business, name, preferred_date, deposit_paid').eq('status', 'pending').order('created_at', { ascending: false }).limit(3), SB.from('invoices').select('invoice_number, amount_incl_vat, due_date').eq('status', 'overdue').limit(3) ]); const list = $('action-list'); const items = []; (leads || []).forEach(l => { items.push(`
New lead — ${escapeHtml(l.business_name || 'Unknown')}
${l.source || 'manual'} · ${fmtAgo(l.created_at)}
`); }); (demos || []).forEach(d => { items.push(`
Demo request — ${escapeHtml(d.business || d.name || 'Prospect')}
${d.preferred_date || 'No date set'}${d.deposit_paid ? ' · Deposit paid ✓' : ''}
`); }); (overdueInv || []).forEach(inv => { items.push(`
!
Overdue — ${escapeHtml(inv.invoice_number || '?')}
${ZAR(inv.amount_incl_vat)} · Due ${inv.due_date || '—'}
`); }); list.innerHTML = items.length ? items.join('') : '
All caught up — nothing requires action right now.
'; } async function loadPipeline() { const { data: clients } = await SB.from('master_clients').select('id, business_name, pipeline_stage'); const STAGES = ['Lead','Quoted','Signed','Onboarding','Active','Maintenance','At-risk','Closed']; const grouped = {}; STAGES.forEach(s => { grouped[s] = 0; }); (clients || []).forEach(c => { const s = c.pipeline_stage || 'Lead'; if (grouped[s] !== undefined) grouped[s]++; else grouped['Lead']++; }); const total = Object.values(grouped).reduce((a, b) => a + b, 0) || 1; const el = $('pipeline'); const active = STAGES.filter(s => grouped[s] > 0); if (!active.length) { el.innerHTML = '
No clients yet.
'; return; } const RISK = ['At-risk']; el.innerHTML = active.map(stage => { const count = grouped[stage]; const pct = Math.min(100, Math.round((count / total) * 100)); const isRisk = RISK.includes(stage); return `
${stage} ${count}
`; }).join(''); } async function loadActivity() { const { data } = await SB.from('activity_feed').select('*').order('created_at', { ascending: false }).limit(20); const feed = $('feed'); const items = data || []; if (!items.length) { feed.innerHTML = '
No activity yet — actions will appear here.
'; return; } feed.innerHTML = items.map(item => `
${item.description || ''}
${fmtAgo(item.created_at)}
`).join(''); } /* ============ PROVISIONING MODULE ============ */ let provCache = []; document.querySelector('.tab[data-tab="provisioning"]').addEventListener('click', () => { setTimeout(loadProvisioning, 50); }); // Prov tab switching (scoped to provisioning page only) document.querySelectorAll('#prov-tabs .fin-tab').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('#prov-tabs .fin-tab').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const tab = btn.dataset.provTab; $('prov-content-pending').style.display = tab === 'pending' ? 'block' : 'none'; $('prov-content-done').style.display = tab === 'done' ? 'block' : 'none'; }); }); async function loadProvisioning() { const { data, error } = await SB.from('master_clients') .select('*') .eq('pipeline_stage', 'Active') .order('business_name'); if (error) { $('prov-pending-tbody').innerHTML = `
Error: ${error.message}
`; return; } provCache = data || []; const pending = provCache.filter(c => !c.provisioned); const done = provCache.filter(c => c.provisioned); $('prov-summary').textContent = `${pending.length} ${pending.length === 1 ? 'client needs' : 'clients need'} provisioning · ${done.length} already provisioned`; $('prov-pending-tbody').innerHTML = !pending.length ? `
All active clients are provisioned — nothing to do.
` : pending.map(c => { const pkgs = (c.packages || []); return `
${escapeHtml(c.business_name || '—')}
${escapeHtml(c.contact_name || '')}${c.contact_email ? ' · ' + escapeHtml(c.contact_email) : ''}
${pkgs.map(p => `${PKG_LABELS[p] || p}`).join('') || ''}
${Number(c.monthly_value) ? 'R ' + Number(c.monthly_value).toLocaleString('en-ZA') : '—'} ${c.contract_start || '—'} `; }).join(''); $('prov-done-tbody').innerHTML = !done.length ? `
No provisioned clients yet.
` : done.map(c => `
${escapeHtml(c.business_name || '—')}
${escapeHtml(c.contact_name || '')}
${c.dashboard_url ? `${escapeHtml(c.dashboard_url)}` : '—'} ${c.provisioned_at ? new Date(c.provisioned_at).toLocaleDateString('en-ZA') : '—'} ${c.subscription_status || 'inactive'} `).join(''); } function openProvModal(clientId) { const c = provCache.find(x => x.id === clientId); if (!c) return; $('prov-client-id').value = clientId; const pkgs = (c.packages || []).map(p => PKG_LABELS[p] || p).join(', ') || '—'; $('prov-client-summary').innerHTML = `${escapeHtml(c.business_name)} · ${escapeHtml(c.contact_name || '')}${c.contact_email ? ' · ' + escapeHtml(c.contact_email) : ''}
Packages: ${pkgs} · Monthly: ${Number(c.monthly_value) ? 'R ' + Number(c.monthly_value).toLocaleString('en-ZA') : '—'}`; $('prov-dashboard-url').value = c.dashboard_url || ''; $('prov-sub-code').value = c.paystack_subscription_code || ''; $('prov-sub-status').value = c.subscription_status || 'inactive'; $('prov-notes').value = c.notes || ''; $('prov-modal').classList.add('show'); } function closeProvModal() { $('prov-modal').classList.remove('show'); } $('prov-modal-close').addEventListener('click', closeProvModal); $('prov-cancel').addEventListener('click', closeProvModal); $('prov-modal').addEventListener('click', e => { if (e.target === $('prov-modal')) closeProvModal(); }); $('prov-save').addEventListener('click', async () => { const clientId = $('prov-client-id').value; if (!clientId) return; $('prov-save').disabled = true; $('prov-save').textContent = 'Saving…'; const dashUrl = $('prov-dashboard-url').value.trim(); const subCode = $('prov-sub-code').value.trim(); const subStatus = $('prov-sub-status').value; const notes = $('prov-notes').value.trim(); const { error } = await SB.from('master_clients').update({ provisioned: true, provisioned_at: new Date().toISOString(), dashboard_url: dashUrl || null, paystack_subscription_code: subCode || null, subscription_status: subStatus, notes: notes || null }).eq('id', clientId); if (error) { $('prov-save').disabled = false; $('prov-save').textContent = 'Mark as Provisioned ✓'; alert('Error saving: ' + error.message); return; } const c = provCache.find(x => x.id === clientId); await SB.from('activity_feed').insert({ type: 'provisioning', description: `Client provisioned — ${escapeHtml(c ? c.business_name : clientId)}${dashUrl ? ' · ' + escapeHtml(dashUrl) : ''}`, related_id: clientId, related_table: 'master_clients' }); $('prov-save').disabled = false; $('prov-save').textContent = 'Mark as Provisioned ✓'; closeProvModal(); loadProvisioning(); loadActivity(); }); /* ============ WEBSITE MODULE ============ */ document.querySelector('.tab[data-tab="website"]').addEventListener('click', () => { setTimeout(loadWebsiteModule, 50); }); async function loadWebsiteModule() { const [ { data: wLeads }, { data: demos } ] = await Promise.all([ SB.from('website_leads').select('*').is('converted_to_lead_id', null).order('created_at', { ascending: false }), SB.from('demo_bookings').select('*').order('created_at', { ascending: false }).limit(50) ]); const pending = (demos || []).filter(d => d.status === 'pending'); const deposits = (demos || []).filter(d => d.deposit_paid); $('web-m-new').textContent = (wLeads || []).length; $('web-m-signups').textContent = pending.length; $('web-m-deposits').textContent = deposits.length; // Website leads table const wlTbody = $('web-leads-tbody'); if (!(wLeads || []).length) { wlTbody.innerHTML = `
No unprocessed website leads.
`; } else { wlTbody.innerHTML = (wLeads || []).map(w => `
${escapeHtml(w.name || '—')}
${escapeHtml(w.business || '')}
${escapeHtml(w.email || '—')}${w.phone ? ' · ' + escapeHtml(w.phone) : ''} ${escapeHtml((w.message || '').slice(0, 80))} ${fmtAgo(w.created_at)} `).join(''); } // Demo bookings table const PRODUCT_LABELS_SHORT = { 'starter-combo': 'Launch Package', 'Launch Promotional Package — Dashboard + Starter Website (R9,500 + R1,500/mo)': 'Launch Package', 'dealer_dashboard': 'Dealer Dashboard', 'website': 'Website' }; const demoTbody = $('web-demos-tbody'); const bookings = (demos || []).filter(d => ['pending','deposit_paid','scheduled'].includes(d.status)); if (!bookings.length) { demoTbody.innerHTML = `
No reservations yet.
`; } else { demoTbody.innerHTML = bookings.map(d => { const product = PRODUCT_LABELS_SHORT[d.product_interested] || (d.product_interested || '').slice(0, 30) || '—'; const statusCls = { pending: '', deposit_paid: 'active', scheduled: 'onboarding' }[d.status] || ''; return `
${escapeHtml(d.business || '—')}
${escapeHtml(d.name || '')}
${escapeHtml(d.email || '—')}${d.phone ? '
' + escapeHtml(d.phone) : ''} ${escapeHtml(product)} ${d.deposit_paid ? 'R4,750 ✓' : '