/* ProspectOS frontend. Set window.__PROSPECT_CONFIG__.apiBase to an explicit API origin only when the UI is not served through the same-host /api proxy. */
asyncfunctionloadSavedFilters(){try{savedFilters=savedItems(awaitjsonRequest('/api/v1/saved-filters'));renderSavedFilters();}catch(error){if(error.message!=='unauthorized')setSavedMessage(`Unable to load saved views: ${apiError(error)}`,true);}}
asyncfunctionsaveCurrentFilters(event){event.preventDefault();constform=event.currentTarget,name=$('savedFilterName').value.trim();if(!name){setSavedMessage('Enter a name for this saved view.',true);return;}try{awaitjsonRequest('/api/v1/saved-filters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,filters:currentFilters()})});form.reset();setSavedMessage('Saved view created.');awaitloadSavedFilters();}catch(error){if(error.message!=='unauthorized')setSavedMessage(apiError(error),true);}}
asyncfunctiondeleteSavedFilter(){constid=$('savedFilterSelect').value;if(!id||!window.confirm('Delete this saved view?'))return;try{awaitjsonRequest(`/api/v1/saved-filters/${encodeURIComponent(id)}`,{method:'DELETE'});setSavedMessage('Saved view deleted.');awaitloadSavedFilters();}catch(error){if(error.message!=='unauthorized')setSavedMessage(apiError(error),true);}}
asyncfunctionbulkReview(action){constids=selectedReviewProspects().map(Number);if(!ids.length)return;constlabel=action==='verify'?'verify':'reject';if(!window.confirm(`Confirm ${label} of ${ids.length} selected prospect${ids.length===1?'':'s'}? This changes review state only; no outreach will be sent.`))return;constbutton=$(action==='verify'?'bulkVerifyBtn':'bulkRejectBtn');button.disabled=true;try{awaitjsonRequest('/api/v1/businesses/bulk-review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({business_ids:ids,action,review_action:action})});selectedReviewIds.clear();setExplorerState(`${label[0].toUpperCase()+label.slice(1)} action completed.`);awaitPromise.all([loadData(),loadReviewQueue()]);}catch(error){if(error.message!=='unauthorized')setExplorerState(apiError(error),true);}finally{updateBulkState();}}
asyncfunctionrequest(path,options={}){constresponse=awaitfetch(endpoint(path),{...options,credentials:'include'});if(response.status===401){showLogin('Your session has expired. Please sign in again.');thrownewError('unauthorized');}if(response.status===403)thrownewError('Tenant/workspace access denied.');returnresponse;}
asyncfunctionloadData(){$('apiStatus').textContent='● Connecting…';$('apiStatus').classList.remove('live');setExplorerState('Loading prospects…');try{const[listRes,summaryRes]=awaitPromise.all([request(`/api/v1/businesses${listQuery()}`),request('/api/v1/dashboard/summary')]);if(!listRes.ok||!summaryRes.ok)thrownewError('API request failed');constlist=awaitlistRes.json(),summary=awaitsummaryRes.json();prospects=Array.isArray(list)?list:(list.businesses||list.items||[]);populateProspectFilters();hasNextPage=Boolean(list.has_next??list.next_page??list.next??list.next_cursor);renderMetrics(summary);$('apiStatus').textContent='● API connected';$('apiStatus').classList.add('live');setExplorerState('');}catch(error){if(error.message==='unauthorized')return;prospects=[];hasNextPage=false;renderMetrics(null);$('apiStatus').textContent='● API unavailable';setExplorerState(`Unable to load prospects: ${apiError(error)}`,true);}renderRows();if(selectedId)loadDetail(selectedId);}
panel.innerHTML=`<div class="website-scan-heading"><div><p class="eyebrow">WEBSITE ANALYSIS</p><h4>Website scan</h4></div><div class="website-scan-actions"><button class="button ghost compact" id="refreshWebsiteScanBtn" type="button">↻ Refresh</button><button class="button primary compact" id="scanWebsiteBtn" type="button">Scan website</button></div></div><p class="website-scan-safety">Website analysis is authenticated workspace evidence from the HTTP scanner. It does not enable outreach.</p><div id="websiteScanState" aria-live="polite">${websiteScanFor(p)?renderWebsiteScan(websiteScanFor(p)):'<p class="muted">No website scan has been returned. Start a scan to see HTTP and contact signals.</p>'}</div>`;
if(!payload){state.innerHTML='<p class="muted">No extracted contacts returned. Extract from approved public business pages to review contact evidence.</p>';return;}
constcontacts=extractedContactItems(payload);
if(!contacts.length){state.innerHTML='<div class="contact-extraction-empty"><strong>No public contacts found</strong><p class="muted">No contact details were found on the approved public business pages checked.</p></div>';return;}
functionrenderContactExtractionPanel(p){constpanel=$('contactExtractionPanel');if(!panel)return;panel.innerHTML=`<div class="contact-extraction-heading"><div><p class="eyebrow">PUBLIC CONTACT EXTRACTION</p><h4>Extracted contacts <span class="count">${extractedContactItems(p.extracted_contacts||p.contact_extraction).length}</span></h4></div><div class="contact-extraction-actions"><button class="button ghost compact" id="refreshContactExtractionBtn" type="button">↻ Refresh</button><button class="button primary compact" id="extractContactsBtn" type="button">Extract public contacts</button></div></div><p class="contact-extraction-safety">Extraction uses approved public business pages only. It does not probe SMTP, verify mailbox access, or send outreach.</p><div id="contactExtractionState" aria-live="polite">${p.extracted_contacts||p.contact_extraction?renderExtractedContactsMarkup(p.extracted_contacts||p.contact_extraction):'<p class="muted">No public contacts extracted yet. Start an extraction to review evidence.</p>'}</div>`;}
functionrenderExtractedContactsMarkup(payload){constcontacts=extractedContactItems(payload);if(!contacts.length)return'<p class="muted">No public contacts extracted yet. Start an extraction to review evidence.</p>';return'<p class="muted">Previously extracted contacts are available. Refresh to check approved public business pages again.</p>';}
asyncfunctionloadContactExtraction(id,{extract=false}={}){conststate=$('contactExtractionState'),extractButton=$('extractContactsBtn'),refreshButton=$('refreshContactExtractionBtn');if(!state||!id)return;[extractButton,refreshButton].forEach(button=>{if(button)button.disabled=true;});state.innerHTML='<div class="detail-loading" aria-live="polite">Loading public contacts…</div>';try{constscan=selectedDetail?.website_scan||selectedDetail?.websiteScan||{};constpayload={approved_public_pages_only:true,smtp_probing:false,outreach:false,website_scan_id:scan.id,source_url:scan.final_url||scan.input_url,html:scan.html};constresult=awaitjsonRequest(extract?`/api/v1/businesses/${encodeURIComponent(id)}/contacts/extract`:`/api/v1/contact-extractions?business_id=${encodeURIComponent(id)}`,extract?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}:{});if(selectedDetail&&Number(selectedDetail.id)===Number(id))selectedDetail={...selectedDetail,extracted_contacts:result.contacts||result.extracted_contacts||result.items||result};renderExtractedContacts(result);}catch(error){if(error.message!=='unauthorized')state.innerHTML=`<div class="detail-error" role="alert"><strong>${extract?'Contact extraction failed':'Unable to load extracted contacts'}</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryContactExtractionBtn" type="button">Try again</button></div>`;}finally{[extractButton,refreshButton].forEach(button=>{if(button)button.disabled=false;});}}
functionrenderScoreConfig(payload){constpanel=$('scoreRulesPanel');if(!panel)return;construles=scoreItems(payload),version=scoreValue(payload,['version','score_version','scoring_version'],'Unknown'),editable=canEditScores();panel.innerHTML=`<div class="panel-heading"><div><p class="eyebrow">CONFIGURATION</p><h3>Score rules</h3></div><span class="small-label">${editable?'Admin controls available':'Read-only for this role'}</span></div><p class="score-config-note">Rules are workspace configuration. ${editable?'Changes require an authorized admin action.':'Your role can view rules but cannot edit them.'}</p><div class="score-rule-table">${rules.length?rules.map(rule=>`<div class="score-config-row"><strong>${esc(scoreValue(rule,['label','name','rule','key'],'Scoring rule'))}</strong><span>${scoreValue(rule,['enabled','active'],false)?'Enabled':'Disabled'}</span><span>${esc(scoreValue(rule,['points','weight','value'],0))} pts</span><small>v${esc(scoreValue(rule,['version','rule_version'],version))}</small>${editable?`<button class="button ghost compact" type="button" data-score-edit="${esc(rule.id)}" data-score-points="${esc(scoreValue(rule,['points','weight','value'],0))}">Edit points</button>`:''}</div>`).join(''):'<p class="muted">No score rules returned by the workspace.</p>'}</div><p class="small-label">Configuration version: ${esc(version)}</p>`;}
functionrenderScoreDistribution(payload){constpanel=$('scoreDistributionPanel');if(!panel)return;constbuckets=payloadItems(payload,['distribution','buckets','items']);constbandMap=payload?.bands&&typeofpayload.bands==='object'&&!Array.isArray(payload.bands)?payload.bands:{};constsummary=payload?.summary||payload||{};constfallback=[['High priority',summary.high_priority??bandMap.high??0],['Medium priority',bandMap.medium??0],['Low priority',bandMap.low??0],['Ineligible',bandMap.ineligible??0]];constrows=buckets.length?buckets.map(item=>[scoreValue(item,['label','band','name'],'Score band'),scoreValue(item,['count','total','value'],0)]):fallback;consttotal=rows.reduce((n,row)=>n+Number(row[1]||0),0);panel.innerHTML=`<div class="panel-heading"><div><p class="eyebrow">SCORING SUMMARY</p><h2>Score distribution</h2></div><span class="small-label">${total} prospects</span></div><div class="score-distribution-list">${rows.map(([label,count])=>`<div class="distribution-row"><span>${esc(label)}</span><strong>${esc(count)}</strong><span class="distribution-track"><i style="width:${total?Math.min(100,Number(count)/total*100):0}%"></i></span></div>`).join('')}</div><p class="score-safety">Distribution is tenant-scoped and reflects API results only.</p>`;}
asyncfunctionupdateScoreRule(id,currentPoints){if(!canEditScores()||!id)return;constvalue=window.prompt('Points for this rule (0–100):',currentPoints);if(value===null)return;constpoints=Number(value);if(!Number.isInteger(points)||points<0||points>100){window.alert('Enter a whole number from 0 to 100.');return;}try{awaitjsonRequest(`/api/v1/score-rules/${encodeURIComponent(id)}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({points})});awaitloadScorePanels();}catch(error){if(error.message!=='unauthorized')window.alert(error.message||'Unable to update score rule.');}}
panel.innerHTML=`<div class="score-panel-heading"><div><p class="eyebrow">SCORING</p><h4>Score breakdown</h4></div><button class="button ghost compact" id="recalculateScoreBtn" type="button">↻ Recalculate</button></div><div class="score-breakdown-summary"><div><small>Total score</small><strong class="score-total ${scoreClass(score)}">${esc(score)}<small>/ 100</small></strong></div><div><small>Priority</small><b>${esc(priority)}</b></div><div><small>Eligibility</small><b>${esc(eligible)}</b></div></div><dl class="score-meta"><div><dt>Score version</dt><dd>${esc(version)}</dd></div><div><dt>Signals</dt><dd>${factors.length}</dd></div></dl><div class="score-rules-explanation"><h5>Rule explanations</h5>${factors.length?factors.map(raw=>{constitem=scoreExplanation(raw);return`<div class="score-rule-row"><span>${esc(scoreValue(item,['label','name','rule','factor'],'Scoring signal'))}</span><strong>${esc(scoreValue(item,['points','value','weight'],0))} pts</strong><small>${esc(scoreValue(item,['explanation','reason','description'],'Observed evidence contributes to this score.'))}</small></div>`}).join(''):'<p class="muted">No scoring signals returned.</p>'}</div><p class="score-safety">Scores are review signals only. They do not override suppression or authorize outreach.</p><p id="scoreMessage" class="form-message" role="status" aria-live="polite"></p>`;
}
asyncfunctionrecalculateScore(){constbutton=$('recalculateScoreBtn'),messageEl=$('scoreMessage');if(!button||!selectedId)return;button.disabled=true;if(messageEl)messageEl.textContent='Recalculation requested…';try{constresult=awaitjsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/score/recalculate`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});if(messageEl)messageEl.textContent=result?.status==='queued'?'Recalculation queued. Refresh when the job completes.':'Score recalculation accepted. Refresh to see the API result.';}catch(error){if(error.message!=='unauthorized'&&messageEl){messageEl.textContent=error.message||'Unable to recalculate score.';messageEl.className='form-message error';}}finally{button.disabled=false;}}
unknown:['AI assistance is unknown','The provider did not establish a result. No suggestion is available for approval.'],
error:['Unable to load AI assistance',detail||'The provider returned an error. Try again later.'],
suppressed:['AI assistance suppressed','This prospect is suppressed. Suggestions are unavailable and no contact action is permitted.']
}[normalized]||['No suggestion yet','Generate an evidence-grounded suggestion when an approved provider is available.'];el.innerHTML=`<div class="ai-state ${esc(normalized)}" role="${normalized==='error'?'alert':'status'}"><strong>${esc(copy[0])}</strong><p>${esc(copy[1])}</p></div>`;}
asyncfunctionloadAiAssistance(id){constpanel=$('aiAssistancePanel');if(!panel)return;constprospect=selectedDetail||prospects.find(item=>Number(item.id)===Number(id))||{};if(statusOf(prospect)==='suppressed'){renderAiState('suppressed');return;}panel.querySelector('#aiAssistanceState').innerHTML='<div class="detail-loading" aria-live="polite">Loading AI assistance…</div>';try{constpayload=awaitjsonRequest(`/api/v1/ai-runs?business_id=${encodeURIComponent(id)}`);constitems=aiItems(payload);conststatus=String(payload?.status||payload?.state||'').toLowerCase().replaceAll('_','-');if(['not-configured','unknown','error','suppressed','stale'].includes(status)){renderAiState(status,payload?.reason||payload?.message);return;}panel.querySelector('#aiAssistanceState').innerHTML=items.length?items.map(renderAiSuggestion).join(''):'<div class="ai-state" role="status"><strong>No suggestion yet</strong><p>Generate an evidence-grounded suggestion when an approved provider is available.</p></div>';}catch(error){if(error.message!=='unauthorized')renderAiState('error',error.message);}}
asyncfunctionaiAction(action,id){if(!id)return;constlabel=action==='approve'?'approve':'reject';if(!window.confirm(`Confirm ${label} of this suggestion? This only records a human decision; no outreach will be sent.`))return;constpath=action==='approve'?`/api/v1/ai-runs/${encodeURIComponent(id)}/approve`:`/api/v1/ai-runs/${encodeURIComponent(id)}/reject`;try{awaitjsonRequest(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({human_approval:true,autonomous_action:false})});awaitloadAiAssistance(selectedId);}catch(error){if(error.message!=='unauthorized')renderAiState('error',error.message);}}
conststate=draftApprovalState(draft),citations=draftCitations(draft),recipient=recipientValue(draft),canApprove=['pending','generated','ready'].includes(state)&&recipient!=='Recipient not returned';
functionrenderOutreachState(state,detail=''){constel=$('outreachDraftState');if(!el)return;constnormalized=outreachState(state);constcopy={'not-configured':['Outreach drafting is not configured','Ask an administrator to configure an approved provider. No draft or message can be sent.'],suppressed:['Outreach blocked by suppression','This prospect is suppressed. No draft may be created and no message can be sent.'],blocked:['Outreach blocked by policy',detail||'The current review or policy state does not permit a draft. No message can be sent.'],error:['Unable to load outreach drafts',detail||'The provider returned an error. No message can be sent.'],unknown:['Outreach status unknown',detail||'No safe provider or draft status was established. No message can be sent.']}[normalized]||['No outreach draft yet','Create an evidence-grounded draft for human review. Creating a draft never sends a message.'];el.innerHTML=`<div class="outreach-state ${esc(normalized)}" role="${normalized==='error'?'alert':'status'}"><strong>${esc(copy[0])}</strong><p>${esc(copy[1])}</p></div>`;}
asyncfunctionloadOutreachDrafts(id){conststate=$('outreachDraftState'),button=$('createOutreachDraftBtn'),prospect=selectedDetail||prospects.find(item=>Number(item.id)===Number(id))||{};if(!state||!id)return;if(statusOf(prospect)==='suppressed'){renderOutreachState('suppressed');if(button)button.disabled=true;return;}state.innerHTML='<div class="detail-loading" aria-live="polite">Loading outreach drafts…</div>';try{constpayload=awaitjsonRequest(`/api/v1/outreach/drafts?business_id=${encodeURIComponent(id)}`),items=outreachItems(payload),status=outreachState(payload?.status||payload?.state||'');if(['not-configured','blocked','suppressed','unknown'].includes(status)){renderOutreachState(status,payload?.reason||payload?.message);if(button)button.disabled=true;return;}state.innerHTML=items.length?items.map(renderDraft).join(''):'<div class="outreach-state" role="status"><strong>No outreach draft yet</strong><p>Create an evidence-grounded draft for human review. Creating a draft never sends a message.</p></div>';}catch(error){if(error.message!=='unauthorized')renderOutreachState(error.message.toLowerCase().includes('config')?'not-configured':'error',error.message);}}
asyncfunctioncreateOutreachDraft(){if(!selectedId)return;constbutton=$('createOutreachDraftBtn');if(!button)return;constprospect=selectedDetail||prospects.find(item=>Number(item.id)===Number(selectedId))||{},recipient=prospect.email||(prospect.contacts||[]).find(c=>c.email)?.email;if(!recipient){renderOutreachState('blocked','No recipient email is available for review.');return;}button.disabled=true;$('outreachDraftState').innerHTML='<div class="detail-loading" aria-live="polite">Creating evidence-grounded draft…</div>';try{constevidence=prospect.evidence||prospect.evidence_timeline||[],body=evidence.length?'Hello, we noticed this about your business: {{evidence.1.claim}}':'Hello, this evidence-grounded draft is ready for your review.';awaitjsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/outreach/drafts`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target:{kind:'email',value:recipient,verified:true},subject:'Hello {{business.name}}',body,legal_basis:'pending human review',consent_confirmed:false,autonomous_action:false,send:false})});awaitloadOutreachDrafts(selectedId);}catch(error){if(error.message!=='unauthorized')renderOutreachState(error.message.toLowerCase().includes('config')?'not-configured':error.message.toLowerCase().includes('supp')?'suppressed':'error',error.message);}finally{button.disabled=false;}}
asyncfunctionapproveOutreachDraft(id){if(!id||!window.confirm('Approve this draft for recordkeeping? Approval does not send a message.'))return;try{awaitjsonRequest(`/api/v1/outreach/drafts/${encodeURIComponent(id)}/approve`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({human_approval:true,send:false,autonomous_action:false})});awaitloadOutreachDrafts(selectedId);}catch(error){if(error.message!=='unauthorized')renderOutreachState('error',error.message);}}
functionrenderProviderPolicy(payload){constpanel=$('providerPolicyPanel');if(!panel)return;constitems=payloadItems(payload,['providers','items','policies']),status=outreachState(payload?.status||payload?.state||'');if(['error','auth-required','suppressed','blocked','unknown'].includes(status)){panel.innerHTML=`<div class="provider-state ${esc(status)}" role="${status==='error'||status==='auth-required'?'alert':'status'}"><strong>${esc(outreachLabel(status))} provider policy</strong><p>${esc(payload?.message||payload?.reason||'No provider status was established. No secrets are displayed.')}</p></div>`;return;}if(!payload){panel.innerHTML='<div class="provider-state" role="status"><strong>Provider policy unavailable</strong><p>No provider configuration was returned. No secrets are displayed.</p></div>';return;}panel.innerHTML=items.length?items.map(provider=>{constconfigured=Boolean(provider.configured??provider.is_configured),enabled=Boolean(provider.enabled??provider.active),providerStatus=provider.status||(!configured?'not-configured':enabled?'enabled':'disabled');return`<article class="provider-policy-row"><div><strong>${esc(provider.name||provider.provider_id||provider.id||'Approved provider')}</strong><small>${esc(provider.model||provider.version||'Version not returned')}</small></div><span class="provider-status ${esc(outreachState(providerStatus))}">${esc(outreachLabel(providerStatus))}</span><dl><div><dt>Configured</dt><dd>${configured?'Yes':'No'}</dd></div><div><dt>Enabled</dt><dd>${enabled?'Yes':'No'}</dd></div><div><dt>Policy</dt><dd>${esc(provider.policy_version||'Unknown')}</dd></div><div><dt>Secrets</dt><dd>Not displayed</dd></div></dl></article>`;}).join(''):`<div class="provider-state" role="status"><strong>Outreach provider not configured</strong><p>Drafting and sending are unavailable until an approved provider is configured. No secrets are displayed.</p></div>`;}
if(payload?.status==='error'&&!services.length){state.innerHTML=`<div class="provider-state error" role="alert"><strong>Unable to load provider status</strong><p>${esc(payload.message||payload.reason||'The provider status could not be established.')}</p></div>`;return;}
state.innerHTML=`<div class="ai-provider-status-list">${rows.map(service=>{conststatus=providerStatusValue(service.status||service.state||(service.configured?'configured':'not-configured')),configured=['configured','ready','enabled','connected'].includes(status);return`<article class="ai-provider-status-row"><div><strong>${esc(service.name||service.provider||'Approved provider')}</strong><small>${esc(service.model||service.base_url||'Configuration details unavailable')}</small></div><span class="provider-status ${esc(status)}">${esc(providerStatusLabel(status))}</span><p>${configured?'Credentials configured; values are never displayed.':status==='invalid'?'Configuration was rejected. Review the fields and try again.':status==='not-configured'||status==='disabled'?'No credentials are configured for this service.':'No safe connection result was returned.'}</p></article>`;}).join('')}</div><p class="provider-status-safety">Secret values, key fingerprints, and response bodies are never shown.</p>`;
functionupdateAiProviderForm(){conststep=$('aiProvider')?.value==='stepfun';constmodel=$('nousModel'),base=$('nousBaseUrl'),key=$('nousApiKey');if(model){model.previousElementSibling&&(model.previousElementSibling.textContent=step?'StepFun model':'Nous Portal model');model.placeholder=step?'step-3.7-flash':'Hermes 4 405B';}if(base){base.previousElementSibling&&(base.previousElementSibling.textContent=step?'StepFun base URL':'Nous Portal base URL');base.placeholder=step?'https://api.stepfun.ai/v1':'https://inference-api.nousresearch.com/v1';}if(key){key.previousElementSibling&&(key.previousElementSibling.textContent=step?'StepFun API key · write-only · leave blank to keep':'Nous Portal API key · write-only · leave blank to keep');}}
functionsetAiProviderAdminState(){conststate=$('aiProviderAdminState'),form=$('aiProviderForm'),test=$('testAiProviderBtn');if(!state)return;consteditable=canEditAiProviders();state.hidden=editable;if(!editable){state.innerHTML='<strong>Administrator access required</strong><p>Your role can view safe provider status, but only an administrator can edit or test credentials.</p>';form?.setAttribute('hidden','');if(test)test.disabled=true;}else{form?.removeAttribute('hidden');if(test)test.disabled=false;}}
asyncfunctionsaveAiProviderSettings(event){event.preventDefault();if(!canEditAiProviders()){message('aiProviderMessage','Administrator access is required to update provider settings.',true);return;}constform=event.currentTarget,data=Object.fromEntries(newFormData(form).entries()),body={provider:data.provider,model:data.nous_model.trim(),nous_base_url:data.nous_base_url.trim(),searxng_base_url:data.searxng_base_url.trim(),enabled:true,credentials:{}};if(data.nous_api_key)body.credentials[data.provider==='stepfun'?'step_api_key':'nous_api_key']=data.nous_api_key;constbutton=$('saveAiProviderBtn');button.disabled=true;message('aiProviderMessage','Saving provider settings…');try{awaitjsonRequest('/api/v1/ai/provider-config',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});form.querySelectorAll('input[type="password"]').forEach(input=>input.value='');message('aiProviderMessage','Provider settings saved. Discovery and AI work were not started.');awaitloadAiProviderSettings();}catch(error){if(error.message!=='unauthorized')message('aiProviderMessage',error.message||'Unable to save provider settings.',true);}finally{button.disabled=false;}}
asyncfunctiontestAiProviderConnection(){if(!canEditAiProviders())return;constbutton=$('testAiProviderBtn');button.disabled=true;conststate=$('aiProviderStatus');state.innerHTML='<div class="detail-loading" aria-live="polite">Testing provider connection…</div>';try{constpayload=awaitjsonRequest('/api/v1/admin/ai-provider-config/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});renderAiProviderStatus(payload);message('aiProviderMessage',payload?.status==='invalid'?'Connection test returned an invalid configuration.':'Connection test completed; no secrets were returned.');}catch(error){if(error.message!=='unauthorized')renderAiProviderStatus({status:error.message.toLowerCase().includes('invalid')?'invalid':'error',message:error.message});}finally{button.disabled=false;}}
try{constpayload=awaitjsonRequest('/api/v1/merge-history');constitems=payloadItems(payload,['history','merges','items']).filter(item=>Number(item.source_business_id)===Number(id)||Number(item.target_business_id)===Number(id));history.innerHTML=`<h4>Merge history <span class="count">${items.length}</span></h4>${items.length?`<div class="history-list">${items.map(item=>{constmergeId=item.merge_id||item.id,source=item.source_name||item.source_business_name||('Prospect '+(item.source_business_id||'source')),target=item.target_name||item.target_business_name||('Prospect '+(item.target_business_id||'target'));return`<article class="history-row"><div><strong>${esc(source)} → ${esc(target)}</strong><small>${esc(item.created_at||item.merged_at||'Time unavailable')} · ${esc(item.status||'Merged')}</small></div>${item.reversible!==false&&item.reversed_at==null?`<button class="button ghost compact" type="button" data-reverse-merge="${esc(mergeId)}">Reverse merge</button>`:`<span class="small-label">${item.reversed_at?'Reversed':'Not reversible'}</span>`}</article>`;}).join('')}</div>`:'<p class="muted">No merges recorded for this prospect.</p>'}`;}catch(error){if(error.message!=='unauthorized')history.innerHTML=`<div class="dedup-error" role="alert"><h4>Unable to load merge history</h4><p>${esc(error.message)}</p><button class="button ghost compact" id="retryHistoryBtn" type="button">Try again</button></div>`;}
}
functionopenMergeDialog(targetId,targetName){mergeSource={id:selectedId,name:selectedDetail?.name||prospects.find(p=>Number(p.id)===Number(selectedId))?.name||`Prospect ${selectedId}`};mergeTarget={id:targetId,name:targetName};$('mergeDialogCopy').innerHTML=`You are about to merge <strong>${esc(mergeSource.name)}</strong> (source) into <strong>${esc(mergeTarget.name)}</strong> (target). Review both records before confirming.`;$('mergeDialogMessage').textContent='';$('mergeDialogMessage').className='form-message';$('mergeDialog').hidden=false;$('confirmMergeBtn').disabled=false;}
asyncfunctionconfirmMerge(){if(!mergeSource||!mergeTarget||mergeBusy)return;mergeBusy=true;constbutton=$('confirmMergeBtn');button.disabled=true;$('mergeDialogMessage').textContent='Merging records…';$('mergeDialogMessage').className='form-message';try{awaitjsonRequest(`/api/v1/businesses/${encodeURIComponent(mergeSource.id)}/merge`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target_id:Number(mergeTarget.id),review_required:true})});$('mergeDialogMessage').textContent='Merge completed and recorded in history.';awaitloadData();closeMergeDialog();if(selectedId)awaitloadDetail(selectedId);}catch(error){if(error.message!=='unauthorized'){$('mergeDialogMessage').textContent=error.message||'Unable to merge records.';$('mergeDialogMessage').className='form-message error';button.disabled=false;}}finally{mergeBusy=false;}}
asyncfunctionreverseMerge(id){if(!window.confirm('Reverse this merge? The original records will be restored.'))return;try{awaitjsonRequest(`/api/v1/merge-history/${encodeURIComponent(id)}/reverse`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_required:true})});if(selectedId){awaitloadDetail(selectedId);awaitloadData();}}catch(error){consthistory=$('mergeHistory');if(error.message!=='unauthorized'&&history)history.insertAdjacentHTML('afterbegin',`<p class="form-message error" role="alert">${esc(error.message||'Unable to reverse merge.')}</p>`);}}
asyncfunctionsaveContact(form){constdata=Object.fromEntries(newFormData(form).entries());if(!data.email.trim()){message('contactMessage','Email is required.',true);return;}try{awaitjsonRequest(`/api/v1/businesses/${selectedId}/contacts`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});message('contactMessage','Contact added.');awaitloadDetail(selectedId);}catch(e){if(e.message!=='unauthorized')message('contactMessage',e.message,true);}}
asyncfunctionsaveNote(form){constdata=Object.fromEntries(newFormData(form).entries());if(!data.body.trim()){message('noteMessage','Note cannot be empty.',true);return;}try{awaitjsonRequest(`/api/v1/businesses/${selectedId}/notes`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});message('noteMessage','Note added.');awaitloadDetail(selectedId);}catch(e){if(e.message!=='unauthorized')message('noteMessage',e.message,true);}}
asyncfunctionsaveStage(form){conststage=newFormData(form).get('stage');if(!stage){message('pipelineMessage','Choose a pipeline stage.',true);return;}try{awaitjsonRequest(`/api/v1/businesses/${selectedId}/pipeline`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({stage})});message('pipelineMessage','Pipeline stage updated.');awaitloadDetail(selectedId);awaitloadData();}catch(e){if(e.message!=='unauthorized')message('pipelineMessage',e.message,true);}}
asyncfunctionaddProspect(event){event.preventDefault();constdata=Object.fromEntries(newFormData(event.currentTarget).entries());constmsg=$('formMessage');try{constbody=awaitjsonRequest('/api/v1/businesses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});prospects.unshift(body);msg.textContent='Added to review queue.';event.currentTarget.reset();renderMetrics(null);renderRows();}catch(e){if(e.message!=='unauthorized'){msg.textContent=e.message;msg.className='form-message error';}}}
asyncfunctionsaveCrmStage(id){constitem=crmPipelineItems.find(p=>String(p.id)===String(id)),select=$(`crm-stage-${id}`);if(!item||!select)return;constnext=select.value;if(!crmStages.includes(next)){select.value=crmStage(item);crmMessage('Choose a valid pipeline stage.',true);return;}if(next===crmStage(item))return;if(statusOf(item)==='suppressed'){window.alert('Suppressed prospects remain unavailable for contact. Remove the suppression rule first if policy allows.');select.value=crmStage(item);return;}if(!window.confirm(`Move ${item.name||`Prospect ${id}`} to ${crmLabel(next)}? This records an internal stage transition only; no outreach will be sent.`)){select.value=crmStage(item);return;}try{awaitjsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}/pipeline`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({stage:next,reason:'CRM operator transition',outreach:false})});crmMessage('Stage transition saved.');awaitPromise.all([loadCrmPipeline(),loadData()]);}catch(error){if(error.message!=='unauthorized')crmMessage(error.message,true);select.value=crmStage(item);}}
asyncfunctionsaveInteraction(event){event.preventDefault();if(!selectedId){message('interactionMessage','Select a prospect in the pipeline first.',true);return;}constform=event.currentTarget,data=Object.fromEntries(newFormData(form).entries());if(!data.body.trim()){message('interactionMessage','Add an internal body before saving.',true);return;}constbutton=form.querySelector('button[type="submit"]');button.disabled=true;try{awaitjsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/interactions`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:data.kind,body:data.body,outcome:data.outcome||'other',follow_up_at:data.follow_up_at||null,outreach:false})});message('interactionMessage','Interaction saved. No message was sent.');form.reset();awaitloadInteractions(selectedId);}catch(error){if(error.message!=='unauthorized')message('interactionMessage',error.message,true);}finally{button.disabled=false;}}
asyncfunctionaddSuppression(event){event.preventDefault();constform=event.currentTarget,data=Object.fromEntries(newFormData(form).entries());if(!data.value.trim()){message('suppressionMessage','A suppression value is required.',true);return;}if(!window.confirm(`Add ${data.kind} suppression for ${data.value.trim()}? This is a do-not-contact rule.`))return;try{awaitjsonRequest('/api/v1/suppressions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...data,value:data.value.trim()})});message('suppressionMessage','Suppression added. Matching records are now unavailable for outreach.');form.reset();awaitPromise.all([loadSuppressions(),loadData()]);}catch(error){if(error.message!=='unauthorized')message('suppressionMessage',error.message,true);}}
asyncfunctionremoveSuppression(id){if(!window.confirm('Remove this suppression rule? Confirm only if policy allows contact eligibility to be reconsidered.'))return;try{awaitjsonRequest(`/api/v1/suppressions/${encodeURIComponent(id)}`,{method:'DELETE'});selectedSuppressionIds.delete(String(id));awaitloadSuppressions();}catch(error){if(error.message!=='unauthorized')message('suppressionMessage',error.message,true);}}
asyncfunctionbulkReviewSuppressions(){constids=[...selectedSuppressionIds];if(!ids.length)return;if(!window.confirm(`Review ${ids.length} suppression rule${ids.length===1?'':'s'}? This will not remove or contact any record.`))return;try{awaitjsonRequest('/api/v1/suppressions/bulk-review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({suppression_ids:ids,review_only:true,outreach:false})});message('suppressionMessage','Suppression review recorded; no rules were changed.');}catch(error){if(error.message!=='unauthorized')message('suppressionMessage',error.message,true);}}
functionrenderSourceSelect(){constmulti=$('directDiscoverySources'),enabled=sources.filter(sourceState);if(multi)multi.innerHTML=enabled.map(s=>`<option value="${esc(s.id)}">${esc(sourceLabel(s))}</option>`).join('')||'<option value="" disabled>No enabled sources are available</option>';consthelp=$('directDiscoverySourceHelp');if(help)help.textContent=enabled.length?'Select the approved sources to use for this run.':'No enabled sources are available. Configure, test, and enable a source first.';}
functionrenderSourceRecords(items){constlist=$('sourceRecordsList');list.innerHTML=items.length?`<div class="source-record-table"><table><tbody>${items.slice(0,25).map(record=>`<tr><td>${esc(record.name||record.title||record.id||'Unnamed record')}</td><td>${esc(record.source_name||record.source||'Unknown source')}</td><td>${esc(record.status||'Pending')}</td></tr>`).join('')}</tbody></table></div>`:'<div class="source-empty">No source records returned by the workspace.</div>';}
asyncfunctionloadSources(){sourceMessage('Loading sources…');try{const[sourcePayload,adapterPayload,recordPayload]=awaitPromise.all([jsonRequest('/api/v1/sources'),jsonRequest('/api/v1/sources/adapters'),jsonRequest('/api/v1/source-records?page_size=25')]);constconfigured=sourceItems(sourcePayload),registeredCodes=newSet(configured.map(sourceType)),optional=sourceItems(adapterPayload).filter(a=>!registeredCodes.has(a.source_code)).map(a=>({...a,kind:a.source_code,optional:true,configured:false,available:Boolean(a.available),enabled:false}));sources=[...configured,...optional];renderSources();renderSourceRecords(sourceItems(recordPayload));$('sourcesUpdatedAt').textContent=`Updated ${newDate().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`;sourceMessage(sources.some(sourceState)?'':'No enabled source. Configure, test, and enable a source before discovery.');}catch(error){sources=[];renderSources();renderSourceRecords([]);if(error.message!=='unauthorized')sourceMessage(error.message||'Unable to load source registry.',true);}}
asyncfunctionsaveSource(event){event.preventDefault();constform=event.currentTarget,fields=Object.fromEntries(newFormData(form).entries());if(fields.source_type==='csv'&&!fields.csv_content.trim()){message('sourceFormMessage','CSV content is required for a CSV source.',true);return;}letproviderSettings={};try{providerSettings=fields.provider_settings.trim()?JSON.parse(fields.provider_settings):{};}catch{message('sourceFormMessage','Provider settings must be valid JSON.',true);return;}setSourceButtons(true,'Saving source configuration…');message('sourceFormMessage','Saving source configuration…');constrateLimit=parseInt(fields.rate_limit,10);if(isNaN(rateLimit)||rateLimit<1||rateLimit>12){message('sourceFormMessage','Rate limit must be a number from 1 to 12.',true);return;}constconfig={owner:fields.owner,terms_url:fields.terms_url,terms_status:fields.terms_status,rate_limit:rateLimit,approved:true,public_access:true,daily_quota:Number(fields.daily_quota),provider_settings:fields.provider_settings};if(fields.source_type==='csv')config.csv=fields.csv_content;elseconfig.rows=[];constkind=fields.source_type==='provider'?(providerSettings.provider||''):fields.source_type;if(!kind){message('sourceFormMessage','Select a provider to register.',true);return;}try{constsaved=awaitjsonRequest('/api/v1/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:fields.name,kind,config,policy:{owner:fields.owner,terms_url:fields.terms_url,terms_status:fields.terms_status,rate_limit:rateLimit},quota:{daily_quota:Number(fields.daily_quota)},enabled:false})});selectedSourceId=saved.id||saved.source?.id||null;message('sourceFormMessage','Source configuration saved. It remains disabled until a successful test and explicit enablement.');awaitloadSources();}catch(error){if(error.message!=='unauthorized')message('sourceFormMessage',error.message||'Unable to save source configuration.',true);}finally{setSourceButtons(false);}}
constcontrols='<div class="discovery-controls"><button class="button danger compact" type="button" data-run-action="cancel" '+(!['queued','running'].includes(status)?'disabled title="Cancel is available only while this run is queued or running."':'')+'>Cancel</button><button class="button ghost compact" type="button" data-run-action="retry" '+(status!=='failed'?'disabled title="Retry is available only after a failed job."':'')+'>Retry</button><span class="small-label">Cancel and retry use available job routes only.</span></div>';
el.innerHTML=controls+(partial?'<div class="discovery-partial" role="status"><strong>Partial results</strong><p>Review provenance before using any candidate.</p></div>':'')+(status==='failed'?'<div class="detail-error" role="alert"><strong>Discovery failed</strong><p>'+esc(job?.error?.message||job?.message||run.error||'The job returned no safe result.')+'</p></div>':'')+stats+health+'<div class="discovery-two-col"><section class="live-log"><h4>Run log</h4><ol>'+log+'</ol></section><section><h4>Results</h4><div class="discovery-candidate-list">'+cards+'</div></section></div>';
$('directDiscoveryResultState').innerHTML=lines+'<p>'+esc(item.name||'Candidate')+' · assessment complete. Review the evidence before any action.</p>';
}catch(error){
if(error.message!=='unauthorized')message('directDiscoveryMessage',error.message||'Unable to complete AI analysis.',true);
}
}
asyncfunctiondiscoveryAction(action,run){if(!run?.id)return;constpath=action==='cancel'?`/api/v1/discovery-runs/${encodeURIComponent(run.id)}/cancel`:action==='retry'&&run.job_id?`/api/v1/jobs/${encodeURIComponent(run.job_id)}/retry`:null;if(!path){message('directDiscoveryMessage','This action is not available for the selected run.',true);return;}message('directDiscoveryMessage',action==='cancel'?'Cancelling discovery run…':'Retrying failed discovery job…');try{awaitjsonRequest(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});message('directDiscoveryMessage',action==='cancel'?'Discovery run cancelled.':'Discovery job retry accepted.');awaitloadDiscoveryRuns();}catch(error){if(error.message!=='unauthorized')message('directDiscoveryMessage',error.message||`Unable to ${action} discovery run.`,true);}}
asyncfunctionsubmitDirectDiscovery(event){event.preventDefault();constform=event.currentTarget,button=$('directDiscoveryRunBtn'),fields=Object.fromEntries(newFormData(form).entries()),include=(fields.include_websites||'').split(/\r?\n|,/).map(v=>v.trim()).filter(Boolean),exclude=(fields.exclude_websites||'').split(/\r?\n|,/).map(v=>v.trim()).filter(Boolean),dryRun=$('directDiscoveryDryRun')?.checked;if(!fields.category.trim()||!fields.keywords.trim()){message('directDiscoveryMessage','Enter a category and keywords before starting discovery.',true);return;}if(!selectedSourceIds().length){message('directDiscoveryMessage','Select at least one enabled source before starting a discovery run.',true);return;}constcriteria={category:fields.category.trim(),keywords:fields.keywords.trim(),city:fields.city.trim(),province:fields.province.trim(),country:fields.country.trim(),language:fields.language.trim()};constpayload={criteria,source_ids:selectedSourceIds(),daily_limit:Number(fields.daily_limit),max_pages:Number(fields.max_pages),max_results:Number(fields.max_results),dry_run:Boolean(dryRun),website_analysis:{priorities:fields.priorities.trim(),include_websites:include,exclude_websites:exclude},idempotency_key:`discovery-${Date.now()}-${Math.random().toString(36).slice(2)}`};payload.schedule=fields.schedule||'run_now';if(dryRun){message('directDiscoveryMessage',`Dry run validated: ${payload.source_ids.length} enabled sources, ${payload.max_results} max results, schedule: ${payload.schedule}. No job created.`);return;}button.disabled=true;message('directDiscoveryMessage','Starting bounded discovery job…');try{constjob=awaitjsonRequest('/api/v1/discovery',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});message('directDiscoveryMessage','Discovery accepted. Tracking job status and partial results below.');awaitloadDiscoveryRuns();construn=discoveryRuns.find(item=>String(item.job_id)===String(job.id));if(run)loadDiscoveryRun(run);}catch(error){if(error.message!=='unauthorized')message('directDiscoveryMessage',error.message||'Unable to start discovery run.',true);}finally{button.disabled=false;}}
functionrenderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='<span>⊞</span><p>No data rows found</p>';return;}consth=Object.keys(rows[0]);$('csvPreview').className='csv-table';$('csvPreview').innerHTML=`<table><thead><tr>${h.map(x=>`<th>${esc(x)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${h.map(x=>`<td>${esc(r[x])}</td>`).join('')}</tr>`).join('')}</tbody></table><small class="muted">Showing up to 10 rows · Preview only; nothing added yet.</small>`;}
asyncfunctionlogin(event){event.preventDefault();constform=event.currentTarget,messageEl=$('loginMessage'),data=Object.fromEntries(newFormData(form).entries());messageEl.textContent='Signing in…';messageEl.className='form-message';try{constres=awaitfetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)});constbody=awaitres.json().catch(()=>({}));if(!res.ok)thrownewError(body.error||'Invalid email or password.');awaitbootstrap();}catch(e){if(e.message!=='unauthorized'){messageEl.textContent=e.message;messageEl.className='form-message error';}}}
asyncfunctionlogout(){try{awaitfetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}}
asyncfunctionbootstrap(){letres;try{res=awaitfetch(endpoint('/api/v1/auth/me'),{credentials:'include'});}catch(e){showLogin('Unable to connect to the workspace. Try again.');return;}if(res.status===401){showLogin();return;}if(!res.ok){showLogin('Unable to verify the workspace session. Try again.');return;}letuser;try{user=awaitres.json();}catch(e){showLogin('Unable to read the workspace session. Try again.');return;}showDashboard(user.user||user);constpanelLoads=[loadData(),loadJobs(),loadSources(),loadDiscoveryRuns(),loadScorePanels(),loadSavedFilters(),loadReviewQueue(),loadCrmPipeline(),loadReports(),loadSuppressions(),loadProviderPolicy(),loadAiProviderSettings()];constresults=awaitPromise.allSettled(panelLoads);constrejected=results.filter(result=>result.status==='rejected');if(rejected.length){console.error('Workspace dashboard panels failed',rejected);$('apiStatus').textContent='● Some panels need refresh';$('apiStatus').classList.remove('live');setExplorerState('Some workspace panels could not be loaded. Use Refresh to retry.',true);}}
$('interactionForm').addEventListener('submit',saveInteraction);$('suppressionForm').addEventListener('submit',addSuppression);$('aiProviderForm').addEventListener('submit',saveAiProviderSettings);$('aiProvider').addEventListener('change',updateAiProviderForm);$('aiProviderRefreshBtn').addEventListener('click',loadAiProviderSettings);$('testAiProviderBtn').addEventListener('click',testAiProviderConnection);$('crmRefreshBtn').addEventListener('click',()=>{loadCrmPipeline();loadInteractions(selectedId);});$('reportsRefreshBtn').addEventListener('click',loadReports);$('suppressionRefreshBtn').addEventListener('click',loadSuppressions);$('pipelineViewToggle').addEventListener('click',()=>{crmListMode=!crmListMode;$('pipelineViewToggle').textContent=crmListMode?'▦ Board view':'☷ List view';$('pipelineViewToggle').setAttribute('aria-pressed',String(crmListMode));renderPipeline();});$('pipelineBoard').addEventListener('click',e=>{constselectButton=e.target.closest?.('[data-crm-select]');if(selectButton){selectedId=Number(selectButton.dataset.crmSelect);selectProspect(selectedId);loadInteractions(selectedId);$('crmActivity').scrollIntoView({behavior:'smooth',block:'start'});}conststageButton=e.target.closest?.('[data-crm-save-stage]');if(stageButton)saveCrmStage(stageButton.dataset.crmSaveStage);if(e.target.id==='retryCrmBtn')loadCrmPipeline();if(e.target.id==='retryInteractionsBtn'&&selectedId)loadInteractions(selectedId);});$('suppressionState').addEventListener('change',e=>{constinput=e.target.closest?.('[data-suppression-id]');if(input){if(input.checked)selectedSuppressionIds.add(String(input.dataset.suppressionId));elseselectedSuppressionIds.delete(String(input.dataset.suppressionId));updateSuppressionSelection();}});$('suppressionState').addEventListener('click',e=>{constbutton=e.target.closest?.('[data-remove-suppression]');if(button)removeSuppression(button.dataset.removeSuppression);if(e.target.id==='retrySuppressionsBtn')loadSuppressions();});$('selectAllSuppressions').addEventListener('change',e=>{suppressions.forEach(item=>e.target.checked?selectedSuppressionIds.add(String(item.id)):selectedSuppressionIds.delete(String(item.id)));renderSuppressions();});$('bulkReviewSuppressionsBtn').addEventListener('click',bulkReviewSuppressions);$('discoveryWorkspace').addEventListener('click',e=>{constaction=e.target.closest?.('[data-run-action]');if(action){construn=discoveryRuns.find(item=>String(item.id)===String(selectedDiscoveryRunId));discoveryAction(action.dataset.runAction,run);}});
$('aiProviderForm').addEventListener('submit',saveAiProviderSettings);$('aiProvider').addEventListener('change',updateAiProviderForm);$('aiProviderRefreshBtn').addEventListener('click',loadAiProviderSettings);$('testAiProviderBtn').addEventListener('click',testAiProviderConnection);$('crmRefreshBtn').addEventListener('click',()=>{loadCrmPipeline();loadInteractions(selectedId);});$('reportsRefreshBtn').addEventListener('click',loadReports);$('suppressionRefreshBtn').addEventListener('click',loadSuppressions);$('pipelineViewToggle').addEventListener('click',()=>{crmListMode=!crmListMode;$('pipelineViewToggle').textContent=crmListMode?'▦ Board view':'☷ List view';$('pipelineViewToggle').setAttribute('aria-pressed',String(crmListMode));renderPipeline();});$('pipelineBoard').addEventListener('click',e=>{constselectButton=e.target.closest?.('[data-crm-select]');if(selectButton){selectedId=Number(selectButton.dataset.crmSelect);selectProspect(selectedId);loadInteractions(selectedId);$('crmActivity').scrollIntoView({behavior:'smooth',block:'start'});}conststageButton=e.target.closest?.('[data-crm-save-stage]');if(stageButton)saveCrmStage(stageButton.dataset.crmSaveStage);if(e.target.id==='retryCrmBtn')loadCrmPipeline();if(e.target.id==='retryInteractionsBtn'&&selectedId)loadInteractions(selectedId);});$('suppressionState').addEventListener('change',e=>{constinput=e.target.closest?.('[data-suppression-id]');if(input){if(input.checked)selectedSuppressionIds.add(String(input.dataset.suppressionId));elseselectedSuppressionIds.delete(String(input.dataset.suppressionId));updateSuppressionSelection();}});$('suppressionState').addEventListener('click',e=>{constbutton=e.target.closest?.('[data-remove-suppression]');if(button)removeSuppression(button.dataset.removeSuppression);if(e.target.id==='retrySuppressionsBtn')loadSuppressions();});$('selectAllSuppressions').addEventListener('change',e=>{suppressions.forEach(item=>e.target.checked?selectedSuppressionIds.add(String(item.id)):selectedSuppressionIds.delete(String(item.id)));renderSuppressions();});$('bulkReviewSuppressionsBtn').addEventListener('click',bulkReviewSuppressions);$('outreachPolicyRefreshBtn').addEventListener('click',loadProviderPolicy);
document.querySelectorAll('.sidebar nav a, [data-scroll]').forEach(link=>link.addEventListener('click',event=>{consttarget=(link.getAttribute('href')||link.dataset.scroll||'').replace(/^#/,'');constview=viewMap[target];if(view){event.preventDefault();activateView(view);document.querySelector('.sidebar')?.classList.remove('open');}}));