334 lines
135 KiB
JavaScript
334 lines
135 KiB
JavaScript
/* 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. */
|
||
(() => {
|
||
'use strict';
|
||
const publicConfig = window.__PROSPECT_CONFIG__ || {};
|
||
const API_BASE = (publicConfig.apiBase ?? window.API_BASE ?? '').replace(/\/$/, '');
|
||
const endpoint = (path) => `${API_BASE}${path}`;
|
||
let prospects = [], selectedId = null, selectedDetail = null, currentUser = null;
|
||
let page = 1, pageSize = 10, hasNextPage = false, savedFilters = [], reviewQueue = [], selectedReviewIds = new Set();
|
||
const filterKeys = ['q','score','status','website_class','pipeline_stage','freshness','source','geography','category','contact_status'];
|
||
const reviewItems = payload => payloadItems(payload, ['items','businesses','prospects','queue']);
|
||
const savedItems = payload => payloadItems(payload, ['items','saved_filters','filters','views']);
|
||
const apiError = error => error?.message || 'Request failed.';
|
||
function setExplorerState(text='', error=false){const el=$('explorerState');if(el){el.textContent=text;el.className=`explorer-state${error?' error':''}`;}}
|
||
function setSavedMessage(text,error=false){const el=$('savedFilterMessage');if(el){el.textContent=text;el.className=`form-message${error?' error':''}`;}}
|
||
function currentFilters(){return filterValues();}
|
||
function applyFilters(filters={}){filterKeys.forEach(key=>{const id={q:'searchInput',score:'scoreFilter',status:'statusFilter',website_class:'websiteClassFilter',pipeline_stage:'pipelineFilter',source:'sourceFilter',geography:'geographyFilter',category:'categoryFilter',contact_status:'contactStatusFilter'}[key];if(filters[key]!==undefined&&$(id))$(id).value=filters[key];});if($('explorerState'))$('explorerState').dataset.filter=filters.freshness||'all';page=1;loadData();}
|
||
function selectedReviewProspects(){return [...selectedReviewIds].filter(id=>reviewQueue.some(p=>String(p.id)===String(id)));}
|
||
function updateBulkState(){const count=selectedReviewProspects().length; if($('selectedReviewCount'))$('selectedReviewCount').textContent=`${count} selected`;['bulkVerifyBtn','bulkRejectBtn'].forEach(id=>{if($(id))$(id).disabled=!count;});if($('selectAllReview'))$('selectAllReview').checked=Boolean(reviewQueue.length&&count===reviewQueue.length);}
|
||
function renderReviewQueue(payload){reviewQueue=reviewItems(payload).slice(0,100);$('reviewQueueCount').textContent=Number(payload?.count??payload?.total??reviewQueue.length);const state=$('reviewQueueState');if(!state)return;if(!reviewQueue.length){state.innerHTML='<p class="muted">No prospects currently require human review.</p>';updateBulkState();return;}state.innerHTML=`<div class="queue-list">${reviewQueue.map(p=>`<label class="queue-row"><input type="checkbox" data-review-id="${esc(p.id)}" ${selectedReviewIds.has(String(p.id))?'checked':''}><span><strong>${esc(p.name||`Prospect ${p.id}`)}</strong><small>${esc(p.website_domain||p.location||'Evidence pending')}</small></span><span class="status review">Needs review</span></label>`).join('')}</div><p class="queue-boundary">Showing up to 100 queued prospects. Bulk actions apply only to selected visible records.</p>`;updateBulkState();}
|
||
async function loadReviewQueue(){const state=$('reviewQueueState');if(state)state.innerHTML='<div class="detail-loading" aria-live="polite">Loading review queue…</div>';try{const payload=await jsonRequest('/api/v1/review-queue?page=1&page_size=100');renderReviewQueue(payload);}catch(error){if(error.message!=='unauthorized'&&state)state.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load review queue</strong><p>${esc(apiError(error))}</p><button class="button ghost compact" id="retryReviewQueueBtn" type="button">Try again</button></div>`;}}
|
||
function renderSavedFilters(){const select=$('savedFilterSelect');if(!select)return;select.innerHTML='<option value="">Saved views</option>'+savedFilters.map(view=>`<option value="${esc(view.id)}">${esc(view.name||`View ${view.id}`)}</option>`).join('');$('deleteSavedFilterBtn').disabled=true;}
|
||
async function loadSavedFilters(){try{savedFilters=savedItems(await jsonRequest('/api/v1/saved-filters'));renderSavedFilters();}catch(error){if(error.message!=='unauthorized')setSavedMessage(`Unable to load saved views: ${apiError(error)}`,true);}}
|
||
async function saveCurrentFilters(event){event.preventDefault();const form=event.currentTarget,name=$('savedFilterName').value.trim();if(!name){setSavedMessage('Enter a name for this saved view.',true);return;}try{await jsonRequest('/api/v1/saved-filters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,filters:currentFilters()})});form.reset();setSavedMessage('Saved view created.');await loadSavedFilters();}catch(error){if(error.message!=='unauthorized')setSavedMessage(apiError(error),true);}}
|
||
async function loadSavedFilter(id){const view=savedFilters.find(item=>String(item.id)===String(id));if(view)applyFilters(view.filters||view.filter||view.filters_json||{});}
|
||
async function deleteSavedFilter(){const id=$('savedFilterSelect').value;if(!id||!window.confirm('Delete this saved view?'))return;try{await jsonRequest(`/api/v1/saved-filters/${encodeURIComponent(id)}`,{method:'DELETE'});setSavedMessage('Saved view deleted.');await loadSavedFilters();}catch(error){if(error.message!=='unauthorized')setSavedMessage(apiError(error),true);}}
|
||
async function bulkReview(action){const ids=selectedReviewProspects().map(Number);if(!ids.length)return;const label=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;const button=$(action==='verify'?'bulkVerifyBtn':'bulkRejectBtn');button.disabled=true;try{await jsonRequest('/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.`);await Promise.all([loadData(),loadReviewQueue()]);}catch(error){if(error.message!=='unauthorized')setExplorerState(apiError(error),true);}finally{updateBulkState();}}
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const scoreClass = (score) => score >= 80 ? 'high' : score >= 60 ? 'medium' : 'low';
|
||
const statusOf = (p) => p.suppressed || p.status === 'suppressed' ? 'suppressed' : (p.verified || p.reviewed || p.status === 'reviewed' || p.reviewed_at ? 'reviewed' : 'review');
|
||
const freshness = (p) => { const raw=p.updated_at||p.last_checked_at||p.created_at; if(!raw)return {label:'Unknown',cls:'stale'}; const days=Math.max(0,Math.floor((Date.now()-new Date(raw).getTime())/86400000)); return {label:days===0?'Today':`${days}d ago`,cls:days<=7?'good':'stale'}; };
|
||
const scoreFor = (p) => Number.isFinite(Number(p.score)) ? Number(p.score) : (p.score_factors||p.factors||[]).reduce((n,f)=>n+({named_business:20,business_site:30,email:25,phone:15,description:10}[f]||0),0);
|
||
const labelFactor = (f) => ({named_business:'Named business',business_site:'Business site',email:'Email found',phone:'Phone found',description:'Description'}[f] || String(f).replaceAll('_',' '));
|
||
async function request(path, options = {}) { const response=await fetch(endpoint(path),{...options,credentials:'include'}); if(response.status===401){showLogin('Your session has expired. Please sign in again.');throw new Error('unauthorized');} if(response.status===403)throw new Error('Tenant/workspace access denied.'); return response; }
|
||
async function jsonRequest(path, options = {}) { const res=await request(path,options); const body=await res.json().catch(()=>({})); if(!res.ok)throw new Error(body.error||body.message||'Request failed'); return body; }
|
||
function showLogin(message=''){currentUser=null;$('dashboardShell').hidden=true;$('loginScreen').hidden=false;$('loginMessage').textContent=message;$('loginMessage').className=`form-message${message?' error':''}`;}
|
||
function showDashboard(user){currentUser=user||{};const name=currentUser.display_name||currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userGreetingName').textContent=name.split(/\s+/)[0]||'there';$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;updateJobPermissions();}
|
||
function renderMetrics(summary){const total=Number(summary?.businesses??summary?.total??prospects.length),high=Number(summary?.high_fit??summary?.high_fit_count??prospects.filter(p=>scoreFor(p)>=80).length),review=Number(summary?.needs_review??summary?.review_count??prospects.filter(p=>statusOf(p)==='review').length),suppressed=Number(summary?.suppressed??summary?.suppressed_count??prospects.filter(p=>statusOf(p)==='suppressed').length),fresh=prospects.length?Math.round(prospects.filter(p=>freshness(p).cls==='good').length/prospects.length*100):0;$('metricTotal').textContent=total;$('heroCount').textContent=`${total} prospects`;$('metricReview').textContent=review;$('metricHigh').textContent=high;$('metricFresh').textContent=`${summary?.freshness_under_7d??summary?.fresh_count??fresh}%`;if($('metricSuppressed'))$('metricSuppressed').textContent=suppressed;}
|
||
function filterValues(){return {q:$('searchInput').value.trim(),score:$('scoreFilter').value,status:$('statusFilter').value,website_class:$('websiteClassFilter').value,pipeline_stage:$('pipelineFilter').value,source:$('sourceFilter')?.value||'all',geography:$('geographyFilter')?.value||'all',category:$('categoryFilter')?.value||'all',contact_status:$('contactStatusFilter')?.value||'all',freshness:$('explorerState')?.dataset?.filter||'all'};}
|
||
function filtered(){const {q,score:sf,status:st,website_class:wc,pipeline_stage:ps,source,geography,category,contact_status,freshness}=filterValues();return prospects.filter(p=>{const s=scoreFor(p),text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(),stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||p.contact_extractions||[], pSource=String(p.source_name||p.source||p.source_id||'all'), geo=String(p.city||p.province||p.country||p.location||'all'), cat=String(p.category||p.industry||p.categories?.[0]||'all'), contactState=contacts.length||p.email||p.phone?'present':statusOf(p)==='suppressed'?'suppressed':'missing';return(!q||text.includes(q.toLowerCase()))&&(source==='all'||pSource===source||String(p.source_id)===source)&&(geography==='all'||geo.toLowerCase().includes(geography.toLowerCase()))&&(category==='all'||cat===category)&&(contact_status==='all'||contactState===contact_status)&&(sf==='all'||(sf==='high'&&s>=80)||(sf==='medium'&&s>=60&&s<80)||(sf==='low'&&s<60))&&(st==='all'||statusOf(p)===st)&&(wc==='all'||(p.website_class||'missing')===wc)&&(ps==='all'||stage===ps)&&(freshness!=='under_7d'||freshnessValue(p));});}
|
||
const freshnessValue=p=>freshness(p).cls==='good';
|
||
function renderRows(){const rows=filtered();$('resultCount').textContent=`Showing ${rows.length} prospect${rows.length===1?'':'s'} · page ${page}`;$('prospectRows').innerHTML=rows.length?rows.map(p=>{const s=scoreFor(p),f=freshness(p),st=statusOf(p),factors=p.score_factors||p.factors||[];return `<tr data-id="${esc(p.id)}" class="${Number(p.id)===Number(selectedId)?'selected':''}"><td>${esc(p.name)}<span class="company-sub">${esc(p.website_domain||'no detected website')}</span></td><td><span class="score ${scoreClass(s)}">${s} <small>/ 100</small></span></td><td class="evidence"><strong>${factors.length} signal${factors.length===1?'':'s'}</strong>${esc(factors.slice(0,2).map(labelFactor).join(' · ')||'Limited evidence')}</td><td><span class="fresh ${f.cls}">${f.label}</span></td><td><span class="status ${st}">${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}</span></td><td class="row-arrow">›</td></tr>`;}).join(''):'<tr><td colspan="6" class="muted">No prospects match these filters.</td></tr>';$('nextPageBtn').disabled=!hasNextPage;document.querySelectorAll('#prospectRows tr[data-id]').forEach(row=>row.addEventListener('click',()=>selectProspect(row.dataset.id)));}
|
||
function listQuery(){const f=filterValues(),params=new URLSearchParams({page:String(page),page_size:String(pageSize)});if(f.q)params.set('q',f.q);if(f.score!=='all')params.set('score_band',f.score);if(f.status!=='all')params.set('status',f.status);if(f.website_class!=='all')params.set('website_class',f.website_class);if(f.pipeline_stage!=='all')params.set('pipeline_stage',f.pipeline_stage);if(f.source!=='all')params.set('source',f.source);if(f.geography!=='all')params.set('geography',f.geography);if(f.category!=='all')params.set('category',f.category);if(f.contact_status!=='all')params.set('contact_status',f.contact_status);if(f.freshness!=='all')params.set('freshness',f.freshness);return `?${params}`;}
|
||
function populateProspectFilters(){const values={source:new Set(),geography:new Set(),category:new Set()};prospects.forEach(p=>{values.source.add(String(p.source_name||p.source||p.source_id||''));values.geography.add(String(p.city||p.province||p.country||p.location||''));values.category.add(String(p.category||p.industry||p.categories?.[0]||''));});[['sourceFilter',values.source,'All sources'],['geographyFilter',values.geography,'All geographies'],['categoryFilter',values.category,'All categories']].forEach(([id,set,placeholder])=>{const el=$(id),current=el?.value||'all';if(!el)return;el.innerHTML='<option value="all">'+placeholder+'</option>'+[...set].filter(Boolean).sort().map(value=>'<option value="'+esc(value)+'">'+esc(value)+'</option>').join('');if([...el.options].some(option=>option.value===current))el.value=current;});}
|
||
async function loadData(){ $('apiStatus').textContent='● Connecting…';$('apiStatus').classList.remove('live');setExplorerState('Loading prospects…');try{const [listRes,summaryRes]=await Promise.all([request(`/api/v1/businesses${listQuery()}`),request('/api/v1/dashboard/summary')]);if(!listRes.ok||!summaryRes.ok)throw new Error('API request failed');const list=await listRes.json(),summary=await summaryRes.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);}
|
||
async function selectProspect(id){selectedId=Number(id);selectedDetail=null;renderRows();$('detailPanel').innerHTML='<div class="detail-loading" aria-live="polite">Loading prospect detail…</div>';await loadDetail(selectedId);}
|
||
async function loadDetail(id){try{const detail=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}`);selectedDetail=detail;const index=prospects.findIndex(p=>Number(p.id)===Number(id));if(index>=0)prospects[index]={...prospects[index],...detail};renderDetail(detail);}catch(error){if(error.message!=='unauthorized')$('detailPanel').innerHTML=`<div class="detail-error" role="alert"><h3>Unable to load detail</h3><p>${esc(error.message)}</p><button class="button ghost" id="retryDetailBtn" type="button">Try again</button></div>`;}}
|
||
const listItems=(items,empty,label)=>Array.isArray(items)&&items.length?`<ul class="detail-list">${items.map(item=>`<li>${esc(typeof item==='string'?item:item[label]||item.value||item.name||JSON.stringify(item))}</li>`).join('')}</ul>`:`<p class="muted">${empty}</p>`;
|
||
const domainValue = item => typeof item === 'string' ? item : item?.domain || item?.hostname || item?.name || item?.value || '';
|
||
const domainForProspect = p => p?.website_domain || (Array.isArray(p?.domains) ? domainValue(p.domains[0]) : '') || '';
|
||
const domainState = value => { const state=String(value ?? '').toLowerCase().replaceAll('_','-'); return state==='nxdomain'?'NXDOMAIN':state==='error'?'Error':state==='not-configured'?'Not configured':state==='unknown'||!state?'Unknown':state.replaceAll('-',' '); };
|
||
const capabilityState = value => { if (value === true || ['present','found','yes','true','ok'].includes(String(value).toLowerCase())) return 'Present'; if (value === false || ['absent','not-found','no','false'].includes(String(value).toLowerCase())) return 'Not found'; return domainState(value); };
|
||
const checkedTime = item => item?.checked_at || item?.checkedAt || item?.last_checked_at || item?.checked_time || 'Time unavailable';
|
||
const cacheState = item => item?.cache_hit === true ? 'Hit' : item?.cache_state || item?.cacheStatus || item?.cache || (item?.cache_hit === false ? 'Miss' : 'Unknown');
|
||
const confidenceText = item => item?.confidence ?? item?.score ?? 'Unknown';
|
||
const reasonsText = item => { const reasons=item?.reasons || item?.reason || item?.explanation || []; return Array.isArray(reasons) ? reasons : [reasons]; };
|
||
function renderDomainPanel(p){
|
||
const panel=$('domainIntelligencePanel'); if(!panel)return;
|
||
panel.innerHTML=`<div class="domain-panel-heading"><div><p class="eyebrow">DOMAIN INTELLIGENCE</p><h4>DNS checks</h4></div><button class="button ghost compact" id="runDomainCheckBtn" type="button">↻ Check DNS</button></div><p class="domain-safety">DNS results are evidence only. Unknown, error, and NXDOMAIN states are never treated as ownership or availability.</p><div id="domainCheckState" class="domain-check-state"><div class="detail-loading" aria-live="polite">Loading DNS results…</div></div><div class="candidate-domain-panel" id="candidateDomainPanel"><div class="detail-loading" aria-live="polite">Loading candidate domains…</div></div>`;
|
||
loadDomainIntelligence(p.id);
|
||
}
|
||
function renderDomainCheck(result){
|
||
const el=$('domainCheckState'); if(!el)return;
|
||
if(!result){el.innerHTML='<p class="muted">No DNS check has been returned. Run a check to see evidence.</p>';return;}
|
||
const dns=result.dns || result.dns_state || result.state || result.status || 'unknown', records=result.records || result.capabilities || result;
|
||
const capability=(key, aliases=[])=>{for(const k of [key,...aliases])if(records?.[k]!==undefined)return records[k];return undefined;};
|
||
el.innerHTML=`<div class="domain-check-meta"><span class="domain-state ${String(dns).toLowerCase().replaceAll('_','-')}">${esc(domainState(dns))}</span><span>Checked ${esc(checkedTime(result))}</span><span>Cache: ${esc(domainState(cacheState(result)))}</span></div><dl class="dns-capabilities">${['A','AAAA','MX','NS','TXT'].map(type=>`<div><dt>${type}</dt><dd>${esc(capabilityState(capability(type,type.toLowerCase())))}</dd></div>`).join('')}</dl>${result.error||result.error_code||result.message?`<p class="domain-error" role="alert">${esc(result.error||result.error_code||result.message)}</p>`:''}`;
|
||
}
|
||
function renderCandidates(payload){
|
||
const panel=$('candidateDomainPanel'); if(!panel)return;
|
||
const candidates=payloadItems(payload,['candidates','candidate_domains','domains','items']);
|
||
panel.innerHTML=`<div class="domain-panel-heading"><div><p class="eyebrow">CANDIDATE DOMAINS</p><h4>Possible matches <span class="count">${candidates.length}</span></h4></div><span class="small-label">Human review required</span></div>${candidates.length?`<div class="candidate-list">${candidates.map((item,index)=>{const domain=domainValue(item);return `<article class="candidate-card" data-candidate-index="${index}"><div class="candidate-head"><strong>${esc(domain||'Unknown domain')}</strong><span class="match-confidence">${esc(confidenceText(item))} confidence</span></div><ul class="match-reasons">${reasonsText(item).map(reason=>`<li>${esc(typeof reason==='string'?reason:reason?.text||reason?.description||JSON.stringify(reason))}</li>`).join('')||'<li>Reason unavailable</li>'}</ul><button class="button ghost compact" type="button" data-domain-availability="${esc(domain)}">Check availability</button><p class="availability-result" aria-live="polite"></p></article>`;}).join('')}</div>`:'<p class="muted">No candidate domains returned.</p>'}<p class="domain-safety compact-safety">Availability is not configured here. This action will never claim a domain is available or owned.</p>`;
|
||
}
|
||
async function loadDomainIntelligence(id){
|
||
const check=$('domainCheckState'), candidates=$('candidateDomainPanel');
|
||
try{const [checkPayload,candidatePayload]=await Promise.all([jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}/domains/check?domain=${encodeURIComponent(domainForProspect(selectedDetail || prospects.find(item=>Number(item.id)===Number(id)) || {}))}`),jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}/domain-candidates`)]);renderDomainCheck(checkPayload);renderCandidates(candidatePayload);}catch(error){if(error.message!=='unauthorized'){if(check)check.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load DNS results</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryDomainBtn" type="button">Try again</button></div>`;if(candidates)candidates.innerHTML='<p class="muted">Candidate domains unavailable.</p>';}}
|
||
}
|
||
async function runDomainCheck(){const button=$('runDomainCheckBtn');if(!selectedId||!button)return;button.disabled=true;const state=$('domainCheckState');if(state)state.innerHTML='<div class="detail-loading" aria-live="polite">Checking DNS…</div>';try{const result=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/domains/check`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domain:domainForProspect(selectedDetail||{})})});renderDomainCheck(result);}catch(error){if(error.message!=='unauthorized'&&state)state.innerHTML=`<div class="detail-error" role="alert"><strong>DNS check failed</strong><p>${esc(error.message)}</p></div>`;}finally{button.disabled=false;}}
|
||
async function checkDomainAvailability(domain,button){const result=button.closest('.candidate-card')?.querySelector('.availability-result');if(!domain||!result)return;button.disabled=true;result.textContent='Checking availability…';try{const body=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/domain-candidates/check-availability`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({domain})});const state=body?.status||body?.state||body?.availability||'unknown';result.textContent=['not-configured','unknown','error'].includes(String(state).toLowerCase())?`Availability: ${domainState(state)}. No ownership conclusion.`:'Availability: Unknown. No ownership conclusion.';}catch(error){if(error.message!=='unauthorized')result.textContent=`Availability: Unknown. ${error.message}`;}finally{button.disabled=false;}}
|
||
const websiteScanValue = (scan, keys, fallback='Unknown') => { for (const key of keys) if (scan?.[key] !== undefined && scan[key] !== null && scan[key] !== '') return scan[key]; return fallback; };
|
||
const websiteStateLabel = value => { const state=String(value ?? 'unknown').toLowerCase().replaceAll('_','-'); return ({queued:'Queued',running:'Running',succeeded:'Complete',completed:'Complete',healthy:'Healthy',broken:'Broken','under-construction':'Under construction',parked:'Parked',placeholder:'Placeholder','redirect-only':'Redirect only',blocked:'Blocked',unknown:'Unknown',error:'Error'})[state] || state.replaceAll('-',' ').replace(/\b\w/g,c=>c.toUpperCase()); };
|
||
const signalLabel = (value, positive='Present') => value === true ? positive : value === false ? 'Not found' : websiteStateLabel(value);
|
||
const websiteScanFor = p => p?.website_scan || p?.websiteScan || p?.scan || null;
|
||
const websiteUrlFor = p => p?.website || p?.website_url || p?.url || (domainForProspect(p) ? `https://${domainForProspect(p)}` : '');
|
||
function renderWebsiteScanPanel(p){
|
||
const panel=$('websiteScanPanel'); if(!panel)return;
|
||
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>`;
|
||
loadWebsiteScan(p.id);
|
||
}
|
||
function renderWebsiteScan(scan){
|
||
const status=websiteStateLabel(websiteScanValue(scan,['scan_status','status','state'])), classification=websiteStateLabel(websiteScanValue(scan,['classification','class'],'unknown')), http=websiteScanValue(scan,['http_status','status_code','status'], 'Unknown'), finalUrl=websiteScanValue(scan,['final_url','url'],'Unknown'), chain=websiteScanValue(scan,['redirect_chain','redirects'],[]), tls=scan?.tls===true||scan?.https===true||scan?.certificate_status==='valid'?'Secure':scan?.tls===false||scan?.https===false?'Not secure':websiteStateLabel(scan?.certificate_status||'Unknown');
|
||
const contact=[['Contact page',scan?.contact_page_signal],['Email',scan?.mail_signal],['Phone',scan?.phone_signal],['WhatsApp',scan?.whatsapp_signal],['Social',scan?.social_signal],['Form',scan?.form_signal]];
|
||
const value=v=>Array.isArray(v)?(v.length?v.join(' → '):'None'):v;
|
||
const error=scan?.error||scan?.error_code||scan?.message;
|
||
return `<div class="website-scan-meta"><span><b>Scan status</b><span class="website-status ${String(status).toLowerCase().replaceAll(' ','-')}">${esc(status)}</span></span><span>Checked ${esc(websiteScanValue(scan,['checked_at','last_checked_at','checked_time']))}</span><span>Cache: ${esc(websiteStateLabel(websiteScanValue(scan,['cache_state','cache_status','cache'], 'Unknown')))}</span></div>${error?`<div class="website-scan-error" role="alert"><strong>${esc(websiteStateLabel(scan.error_code||'Error'))}</strong><p>${esc(error)}</p></div>`:''}<div class="website-scan-grid"><div><dt>HTTP status</dt><dd>${esc(http)}</dd></div><div><dt>Final URL</dt><dd class="breakable">${esc(finalUrl)}</dd></div><div><dt>Redirect chain</dt><dd class="breakable">${esc(value(chain)||'None')}</dd></div><div><dt>Classification</dt><dd>${esc(classification)}</dd></div><div><dt>Title</dt><dd>${esc(websiteScanValue(scan,['title']))}</dd></div><div><dt>Meta description</dt><dd>${esc(websiteScanValue(scan,['meta_description','description']))}</dd></div><div><dt>Viewport</dt><dd>${esc(scan?.responsive_signal===true?'Responsive signal present':scan?.viewport||'Unknown')}</dd></div><div><dt>HTTPS / TLS</dt><dd>${esc(tls)}${scan?.certificate_status?` · ${esc(websiteStateLabel(scan.certificate_status))}`:''}</dd></div><div><dt>Load time</dt><dd>${esc(websiteScanValue(scan,['elapsed_ms','load_time_ms','duration_ms'],'Unknown'))}${scan?.elapsed_ms!=null?' ms':''}</dd></div><div><dt>Response size</dt><dd>${esc(websiteScanValue(scan,['response_size','size_bytes','body_size'],'Unknown'))}${scan?.size_bytes!=null?' bytes':''}</dd></div></div><div class="website-contact-signals"><h5>Contact signals</h5><div class="signal-list">${contact.map(([label,val])=>`<span class="signal ${val===true?'present':val===false?'absent':'unknown'}"><b>${esc(label)}</b>${esc(signalLabel(val))}</span>`).join('')}</div></div>`;
|
||
}
|
||
async function loadWebsiteScan(id, {scan=false}={}){const state=$('websiteScanState'), scanButton=$('scanWebsiteBtn'), refreshButton=$('refreshWebsiteScanBtn');if(!state||!id)return;[scanButton,refreshButton].forEach(button=>{if(button)button.disabled=true;});state.innerHTML='<div class="detail-loading" aria-live="polite">Loading website scan…</div>';try{const result=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}/websites/scan`,scan?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:websiteUrlFor(selectedDetail||prospects.find(item=>Number(item.id)===Number(id))||{})})}:{});if(selectedDetail&&Number(selectedDetail.id)===Number(id))selectedDetail={...selectedDetail,website_scan:result};state.innerHTML=renderWebsiteScan(result);}catch(error){if(error.message!=='unauthorized')state.innerHTML=`<div class="detail-error" role="alert"><strong>${scan?'Website scan failed':'Unable to load website scan'}</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryWebsiteScanBtn" type="button">Try again</button></div>`;}finally{[scanButton,refreshButton].forEach(button=>{if(button)button.disabled=false;});}}
|
||
const extractedContactItems = payload => { for (const key of ['contacts','extracted_contacts','items','results']) if (Array.isArray(payload?.[key])) return payload[key]; return Array.isArray(payload) ? payload : []; };
|
||
const extractedContactValue = (item, keys, fallback='Unknown') => { for (const key of keys) if (item?.[key] !== undefined && item[key] !== null && item[key] !== '') return item[key]; return fallback; };
|
||
const extractedContactStatus = item => { const status=String(extractedContactValue(item,['suppression_status','suppression','status'],'Unknown')).toLowerCase().replaceAll('_','-'); return status==='suppressed'||item?.suppressed===true ? 'Suppressed' : status==='not-suppressed'||status==='allowed'||item?.suppressed===false ? 'Not suppressed' : status.replaceAll('-',' ').replace(/\\b\\w/g,c=>c.toUpperCase()); };
|
||
function renderExtractedContacts(payload){
|
||
const state=$('contactExtractionState'); if(!state)return;
|
||
if(!payload){state.innerHTML='<p class="muted">No extracted contacts returned. Extract from approved public business pages to review contact evidence.</p>';return;}
|
||
const contacts=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;}
|
||
state.innerHTML=`<div class="extracted-contact-list">${contacts.map(item=>{const type=extractedContactValue(item,['type','contact_type','kind'],'Other'),email=extractedContactValue(item,['email','value','address']),emailClass=extractedContactValue(item,['email_class','classification','class'],'Unknown'),validation=extractedContactValue(item,['validation','validation_status','verified_status'],'Unknown'),confidence=extractedContactValue(item,['confidence','confidence_score','score']),source=extractedContactValue(item,['source_url','url','source'],'Unknown source URL'),suppression=extractedContactStatus(item);return `<article class="extracted-contact" data-contact-type="${esc(type)}"><div class="extracted-contact-head"><strong>${esc(email)}</strong><span class="contact-confidence">${esc(confidence)} confidence</span></div><dl class="extracted-contact-facts"><div><dt>Type</dt><dd>${esc(type)}</dd></div><div><dt>Email class</dt><dd>${esc(emailClass)}</dd></div><div><dt>Validation</dt><dd>${esc(validation)}</dd></div><div><dt>Suppression</dt><dd class="suppression-status">${esc(suppression)}</dd></div><div class="contact-source"><dt>Source URL</dt><dd class="breakable"><a href="${esc(source)}" target="_blank" rel="noreferrer">${esc(source)}</a></dd></div></dl></article>`;}).join('')}</div>`;
|
||
}
|
||
function renderContactExtractionPanel(p){const panel=$('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>`;}
|
||
function renderExtractedContactsMarkup(payload){const contacts=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>';}
|
||
async function loadContactExtraction(id,{extract=false}={}){const state=$('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{const scan=selectedDetail?.website_scan||selectedDetail?.websiteScan||{};const payload={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};const result=await jsonRequest(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;});}}
|
||
const scoreItems = payload => payloadItems(payload, ['rules','score_rules','items','factors']);
|
||
const scoreValue = (item, keys, fallback='Unknown') => { for (const key of keys) if (item?.[key] !== undefined && item[key] !== null && item[key] !== '') return item[key]; return fallback; };
|
||
const scoreRole = () => String(currentUser?.role || currentUser?.roles?.[0] || '').toLowerCase();
|
||
const canEditScores = () => ['owner','admin'].includes(scoreRole()) || Boolean(currentUser?.permissions?.includes?.('score_rules:write'));
|
||
const scoreExplanation = factor => typeof factor === 'string' ? {name:labelFactor(factor), points:scoreValue(({named_business:20,business_site:30,email:25,phone:15,description:10}),[factor],0)} : factor || {};
|
||
function renderScoreConfig(payload) { const panel=$('scoreRulesPanel'); if(!panel)return; const rules=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>`; }
|
||
function renderScoreDistribution(payload) { const panel=$('scoreDistributionPanel'); if(!panel)return; const buckets=payloadItems(payload,['distribution','buckets','items']); const bandMap=payload?.bands&&typeof payload.bands==='object'&&!Array.isArray(payload.bands)?payload.bands:{}; const summary=payload?.summary||payload||{}; const fallback=[['High priority',summary.high_priority??bandMap.high??0],['Medium priority',bandMap.medium??0],['Low priority',bandMap.low??0],['Ineligible',bandMap.ineligible??0]]; const rows=buckets.length?buckets.map(item=>[scoreValue(item,['label','band','name'],'Score band'),scoreValue(item,['count','total','value'],0)]):fallback; const total=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>`; }
|
||
async function loadScorePanels() { const distribution=$('scoreDistributionPanel'), rules=$('scoreRulesPanel'); if(distribution)distribution.innerHTML='<div class="detail-loading" aria-live="polite">Loading score distribution…</div>'; if(rules)rules.innerHTML='<div class="detail-loading" aria-live="polite">Loading score rules…</div>'; try { const [distributionPayload,rulesPayload]=await Promise.all([jsonRequest('/api/v1/scoring/summary'),jsonRequest('/api/v1/score-rules')]); renderScoreDistribution(distributionPayload); renderScoreConfig(rulesPayload); } catch(error) { if(error.message!=='unauthorized'){ if(distribution)distribution.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load score distribution</strong><p>${esc(error.message)}</p></div>`; if(rules)rules.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load score rules</strong><p>${esc(error.message)}</p></div>`; } } }
|
||
async function updateScoreRule(id, currentPoints) { if(!canEditScores()||!id)return; const value=window.prompt('Points for this rule (0–100):',currentPoints); if(value===null)return; const points=Number(value); if(!Number.isInteger(points)||points<0||points>100){window.alert('Enter a whole number from 0 to 100.');return;} try { await jsonRequest(`/api/v1/score-rules/${encodeURIComponent(id)}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({points})}); await loadScorePanels(); } catch(error) { if(error.message!=='unauthorized')window.alert(error.message||'Unable to update score rule.'); } }
|
||
function renderScoreBreakdown(p) {
|
||
const panel=$('scoreBreakdownPanel'); if(!panel)return;
|
||
const score=scoreFor(p), factors=p.score_factors||p.factors||[], eligible=scoreValue(p,['eligible','score_eligible','eligibility'],'Unknown'), priority=scoreValue(p,['priority','priority_band','score_priority'],score>=80?'High':score>=60?'Medium':'Low'), version=scoreValue(p,['score_version','scoring_version'],'Unknown');
|
||
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=>{const item=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>`;
|
||
}
|
||
async function recalculateScore() { const button=$('recalculateScoreBtn'), messageEl=$('scoreMessage'); if(!button||!selectedId)return; button.disabled=true; if(messageEl)messageEl.textContent='Recalculation requested…'; try { const result=await jsonRequest(`/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;} }
|
||
const aiItems = payload => { const runs=payloadItems(payload, ['items','runs','ai_runs']); return runs.flatMap(run => (run.suggestions||[]).length ? run.suggestions.map(item => ({...item,id:run.id,status:run.approval_state||run.status,provider:run.provider||run.model||'Unknown'})) : [{...run,id:run.id,status:run.status||run.approval_state,provider:run.provider||run.model||'Unknown'}]); };
|
||
const aiStatus = item => String(item?.status || item?.state || item?.suggestion_status || 'unknown').toLowerCase().replaceAll('_','-');
|
||
const aiLabel = value => ({'not-configured':'Not configured',unknown:'Unknown',error:'Error',suppressed:'Suppressed',pending:'Pending approval',approved:'Approved',rejected:'Rejected',generated:'Generated',ready:'Ready'})[String(value).toLowerCase()] || String(value || 'Unknown').replaceAll('-',' ').replace(/\b\w/g,c=>c.toUpperCase());
|
||
const aiCitations = item => item?.citations || item?.sources || item?.evidence || [];
|
||
const aiCitationText = citation => typeof citation === 'string' ? citation : citation?.title || citation?.label || citation?.source_url || citation?.url || citation?.quote || citation?.text || JSON.stringify(citation);
|
||
function aiFacts(item) { const classification=item?.classification||item?.class||item?.label||'Unknown'; const summary=item?.summary||item?.abstract||item?.description||item?.suggestion||item?.text||'No summary returned.'; const priority=item?.priority||item?.priority_band||'Unknown'; const confidence=item?.confidence||item?.confidence_score||'Unknown'; const uncertainty=item?.uncertainty||item?.uncertainty_reasons||item?.reasons||'None returned'; return {classification,summary,priority,confidence,uncertainty}; }
|
||
function renderAiSuggestion(item) {
|
||
const status=aiStatus(item), citations=aiCitations(item), pending=['pending','generated','ready'].includes(status), id=item?.id || item?.suggestion_id, facts=aiFacts(item), uncertainty=Array.isArray(facts.uncertainty)?facts.uncertainty.join(' · '):facts.uncertainty;
|
||
const body=facts.summary;
|
||
const provider=item?.provider || item?.provider_name || item?.model || 'Unknown';
|
||
return `<article class="ai-suggestion ${status}" data-ai-suggestion-id="${esc(id || '')}"><div class="ai-suggestion-head"><div><span class="ai-status ${esc(status)}">${esc(aiLabel(status))}</span><span class="ai-provider">Provider: ${esc(provider)}</span></div>${pending && id ? `<div class="ai-actions"><button class="button ghost compact" type="button" data-ai-action="reject" data-ai-id="${esc(id)}">Reject</button><button class="button primary compact" type="button" data-ai-action="approve" data-ai-id="${esc(id)}">Approve</button></div>` : ''}</div><dl class="ai-facts"><div><dt>Classification</dt><dd>${esc(facts.classification)}</dd></div><div><dt>Priority</dt><dd>${esc(facts.priority)}</dd></div><div><dt>Confidence</dt><dd>${esc(facts.confidence)}</dd></div><div><dt>Uncertainty</dt><dd>${esc(uncertainty)}</dd></div></dl><p class="ai-copy">${esc(body)}</p>${citations.length?`<div class="ai-citations"><h5>Evidence citations <span class="count">${citations.length}</span></h5><ol>${citations.slice(0,10).map(c=>`<li>${esc(aiCitationText(c))}${c?.url||c?.source_url?` <a href="${esc(c.url||c.source_url)}" target="_blank" rel="noreferrer">Open source</a>`:''}</li>`).join('')}</ol></div>`:'<p class="muted">No citations were returned; treat this as unknown.</p>'}</article>`;
|
||
}
|
||
function renderAiState(state, detail='') { const el=$('aiAssistanceState'); if(!el)return; const normalized=String(state||'unknown').toLowerCase().replaceAll('_','-'); const copy={
|
||
'not-configured':['AI assistance is not configured','Ask an administrator to configure an approved provider before generating suggestions.'],
|
||
stale:['AI assistance is stale','The evidence snapshot changed or expired. Generate a fresh suggestion before approval.'],
|
||
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>`; }
|
||
async function loadAiAssistance(id) { const panel=$('aiAssistancePanel'); if(!panel)return; const prospect=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 { const payload=await jsonRequest(`/api/v1/ai-runs?business_id=${encodeURIComponent(id)}`); const items=aiItems(payload); const status=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); } }
|
||
async function generateAiSuggestion() { if(!selectedId)return; const button=$('generateAiSuggestionBtn');if(!button)return;button.disabled=true;const state=$('aiAssistanceState');state.innerHTML='<div class="detail-loading" aria-live="polite">Generating evidence-grounded suggestion…</div>';try{await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/ai/suggest`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({evidence_grounded:true,autonomous_action:false})});await loadAiAssistance(selectedId);}catch(error){if(error.message!=='unauthorized')renderAiState(error.message.toLowerCase().includes('config')?'not-configured':'error',error.message);}finally{button.disabled=false;}}
|
||
async function aiAction(action,id) { if(!id)return; const label=action==='approve'?'approve':'reject'; if(!window.confirm(`Confirm ${label} of this suggestion? This only records a human decision; no outreach will be sent.`))return; const path=action==='approve'?`/api/v1/ai-runs/${encodeURIComponent(id)}/approve`:`/api/v1/ai-runs/${encodeURIComponent(id)}/reject`; try{await jsonRequest(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({human_approval:true,autonomous_action:false})});await loadAiAssistance(selectedId);}catch(error){if(error.message!=='unauthorized')renderAiState('error',error.message);}}
|
||
const outreachItems = payload => payloadItems(payload, ['drafts','outreach_drafts','items','results']);
|
||
const outreachState = value => String(value || 'unknown').toLowerCase().replaceAll('_','-');
|
||
const outreachLabel = value => ({'not-configured':'Not configured',configured:'Configured',enabled:'Enabled',disabled:'Disabled',suppressed:'Suppressed',blocked:'Blocked',pending:'Pending approval',approved:'Approved',rejected:'Rejected',expired:'Expired',unknown:'Unknown',error:'Error',stale:'Stale'})[outreachState(value)] || String(value || 'Unknown').replaceAll('-',' ').replace(/\\b\\w/g,c=>c.toUpperCase());
|
||
const draftCitations = draft => draft?.citations || draft?.sources || draft?.evidence || [];
|
||
const draftText = draft => draft?.body || draft?.text || draft?.content || draft?.message || draft?.draft || 'No draft content returned.';
|
||
const recipientValue = draft => draft?.recipient?.email || draft?.recipient_email || draft?.target?.value || draft?.to || draft?.email || 'Recipient not returned';
|
||
const draftApprovalState = draft => outreachState(draft?.approval_state || draft?.status || draft?.state || 'pending');
|
||
function renderDraft(draft){
|
||
const state=draftApprovalState(draft), citations=draftCitations(draft), recipient=recipientValue(draft), canApprove=['pending','generated','ready'].includes(state) && recipient !== 'Recipient not returned';
|
||
return `<article class="outreach-draft ${esc(state)}" data-outreach-draft-id="${esc(draft?.id || '')}"><div class="outreach-draft-head"><div><span class="outreach-status ${esc(state)}">${esc(outreachLabel(state))}</span><span class="small-label">Draft only</span></div>${canApprove?`<button class="button primary compact" type="button" data-outreach-action="approve" data-outreach-id="${esc(draft.id)}">Approve draft</button>`:''}</div><div class="recipient-review"><strong>Recipient review</strong><span>${esc(recipient)}</span><small>${esc(draft?.recipient_name || draft?.recipient?.name || 'Name not returned')} · Human review required</small></div><p class="outreach-copy">${esc(draftText(draft))}</p>${citations.length?`<div class="outreach-citations"><h5>Evidence citations <span class="count">${citations.length}</span></h5><ol>${citations.slice(0,10).map(c=>`<li>${esc(aiCitationText(c))}${c?.url||c?.source_url?` <a href="${esc(c.url||c.source_url)}" target="_blank" rel="noreferrer">Open source</a>`:''}</li>`).join('')}</ol></div>`:'<p class="muted">No citations returned; this draft cannot be approved.</p>'}<div class="outreach-audit"><span>Evidence hash: ${esc(draft?.evidence_hash || draft?.snapshot_id || 'Unknown')}</span><span>Policy: ${esc(draft?.policy_version || 'Unknown')}</span><span>Created: ${esc(draft?.created_at || 'Time unavailable')}</span><span>Actor: ${esc(draft?.created_by || draft?.actor || 'Workspace')}</span></div></article>`;
|
||
}
|
||
function renderOutreachState(state, detail=''){const el=$('outreachDraftState');if(!el)return;const normalized=outreachState(state);const copy={'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>`;}
|
||
async function loadOutreachDrafts(id){const state=$('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{const payload=await jsonRequest(`/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);}}
|
||
async function createOutreachDraft(){if(!selectedId)return;const button=$('createOutreachDraftBtn');if(!button)return;const prospect=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{const evidence=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.';await jsonRequest(`/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})});await loadOutreachDrafts(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;}}
|
||
async function approveOutreachDraft(id){if(!id||!window.confirm('Approve this draft for recordkeeping? Approval does not send a message.'))return;try{await jsonRequest(`/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})});await loadOutreachDrafts(selectedId);}catch(error){if(error.message!=='unauthorized')renderOutreachState('error',error.message);}}
|
||
function renderProviderPolicy(payload){const panel=$('providerPolicyPanel');if(!panel)return;const items=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=>{const configured=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>`;}
|
||
const canEditAiProviders = () => ['admin','owner'].includes(String(currentUser?.role || currentUser?.roles?.[0] || '').toLowerCase()) || Boolean(currentUser?.permissions?.includes?.('ai_provider:write'));
|
||
const providerStatusValue = value => String(value || 'not-configured').toLowerCase().replaceAll('_','-');
|
||
const providerStatusLabel = value => ({configured:'Configured',ready:'Configured',enabled:'Configured',disabled:'Not configured','not-configured':'Not configured',invalid:'Invalid configuration',error:'Error',testing:'Testing…',connected:'Connected',unavailable:'Unavailable'})[providerStatusValue(value)] || 'Unknown';
|
||
function renderAiProviderStatus(payload) {
|
||
const state=$('aiProviderStatus'); if(!state)return;
|
||
const services=payloadItems(payload,['providers','services','items']);
|
||
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; }
|
||
const rows=services.length?services:[{name:'Nous Portal',provider:'nous_portal',status:payload?.nous_portal?.status||payload?.status||'not-configured',model:payload?.model||payload?.nous_portal?.model,base_url:payload?.base_url||payload?.nous_portal?.base_url},{name:'Firecrawl',provider:'firecrawl',status:payload?.firecrawl?.status||payload?.firecrawl_status||'not-configured',base_url:payload?.firecrawl?.base_url}];
|
||
state.innerHTML=`<div class="ai-provider-status-list">${rows.map(service=>{const status=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>`;
|
||
}
|
||
function updateAiProviderForm(){const step=$('aiProvider')?.value==='stepfun';const model=$('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');}}
|
||
function setAiProviderAdminState() { const state=$('aiProviderAdminState'),form=$('aiProviderForm'),test=$('testAiProviderBtn'); if(!state)return; const editable=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;} }
|
||
function populateAiProviderFields(payload) { const source=payload?.nous_portal||payload?.provider_config||payload||{}; if(source.provider&&$("aiProvider"))$("aiProvider").value=source.provider; [['nousModel',source.model||source.nous_model],['nousBaseUrl',source.base_url||source.nous_base_url],['searxngBaseUrl',source.searxng_base_url||source.firecrawl_base_url||payload?.searxng?.base_url||payload?.firecrawl?.base_url]].forEach(([id,value])=>{if(value!==undefined&&$(id))$(id).value=value;}); ['nousApiKey'].forEach(id=>{if($(id))$(id).value='';}); updateAiProviderForm(); }
|
||
async function loadAiProviderSettings(){const state=$('aiProviderStatus');if(!state)return;setAiProviderAdminState();state.innerHTML='<div class="detail-loading" aria-live="polite">Loading AI provider status…</div>';try{const payload=await jsonRequest('/api/v1/ai/provider-config');populateAiProviderFields(payload);renderAiProviderStatus(payload);}catch(error){if(error.message!=='unauthorized')renderAiProviderStatus({status:error.message.toLowerCase().includes('invalid')?'invalid':'error',message:error.message});}}
|
||
async function saveAiProviderSettings(event){event.preventDefault();if(!canEditAiProviders()){message('aiProviderMessage','Administrator access is required to update provider settings.',true);return;}const form=event.currentTarget,data=Object.fromEntries(new FormData(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;const button=$('saveAiProviderBtn');button.disabled=true;message('aiProviderMessage','Saving provider settings…');try{await jsonRequest('/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.');await loadAiProviderSettings();}catch(error){if(error.message!=='unauthorized')message('aiProviderMessage',error.message||'Unable to save provider settings.',true);}finally{button.disabled=false;}}
|
||
async function testAiProviderConnection(){if(!canEditAiProviders())return;const button=$('testAiProviderBtn');button.disabled=true;const state=$('aiProviderStatus');state.innerHTML='<div class="detail-loading" aria-live="polite">Testing provider connection…</div>';try{const payload=await jsonRequest('/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;}}
|
||
async function loadProviderPolicy(){const panel=$('providerPolicyPanel');if(!panel)return;panel.innerHTML='<div class="detail-loading" aria-live="polite">Loading provider policy…</div>';try{const payload=await jsonRequest('/api/v1/outreach/provider-config');renderProviderPolicy(payload);}catch(error){if(error.message!=='unauthorized')renderProviderPolicy({status:error.message.toLowerCase().includes('auth')?'auth-required':'error',message:error.message,providers:[]});}}
|
||
function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`<div class="detail-head"><div><p class="eyebrow">PROSPECT DETAIL</p><h3>${esc(p.name)}</h3><p class="detail-domain">${esc(p.website_domain||'no detected website')}</p></div><span class="status ${st}">${esc(review)}</span></div><div class="detail-score"><div><small>Fit score</small><b>${s}<small>/ 100</small></b></div><span class="score ${scoreClass(s)}">${esc(p.confidence||(s>=80?'High':s>=60?'Medium':'Low'))} confidence</span></div><section class="detail-block score-breakdown-panel" id="scoreBreakdownPanel" data-smoke="score-breakdown"><div class="detail-loading">Loading score breakdown…</div></section><div class="detail-block"><h4>Pipeline stage</h4><form id="pipelineForm" class="inline-form"><select name="stage" aria-label="Pipeline stage"><option value="new" ${stage==='new'?'selected':''}>New</option><option value="contacted" ${stage==='contacted'?'selected':''}>Contacted</option><option value="qualified" ${stage==='qualified'?'selected':''}>Qualified</option><option value="proposal" ${stage==='proposal'?'selected':''}>Proposal</option><option value="negotiation" ${stage==='negotiation'?'selected':''}>Negotiation</option><option value="won" ${stage==='won'?'selected':''}>Won</option><option value="lost" ${stage==='lost'?'selected':''}>Lost</option></select><button class="button ghost compact" type="submit">Save stage</button></form><p id="pipelineMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Contacts <span class="count">${contacts.length}</span></h4>${listItems(contacts,'No contacts added.','email')}<form id="contactForm" class="compact-form"><input name="name" placeholder="Contact name" aria-label="Contact name"><input name="email" type="email" placeholder="Email" aria-label="Contact email" required><button class="button ghost compact" type="submit">Add contact</button></form><p id="contactMessage" class="form-message" role="status"></p></div><section class="detail-block contact-extraction-panel" id="contactExtractionPanel" data-smoke="contact-extraction"><div class="detail-loading">Loading public contacts…</div></section><section class="detail-block website-scan-panel" id="websiteScanPanel" data-smoke="website-scan"><div class="detail-loading">Loading website scan…</div></section><section class="detail-block domain-intelligence" id="domainIntelligencePanel" data-smoke="domain-intelligence"><div class="detail-loading">Loading domain intelligence…</div></section><section class="detail-block ai-assistance-panel" id="aiAssistancePanel" data-smoke="ai-assistance"><div class="ai-panel-heading"><div><p class="eyebrow">AI ASSISTANCE</p><h4>Evidence-grounded suggestion</h4></div><button class="button primary compact" id="generateAiSuggestionBtn" type="button">Generate suggestion</button></div><p class="ai-safety">AI suggestions summarize workspace evidence and citations only. Review and approve explicitly; No autonomous action or outreach is taken.</p><div id="aiAssistanceState" aria-live="polite"><div class="detail-loading">Loading AI assistance…</div></div></section><section class="detail-block outreach-panel" id="outreachDraftPanel" data-smoke="outreach-preparation"><div class="outreach-panel-heading"><div><p class="eyebrow">OUTREACH PREPARATION</p><h4>Evidence-grounded outreach draft</h4></div><button class="button primary compact" id="createOutreachDraftBtn" type="button">Create draft</button></div><p class="outreach-safety">Draft-only preparation. Review the recipient and citations before approval. No message will be sent from this screen.</p><div id="outreachDraftState" aria-live="polite"><div class="detail-loading">Loading outreach drafts…</div></div><button class="button disabled-action" disabled aria-disabled="true">Send unavailable</button><p class="disabled-reason">Sending is disabled by policy; approval never sends a message.</p></section><div class="detail-block"><h4>Domains & websites</h4>${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}</div><div class="detail-block"><h4>Evidence timeline</h4>${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`<p class="evidence-line"><span>✓ ${esc(labelFactor(x))}</span><span class="confidence">${esc(p.confidence||'Medium')}</span></p>`).join(''):''}</div><div class="detail-block"><h4>Notes <span class="count">${notes.length}</span></h4>${listItems(notes,'No notes added.','body')}<form id="noteForm" class="compact-form"><textarea name="body" rows="2" placeholder="Add a review note…" required></textarea><button class="button ghost compact" type="submit">Add note</button></form><p id="noteMessage" class="form-message" role="status"></p></div><div class="detail-block"><h4>Review status</h4><p class="review-status">${esc(review)}</p>${st!=='suppressed'?'<button class="button primary compact" id="verifyBtn" type="button">Mark verified</button>':''}<p id="verifyMessage" class="form-message" role="status"></p></div>${blocked?`<button class="button disabled-action" disabled aria-disabled="true">Outreach unavailable</button><p class="disabled-reason">${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}</p>`:''}`;renderScoreBreakdown(p);renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);loadAiAssistance(p.id);loadOutreachDrafts(p.id);renderDedupPanel();}
|
||
let mergeSource = null, mergeTarget = null, mergeBusy = false;
|
||
const payloadItems = (payload, keys) => { for (const key of keys) if (Array.isArray(payload?.[key])) return payload[key]; return Array.isArray(payload) ? payload : []; };
|
||
const suggestionId = item => item.target_id ?? item.business_id ?? item.prospect_id ?? item.id;
|
||
const suggestionName = item => item.target_name || item.business_name || item.prospect_name || item.name || `Prospect ${suggestionId(item)}`;
|
||
const suggestionConfidence = item => item.confidence ?? item.score ?? item.match_confidence ?? 'Unknown';
|
||
const suggestionReasons = item => item.reasons || item.reason || item.match_reasons || item.explanation || [];
|
||
const reasonItems = reasons => Array.isArray(reasons) ? reasons : [reasons];
|
||
function renderDedupPanel() {
|
||
const detail = $('detailPanel'); if (!detail || !selectedId) return;
|
||
let panel = $('dedupPanel');
|
||
if (!panel) { panel = document.createElement('section'); panel.id = 'dedupPanel'; panel.className = 'dedup-panel'; panel.dataset.smoke = 'deduplication'; detail.appendChild(panel); }
|
||
panel.innerHTML = '<div class="detail-loading" aria-live="polite">Loading match suggestions…</div>';
|
||
loadMatchSuggestions(selectedId);
|
||
}
|
||
async function loadMatchSuggestions(id) {
|
||
const panel = $('dedupPanel'); if (!panel) return;
|
||
try {
|
||
const payload = await jsonRequest(`/api/v1/businesses/${encodeURIComponent(id)}/matches`);
|
||
const suggestions = payloadItems(payload, ['suggestions','matches','items']);
|
||
panel.innerHTML = `<div class="dedup-heading"><div><p class="eyebrow">DEDUPLICATION</p><h4>Possible matches <span class="count">${suggestions.length}</span></h4></div><span class="small-label">Human review required</span></div>${suggestions.length ? `<div class="match-list">${suggestions.map(item => { const confidence=String(suggestionConfidence(item)); const reasons=reasonItems(suggestionReasons(item)); return `<article class="match-card" data-match-id="${esc(suggestionId(item))}"><div class="match-card-head"><strong>${esc(suggestionName(item))}</strong><span class="match-confidence">${esc(confidence)} confidence</span></div><ul class="match-reasons">${reasons.map(reason => `<li>${esc(typeof reason === 'string' ? reason : reason.text || reason.description || JSON.stringify(reason))}</li>`).join('')}</ul><button class="button primary compact review-required" type="button" data-merge-target="${esc(suggestionId(item))}" data-merge-target-name="${esc(suggestionName(item))}">Review & merge</button></article>`; }).join('')}</div>` : '<p class="muted">No possible matches returned. Nothing was merged automatically.</p>'}<div class="merge-history" id="mergeHistory" data-api-marker="merge-history"><div class="detail-loading" aria-live="polite">Loading merge history…</div></div>`;
|
||
loadMergeHistory(id);
|
||
} catch (error) { if (error.message !== 'unauthorized') panel.innerHTML = `<div class="dedup-error" role="alert"><h4>Unable to load match suggestions</h4><p>${esc(error.message)}</p><button class="button ghost compact" id="retryDedupBtn" type="button">Try again</button></div><div class="merge-history" id="mergeHistory"></div>`; }
|
||
}
|
||
async function loadMergeHistory(id) {
|
||
const history = $('mergeHistory'); if (!history) return;
|
||
try { const payload = await jsonRequest('/api/v1/merge-history'); const items = 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 => { const mergeId=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>`; }
|
||
}
|
||
function openMergeDialog(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; }
|
||
function closeMergeDialog() { if (mergeBusy) return; $('mergeDialog').hidden=true; mergeSource=null; mergeTarget=null; }
|
||
async function confirmMerge() { if (!mergeSource || !mergeTarget || mergeBusy) return; mergeBusy=true; const button=$('confirmMergeBtn'); button.disabled=true; $('mergeDialogMessage').textContent='Merging records…'; $('mergeDialogMessage').className='form-message'; try { await jsonRequest(`/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.'; await loadData(); closeMergeDialog(); if (selectedId) await loadDetail(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; } }
|
||
async function reverseMerge(id) { if (!window.confirm('Reverse this merge? The original records will be restored.')) return; try { await jsonRequest(`/api/v1/merge-history/${encodeURIComponent(id)}/reverse`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_required:true})}); if(selectedId) { await loadDetail(selectedId); await loadData(); } } catch(error) { const history=$('mergeHistory'); if(error.message!=='unauthorized'&&history) history.insertAdjacentHTML('afterbegin',`<p class="form-message error" role="alert">${esc(error.message||'Unable to reverse merge.')}</p>`); } }
|
||
function message(id,text,error=false){const el=$(id);if(el){el.textContent=text;el.className=`form-message${error?' error':''}`;}}
|
||
async function saveContact(form){const data=Object.fromEntries(new FormData(form).entries());if(!data.email.trim()){message('contactMessage','Email is required.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/contacts`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});message('contactMessage','Contact added.');await loadDetail(selectedId);}catch(e){if(e.message!=='unauthorized')message('contactMessage',e.message,true);}}
|
||
async function saveNote(form){const data=Object.fromEntries(new FormData(form).entries());if(!data.body.trim()){message('noteMessage','Note cannot be empty.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/notes`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});message('noteMessage','Note added.');await loadDetail(selectedId);}catch(e){if(e.message!=='unauthorized')message('noteMessage',e.message,true);}}
|
||
async function saveStage(form){const stage=new FormData(form).get('stage');if(!stage){message('pipelineMessage','Choose a pipeline stage.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/pipeline`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({stage})});message('pipelineMessage','Pipeline stage updated.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('pipelineMessage',e.message,true);}}
|
||
async function verify(){try{await jsonRequest(`/api/v1/businesses/${selectedId}/verify`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({verified:true})});message('verifyMessage','Prospect marked verified.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('verifyMessage',e.message,true);}}
|
||
async function addProspect(event){event.preventDefault();const data=Object.fromEntries(new FormData(event.currentTarget).entries());const msg=$('formMessage');try{const body=await jsonRequest('/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';}}}
|
||
// Phase 12 CRM surfaces: all actions are authenticated internal records; no send/outreach capability.
|
||
let crmPipelineItems = [], crmListMode = false, selectedSuppressionIds = new Set();
|
||
const crmStages = ['new','contacted','qualified','proposal','negotiation','won','lost'];
|
||
const crmLabel = value => String(value || 'unknown').replaceAll('_',' ').replace(/\\b\\w/g, c => c.toUpperCase());
|
||
const crmArray = (payload, keys=[]) => payload && Array.isArray(payload) ? payload : (keys.map(k => payload?.[k]).find(Array.isArray) || []);
|
||
const crmStage = p => String(p.pipeline_stage || p.stage || p.pipeline?.stage || (Array.isArray(p.pipeline) ? p.pipeline.at(-1)?.stage : '') || 'new').toLowerCase();
|
||
function crmMessage(text, error=false){const el=$('crmMessage');if(el){el.textContent=text||'';el.className=`crm-message${error?' error':''}`;}}
|
||
function renderPipeline(){const board=$('pipelineBoard');if(!board)return;if(!crmPipelineItems.length){board.innerHTML='<div class="crm-empty">No pipeline records returned by the workspace.</div>';return;}const card=p=>{const st=statusOf(p), suppressed=st==='suppressed'||p.suppressed===true;return `<article class="pipeline-card ${suppressed?'is-suppressed':''}" data-prospect-id="${esc(p.id)}"><button class="pipeline-card-link" type="button" data-crm-select="${esc(p.id)}"><strong>${esc(p.name||`Prospect ${p.id}`)}</strong><small>${esc(p.website_domain||p.location||'No domain')}</small><span class="score ${scoreClass(scoreFor(p))}">${esc(scoreFor(p))} / 100</span></button><div class="pipeline-card-actions"><label class="sr-only" for="crm-stage-${esc(p.id)}">Stage for ${esc(p.name)}</label><select id="crm-stage-${esc(p.id)}" data-crm-stage="${esc(p.id)}">${crmStages.map(s=>`<option value="${s}" ${crmStage(p)===s?'selected':''}>${crmLabel(s)}</option>`).join('')}</select><button class="button ghost compact" type="button" data-crm-save-stage="${esc(p.id)}">Save</button></div>${suppressed?'<p class="suppression-inline">Suppressed · no outreach</p>':st==='review'?'<p class="review-inline">Needs review before outreach</p>':''}</article>`;};if(crmListMode){board.className='pipeline-board pipeline-list-view';board.innerHTML=`<div class="pipeline-list">${crmPipelineItems.map(card).join('')}</div>`;}else{board.className='pipeline-board';board.innerHTML=crmStages.map(stage=>{const items=crmPipelineItems.filter(p=>crmStage(p)===stage);return `<section class="pipeline-column" data-stage="${stage}"><div class="pipeline-column-head"><h3>${crmLabel(stage)}</h3><span class="count">${items.length}</span></div>${items.length?items.map(card).join(''):'<p class="crm-column-empty">No prospects</p>'}</section>`;}).join('');}}
|
||
async function loadCrmPipeline(){const board=$('pipelineBoard');if(board)board.innerHTML='<div class="detail-loading" aria-live="polite">Loading CRM pipeline…</div>';try{const [businessPayload,pipelinePayload]=await Promise.all([jsonRequest('/api/v1/businesses?page=1&page_size=100'),jsonRequest('/api/v1/pipeline-entries')]);const businesses=crmArray(businessPayload,['items','businesses','prospects']),entries=crmArray(pipelinePayload,['items','entries','pipeline']);const byId=new Map(businesses.map(p=>[String(p.id),p]));crmPipelineItems=entries.map(entry=>({...byId.get(String(entry.business_id||entry.prospect_id))||{},...entry,id:entry.business_id||entry.prospect_id||entry.id}));if(!crmPipelineItems.length)crmPipelineItems=businesses;renderPipeline();crmMessage('');}catch(error){if(error.message!=='unauthorized'){if(board)board.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load CRM pipeline</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryCrmBtn" type="button">Try again</button></div>`;crmMessage(error.message,true);}}}
|
||
async function saveCrmStage(id){const item=crmPipelineItems.find(p=>String(p.id)===String(id)),select=$(`crm-stage-${id}`);if(!item||!select)return;const next=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{await jsonRequest(`/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.');await Promise.all([loadCrmPipeline(),loadData()]);}catch(error){if(error.message!=='unauthorized')crmMessage(error.message,true);select.value=crmStage(item);}}
|
||
async function loadInteractions(id){const state=$('interactionState');if(!state)return;state.innerHTML='<div class="detail-loading" aria-live="polite">Loading interaction timeline…</div>';try{const payload=await jsonRequest(`/api/v1/interactions?business_id=${encodeURIComponent(id)}`),items=crmArray(payload,['interactions','items','timeline','events']);state.innerHTML=`<div class="panel-heading"><div><p class="eyebrow">TIMELINE</p><h3>${esc((crmPipelineItems.find(p=>String(p.id)===String(id))||selectedDetail||{}).name||`Prospect ${id}`)}</h3></div><span class="small-label">${items.length} record${items.length===1?'':'s'}</span></div>${items.length?`<ol class="crm-timeline">${items.map(item=>`<li><span class="timeline-dot"></span><div><strong>${esc(item.kind||'Interaction')}</strong><span class="outcome-chip">${esc(crmLabel(item.outcome||'No outcome'))}</span><p>${esc(item.body||item.notes||'No body')}</p><small>${esc(item.follow_up_at?`Follow-up ${item.follow_up_at} · `:'')}${esc(item.created_at||item.occurred_at||'Time unavailable')}</small></div></li>`).join('')}</ol>`:'<p class="crm-empty">No interactions recorded yet.</p>'}`;state.dataset.businessId=id;}catch(error){if(error.message!=='unauthorized')state.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load interaction timeline</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryInteractionsBtn" type="button">Try again</button></div>`;}}
|
||
async function saveInteraction(event){event.preventDefault();if(!selectedId){message('interactionMessage','Select a prospect in the pipeline first.',true);return;}const form=event.currentTarget,data=Object.fromEntries(new FormData(form).entries());if(!data.body.trim()){message('interactionMessage','Add an internal body before saving.',true);return;}const button=form.querySelector('button[type="submit"]');button.disabled=true;try{await jsonRequest(`/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();await loadInteractions(selectedId);}catch(error){if(error.message!=='unauthorized')message('interactionMessage',error.message,true);}finally{button.disabled=false;}}
|
||
let suppressions=[];
|
||
function renderSuppressions(){const state=$('suppressionState');if(!state)return;if(!suppressions.length){state.innerHTML='<div class="crm-empty">No suppression rules returned by the workspace.</div>';return;}state.innerHTML=`<div class="suppression-list">${suppressions.map(item=>`<article class="suppression-row"><label class="checkbox-label"><input type="checkbox" data-suppression-id="${esc(item.id)}" ${selectedSuppressionIds.has(String(item.id))?'checked':''}><span><strong>${esc(item.value||item.identifier||'Redacted value')}</strong><small>${esc(crmLabel(item.kind||item.type||'rule'))}${item.reason?` · ${esc(item.reason)}`:''}</small></span></label><button class="button danger compact" type="button" data-remove-suppression="${esc(item.id)}">Remove</button></article>`).join('')}</div>`;updateSuppressionSelection();}
|
||
function updateSuppressionSelection(){const count=selectedSuppressionIds.size;const btn=$('bulkReviewSuppressionsBtn');if(btn){btn.disabled=!count;btn.textContent=count?`Review selected (${count})`:'Review selected';}const all=$('selectAllSuppressions');if(all)all.checked=Boolean(suppressions.length&&count===suppressions.length);}
|
||
async function loadSuppressions(){const state=$('suppressionState');if(state)state.innerHTML='<div class="detail-loading" aria-live="polite">Loading suppression rules…</div>';try{const payload=await jsonRequest('/api/v1/suppressions');suppressions=crmArray(payload,['suppressions','items','rules']);renderSuppressions();}catch(error){if(error.message!=='unauthorized'&&state)state.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load suppressions</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retrySuppressionsBtn" type="button">Try again</button></div>`;}}
|
||
async function addSuppression(event){event.preventDefault();const form=event.currentTarget,data=Object.fromEntries(new FormData(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{await jsonRequest('/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();await Promise.all([loadSuppressions(),loadData()]);}catch(error){if(error.message!=='unauthorized')message('suppressionMessage',error.message,true);}}
|
||
async function removeSuppression(id){if(!window.confirm('Remove this suppression rule? Confirm only if policy allows contact eligibility to be reconsidered.'))return;try{await jsonRequest(`/api/v1/suppressions/${encodeURIComponent(id)}`,{method:'DELETE'});selectedSuppressionIds.delete(String(id));await loadSuppressions();}catch(error){if(error.message!=='unauthorized')message('suppressionMessage',error.message,true);}}
|
||
async function bulkReviewSuppressions(){const ids=[...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{await jsonRequest('/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);}}
|
||
const reportRows=(payload, keys=['items','rows','data'])=>crmArray(payload,keys);
|
||
function renderReport(id,title,payload){const el=$(id);if(!el)return;const rows=reportRows(payload);const obj=payload?.summary||payload?.counts||payload||{};const fallback=Object.entries(obj).filter(([,v])=>typeof v==='number').slice(0,8);const entries=rows.length?rows.map(r=>[r.label||r.name||r.stage||r.outcome||r.type||'Metric',r.count??r.total??r.value??0]):fallback;if(!entries.length){el.innerHTML=`<div class="panel-heading"><h3>${title}</h3></div><p class="crm-empty">No ${title.toLowerCase()} data returned.</p>`;return;}el.innerHTML=`<div class="panel-heading"><div><p class="eyebrow">REPORT</p><h3>${title}</h3></div><span class="small-label">API summary</span></div><div class="report-rows">${entries.map(([label,value])=>`<div><span>${esc(crmLabel(label))}</span><strong>${esc(value)}</strong></div>`).join('')}</div><p class="report-note">Tenant-scoped API results. Loading, unavailable, and empty are distinct states.</p>`;}
|
||
async function loadReports(){[['pipelineReport','pipeline report'],['outcomesReport','outcomes report'],['activityReport','activity report']].forEach(([id,title])=>{const el=$(id);if(el)el.innerHTML=`<div class="detail-loading">Loading ${title}…</div>`;});try{const [pipeline,outcomes,activity]=await Promise.all([jsonRequest('/api/v1/reports/pipeline'),jsonRequest('/api/v1/reports/outcomes'),jsonRequest('/api/v1/reports/activity')]);renderReport('pipelineReport','Pipeline',pipeline);renderReport('outcomesReport','Outcomes',outcomes);renderReport('activityReport','Activity',activity);}catch(error){if(error.message!=='unauthorized')[['pipelineReport','pipeline'],['outcomesReport','outcomes'],['activityReport','activity']].forEach(([id,label])=>{const el=$(id);if(el)el.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load ${label} report</strong><p>${esc(error.message)}</p></div>`;});}}
|
||
|
||
const jobStatuses = ['queued','running','succeeded','failed','cancelled'];
|
||
const jobRole = () => String(currentUser?.role || currentUser?.roles?.[0] || '').toLowerCase();
|
||
const canManageJobs = () => ['admin','owner','operator','manager'].includes(jobRole()) || Boolean(currentUser?.permissions?.includes?.('jobs:manage'));
|
||
const jobStatus = job => String(job?.status || job?.state || 'queued').toLowerCase();
|
||
const jobLabel = status => status.charAt(0).toUpperCase() + status.slice(1);
|
||
async function jobsRequest(path, options = {}) { return jsonRequest(path, options); }
|
||
function jobMessage(text, error = false) { const el = $('jobsMessage'); el.textContent = text || ''; el.className = `jobs-message${error ? ' error' : ''}`; }
|
||
function resetJobCounts() { jobStatuses.forEach(status => { const el = $(`jobCount${jobLabel(status)}`); if (el) el.textContent = '—'; }); }
|
||
function renderJobCounts(payload) {
|
||
const counts = payload?.counts || payload?.status_counts || payload?.summary || {};
|
||
const derived = jobs.reduce((out, job) => { const status = jobStatus(job); if (jobStatuses.includes(status)) out[status] += 1; return out; }, {queued:0,running:0,succeeded:0,failed:0,cancelled:0});
|
||
jobStatuses.forEach(status => { const value = counts[status] ?? counts[`${status}_count`] ?? derived[status]; $(`jobCount${jobLabel(status)}`).textContent = Number.isFinite(Number(value)) ? Number(value) : 0; });
|
||
}
|
||
function renderJobsList() {
|
||
const list = $('jobsList');
|
||
if (!jobs.length) { list.innerHTML = '<div class="job-empty">No jobs returned by the workspace.</div>'; return; }
|
||
list.innerHTML = jobs.map(job => { const status = jobStatus(job), progress = Number(job.progress ?? job.percent ?? 0); return `<button class="job-row ${Number(job.id) === Number(selectedJobId) ? 'selected' : ''}" type="button" data-job-id="${esc(job.id)}"><span class="job-row-main"><strong>${esc(job.name || job.type || `Job ${job.id}`)}</strong><small>${esc(job.created_at || job.submitted_at || 'Time unavailable')}</small></span><span class="job-row-state ${esc(status)}">${esc(jobLabel(status))}</span><span class="job-row-progress">${status === 'running' ? `${Math.max(0, Math.min(100, progress))}%` : ''}</span></button>`; }).join('');
|
||
}
|
||
const structuredError = job => job.error || job.structured_error || job.failure || (job.error_code ? {code:job.error_code,message:job.error_message || 'The job reported a structured error.'} : null);
|
||
const eventText = event => event.message || event.description || event.name || event.type || 'Job event';
|
||
function renderJobDetail(job, events = job.events || job.event_timeline || []) {
|
||
const status = jobStatus(job), error = structuredError(job), progress = Math.max(0, Math.min(100, Number(job.progress ?? job.percent ?? 0))), canCancel = canManageJobs() && ['queued','running'].includes(status), canRetry = canManageJobs() && status === 'failed';
|
||
$('jobDetailPanel').innerHTML = `<div class="job-detail-head"><div><p class="eyebrow">JOB DETAIL</p><h3>${esc(job.name || job.type || `Job ${job.id}`)}</h3><p class="muted">ID ${esc(job.id)}</p></div><span class="job-row-state ${esc(status)}">${esc(jobLabel(status))}</span></div><div class="job-progress" aria-label="Job progress"><div class="job-progress-meta"><strong>${status === 'running' ? `${progress}% complete` : jobLabel(status)}</strong><span>${esc(job.progress_message || job.message || '')}</span></div><div class="progress-track"><span style="width:${progress}%"></span></div></div>${error ? `<div class="structured-error" role="alert"><strong>${esc(error.code || error.type || 'Job error')}</strong><p>${esc(error.message || error.detail || error.description || JSON.stringify(error))}</p>${error.details ? `<pre>${esc(JSON.stringify(error.details, null, 2))}</pre>` : ''}</div>` : ''}<div class="job-detail-actions">${canCancel ? '<button class="button ghost compact" id="cancelJobBtn" type="button">Cancel job</button>' : ''}${canRetry ? '<button class="button primary compact" id="retryJobBtn" type="button">Retry job</button>' : ''}${!canCancel && !canRetry && !canManageJobs() ? '<span class="muted">Your role cannot change jobs.</span>' : ''}</div><div class="event-timeline"><h4>Event timeline</h4>${Array.isArray(events) && events.length ? `<ol>${events.map(event => `<li><span class="timeline-dot"></span><div><strong>${esc(eventText(event))}</strong><small>${esc(event.created_at || event.timestamp || event.at || '')}</small>${event.progress != null ? `<span class="timeline-progress">${esc(event.progress)}%</span>` : ''}</div></li>`).join('')}</ol>` : '<p class="muted">No events returned yet.</p>'}</div>`;
|
||
}
|
||
async function loadJobDetail(id) { selectedJobId = id; renderJobsList(); $('jobDetailPanel').innerHTML = '<div class="detail-loading" aria-live="polite">Loading job detail…</div>'; try { const job = await jobsRequest(`/api/v1/jobs/${encodeURIComponent(id)}`); let events = job.events || job.event_timeline; if (!events) { const eventPayload = await jobsRequest(`/api/v1/jobs/${encodeURIComponent(id)}/events`); events = eventPayload.events || eventPayload.items || eventPayload; } renderJobDetail(job, events); } catch (error) { if (error.message !== 'unauthorized') $('jobDetailPanel').innerHTML = `<div class="detail-error" role="alert"><h3>Unable to load job</h3><p>${esc(error.message)}</p><button class="button ghost" id="retryJobDetailBtn" type="button">Try again</button></div>`; } }
|
||
async function loadJobs({silent = false} = {}) { if (!silent) { jobMessage('Loading jobs…'); resetJobCounts(); } try { const payload = await jobsRequest('/api/v1/jobs'); jobs = Array.isArray(payload) ? payload : (payload.jobs || payload.items || []); renderJobCounts(payload); renderJobsList(); $('jobsUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; jobMessage(''); const active = jobs.some(job => ['queued','running'].includes(jobStatus(job))); if (active && !jobPollTimer) jobPollTimer = setInterval(() => loadJobs({silent:true}), 5000); if (!active && jobPollTimer) { clearInterval(jobPollTimer); jobPollTimer = null; } if (selectedJobId) { const selected = jobs.find(job => String(job.id) === String(selectedJobId)); if (selected) await loadJobDetail(selectedJobId); } } catch (error) { jobs = []; resetJobCounts(); renderJobsList(); if (error.message !== 'unauthorized') jobMessage(error.message || 'Unable to load jobs.', true); } }
|
||
async function startDemoJob() { if (!canManageJobs()) { jobMessage('Your role is not permitted to start jobs.', true); return; } const button = $('startDemoJobBtn'); button.disabled = true; jobMessage('Starting demo job…'); try { const job = await jobsRequest('/api/v1/jobs', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({type:'noop', payload:{}, idempotency_key:`demo-${Date.now()}-${Math.random().toString(36).slice(2)}`})}); await loadJobs({silent:true}); if (job?.id) await loadJobDetail(job.id); jobMessage('Demo job started.'); } catch (error) { if (error.message !== 'unauthorized') jobMessage(error.message || 'Unable to start demo job.', true); } finally { button.disabled = !canManageJobs(); } }
|
||
async function jobAction(action) { const job = jobs.find(item => String(item.id) === String(selectedJobId)); if (!job || !canManageJobs()) return; const endpointPath = action === 'cancel' ? `/api/v1/jobs/${encodeURIComponent(job.id)}/cancel` : `/api/v1/jobs/${encodeURIComponent(job.id)}/retry`; const label = action === 'cancel' ? 'cancel' : 'retry'; try { await jobsRequest(endpointPath, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); jobMessage(`Job ${label} requested.`); await loadJobs({silent:true}); await loadJobDetail(job.id); } catch (error) { if (error.message !== 'unauthorized') jobMessage(error.message || `Unable to ${label} job.`, true); } }
|
||
function updateJobPermissions() { if ($('startDemoJobBtn')) $('startDemoJobBtn').disabled = !canManageJobs(); }
|
||
|
||
let sources = [], selectedSourceId = null;
|
||
const sourceState = source => Boolean(source?.enabled ?? source?.active);
|
||
const sourceStatus = source => source.optional && !source.configured ? 'unavailable' : sourceState(source) ? 'enabled' : 'disabled';
|
||
const sourceItems = payload => Array.isArray(payload) ? payload : (payload?.sources || payload?.items || payload?.records || []);
|
||
const sourceJson = (value, fallback={}) => { if (value && typeof value === 'object') return value; try { return value ? JSON.parse(value) : fallback; } catch { return fallback; } };
|
||
const sourceLabel = source => source.display_name || source.name || source.source_code || source.kind || `Source ${source.id}`;
|
||
const sourceType = source => source.source_code || source.kind || source.type || 'unknown';
|
||
const sourceText = (source, keys, fallback='Not returned') => { for (const key of keys) if (source?.[key] !== undefined && source[key] !== null && source[key] !== '') return source[key]; return fallback; };
|
||
function sourceMessage(text, error = false) { const el = $('sourcesMessage'); if (el) { el.textContent = text || ''; el.className = `sources-message${error ? ' error' : ''}`; } }
|
||
function renderSourceSelect() { const select = $('discoverySource'); if (select) select.innerHTML = `<option value="">Select a source</option>${sources.filter(source=>!source.optional).map(s => `<option value="${esc(s.id)}" ${sourceState(s)?'':'disabled'}>${esc(sourceLabel(s))} · ${esc(sourceStatus(s))}</option>`).join('')}`; const multi=$('directDiscoverySources'); if(multi) multi.innerHTML=sources.filter(sourceState).map(s=>`<option value="${esc(s.id)}">${esc(sourceLabel(s))} · ${esc(sourceType(s))}</option>`).join('') || '<option value="" disabled>No approved sources enabled</option>'; }
|
||
function renderSources() { renderSourceSelect(); const list = $('sourcesList'); if (!sources.length) { list.innerHTML = '<div class="source-empty">No registered or available sources returned by the workspace.</div>'; return; } list.innerHTML = sources.map(source => { const policy=sourceJson(source.policy_json||source.policy), quota=sourceJson(source.quota_json||source.quota), config=sourceJson(source.config_json||source.config), status=sourceStatus(source), configured=source.configured ?? (!source.optional && (source.approved || sourceType(source)==='manual'||sourceType(source)==='csv')), available=source.available ?? (!source.optional || Boolean(source.configured)), health=sourceText(source,['health_status','health'],'Not tested'), failures=sourceText(source,['consecutive_failures'],'0'), circuit=source.circuit_open===true||source.circuit_open===1?'Open':'Closed', credential=sourceText(source,['api_credential_status','credential_status'],source.optional?'Required / not configured':'Not required'), terms=sourceText(source,['terms_status','terms_reviewed'],policy.terms_accepted===true?'Accepted':policy.terms_url||config.terms_url?'Provided':'Not reviewed'), owner=sourceText(source,['owner','owner_name'],policy.owner||config.owner||'Not assigned'), rate=sourceText(source,['rate_limit','rate_limit_label'],policy.rate_limit||config.rate_limit||'Not set'), daily=sourceText(source,['daily_quota','daily_limit'],quota.daily_limit||'Not set'), lastHealth=sourceText(source,['last_health_at','last_checked_at','updated_at'],'Not checked'), success=sourceText(source,['last_success_at'],'No successful run'), error=sourceText(source,['last_error','error'],'None recorded'); return `<article class="source-row ${source.optional?'source-optional':''}" data-source-id="${esc(source.id||sourceType(source))}"><div class="source-row-main"><div class="source-title-line"><strong>${esc(sourceLabel(source))}</strong><span class="source-type">${esc(sourceType(source))}</span></div><small>${source.optional?'Optional adapter · configuration-gated':'Registered workspace source'}</small></div><span class="source-status ${status}">${esc(status==='unavailable'?'Unavailable':status)}</span><dl class="source-facts source-registry-facts"><div><dt>Integration</dt><dd>${esc(source.optional?'Optional adapter':'Registered')}</dd></div><div><dt>Configured</dt><dd>${configured?'Yes':'No'}</dd></div><div><dt>Available</dt><dd>${available?'Yes':'No'}</dd></div><div><dt>Enabled</dt><dd>${sourceState(source)?'Yes':'No'}</dd></div><div><dt>API credential</dt><dd>${esc(credential)}</dd></div><div><dt>Terms</dt><dd>${esc(terms)}</dd></div><div><dt>Owner</dt><dd>${esc(owner)}</dd></div><div><dt>Rate limit</dt><dd>${esc(rate)}</dd></div><div><dt>Daily quota</dt><dd>${esc(daily)}</dd></div><div><dt>Last health</dt><dd>${esc(lastHealth)} · ${esc(health)}</dd></div><div><dt>Success / error</dt><dd>${esc(success)}<br>${esc(error)}</dd></div><div><dt>Circuit</dt><dd>${esc(circuit)} · ${esc(failures)} failures</dd></div></dl><div class="source-actions"><button class="button ghost compact" type="button" data-source-action="review" data-source-id="${esc(source.id||'')}" ${source.optional?'disabled':''}>Review</button><button class="button ghost compact" type="button" data-source-action="test" data-source-id="${esc(source.id||'')}" ${source.optional?'disabled':''}>Test</button><button class="button ${status === 'enabled' ? 'danger' : 'primary'} compact" type="button" data-source-action="toggle" data-source-id="${esc(source.id||'')}" ${source.optional||!available?'disabled':''}>${status === 'enabled' ? 'Disable' : 'Enable'}</button></div></article>`; }).join(''); }
|
||
function renderSourceRecords(items) { const list = $('sourceRecordsList'); if (!items.length) { list.innerHTML = '<div class="source-empty">No source records returned by the workspace.</div>'; return; } list.innerHTML = `<div class="source-record-table"><table><thead><tr><th>Record</th><th>Source</th><th>Status</th><th>Observed</th></tr></thead><tbody>${items.slice(0,25).map(record => `<tr><td>${esc(record.name || record.title || record.external_id || record.id || 'Unnamed record')}</td><td>${esc(record.source_name || record.source || 'Unknown source')}</td><td><span class="status">${esc(record.status || 'Pending')}</span></td><td>${esc(record.observed_at || record.created_at || 'Time unavailable')}</td></tr>`).join('')}</tbody></table></div>`; }
|
||
async function loadSources() { sourceMessage('Loading sources…'); $('sourcesList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source registry…</div>'; $('sourceRecordsList').innerHTML = '<div class="detail-loading" aria-live="polite">Loading source records…</div>'; try { const [sourcePayload, adapterPayload, recordPayload] = await Promise.all([jsonRequest('/api/v1/sources'), jsonRequest('/api/v1/sources/adapters'), jsonRequest('/api/v1/source-records?page_size=25')]); const configured=sourceItems(sourcePayload), registeredCodes=new Set(configured.map(sourceType)); const optional=sourceItems(adapterPayload).filter(adapter=>!registeredCodes.has(adapter.source_code)).map(adapter=>({...adapter,source_code:adapter.source_code,display_name:adapter.display_name,kind:adapter.source_code,optional:true,configured:false,available:false,enabled:false})); sources=[...configured,...optional]; renderSources(); renderSourceRecords(sourceItems(recordPayload)); $('sourcesUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; sourceMessage(sources.some(sourceState) ? '' : 'No live source is enabled. Optional integrations remain unavailable until configured and approved.'); } catch (error) { sources = []; renderSources(); renderSourceRecords([]); if (error.message !== 'unauthorized') sourceMessage(error.message || 'Unable to load sources.', true); } }
|
||
async function saveSource(event) { event.preventDefault(); const form = event.currentTarget, fields = Object.fromEntries(new FormData(form).entries()); if (fields.source_type === 'csv' && !fields.csv_content.trim()) { message('sourceFormMessage', 'CSV content is required for a CSV source.', true); return; } const config = {url:fields.url, terms_url:fields.terms_url, owner:fields.owner, rate_limit:fields.rate_limit}; if (fields.source_type === 'csv') config.csv = fields.csv_content; else config.rows = []; try { await jsonRequest('/api/v1/sources', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name:fields.name, kind:fields.source_type, config, enabled:false})}); message('sourceFormMessage', 'Source saved. It remains disabled until explicitly enabled.'); form.reset(); $('sourceCsvField').hidden = true; await loadSources(); } catch (error) { if (error.message !== 'unauthorized') message('sourceFormMessage', error.message || 'Unable to save source.', true); } }
|
||
async function sourceAction(id, action) { const source = sources.find(item => String(item.id) === String(id)); if (!source || source.optional) return; if (action === 'review') { selectedSourceId=id; document.querySelector(`[data-source-id="${CSS.escape(String(id))}"]`)?.scrollIntoView({behavior:'smooth',block:'center'}); sourceMessage('Source details are shown below. Review terms, owner, limits, health, and circuit state before enabling.'); return; } try { if (action === 'test') { await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}/test`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); sourceMessage('Source test completed.'); } else { const enabled = sourceState(source); await jsonRequest(`/api/v1/sources/${encodeURIComponent(id)}`, {method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:!enabled})}); sourceMessage(`Source ${enabled ? 'disabled' : 'enabled'} by the workspace.`); } await loadSources(); } catch (error) { if (error.message !== 'unauthorized') sourceMessage(error.message || `Unable to ${action} source.`, true); } }
|
||
async function runDiscovery(dryRun) { const form = $('discoveryForm'), data = Object.fromEntries(new FormData(form).entries()); data.dry_run = Boolean(dryRun); if (!data.source_id || !data.query.trim()) { message('discoveryMessage', 'Select a source and enter a query.', true); return; } const source = sources.find(item => String(item.id) === String(data.source_id)); if (!dryRun && !sourceState(source)) { message('discoveryMessage', 'This source is disabled. Enable it only after review.', true); return; } const button = dryRun ? $('discoveryDryRunBtn') : $('discoveryRunBtn'); button.disabled = true; message('discoveryMessage', dryRun ? 'Validating query…' : 'Starting discovery…'); try { const query = await jsonRequest('/api/v1/discovery-queries', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({source_id:Number(data.source_id), name:data.query.trim().slice(0,80), query:{text:data.query.trim()}, dry_run:data.dry_run})}); if (!dryRun) await jsonRequest(`/api/v1/discovery-queries/${encodeURIComponent(query.id)}/run`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); message('discoveryMessage', dryRun ? 'Dry run completed; no discovery job was started.' : 'Discovery request accepted. Check Jobs for progress.'); } catch (error) { if (error.message !== 'unauthorized') message('discoveryMessage', error.message || 'Discovery request failed.', true); } finally { button.disabled = false; } }
|
||
let discoveryRuns = [], selectedDiscoveryRunId = null, discoveryPollTimer = null;
|
||
const discoveryStatus = run => String(run?.status || run?.state || 'queued').toLowerCase().replaceAll('_','-');
|
||
const discoveryLabel = state => ({queued:'Queued',running:'Running',succeeded:'Complete',failed:'Failed',cancelled:'Cancelled',partial:'Partial result',paused:'Paused',stale:'Stale'})[state] || state.replaceAll('-',' ');
|
||
const discoveryCandidates = run => payloadItems(run?.result||{}, ['items','candidates','businesses','prospects','results']);
|
||
const selectedSourceIds = () => [...($('directDiscoverySources')?.selectedOptions||[])].map(option=>Number(option.value)).filter(Number.isInteger);
|
||
function renderDiscoveryResult(run, job) {
|
||
const el = $('directDiscoveryResultState');
|
||
const status = discoveryStatus(job || run);
|
||
const candidates = discoveryCandidates(run);
|
||
const result = run?.result || {};
|
||
const progress = Math.max(0, Math.min(100, Number(job?.progress ?? run.progress ?? 0)));
|
||
const events = job?.events || job?.event_timeline || run?.events || [];
|
||
const errors = run?.errors || result.errors || [];
|
||
$('directDiscoveryResultTitle').textContent = 'Run ' + run.id + ' · ' + (run.criteria?.keywords || run.criteria?.keyword || 'Discovery brief');
|
||
$('directDiscoveryResultStatus').textContent = discoveryLabel(status);
|
||
const controls = '<div class="discovery-controls"><button class="button ghost compact" type="button" data-run-action="pause" ' + (!['queued','running'].includes(status) ? 'disabled' : '') + '>Ⅱ Pause</button><button class="button ghost compact" type="button" data-run-action="resume" ' + (status !== 'paused' ? 'disabled' : '') + '>▶ Resume</button><button class="button danger compact" type="button" data-run-action="cancel" ' + (!['queued','running','paused'].includes(status) ? 'disabled' : '') + '>Cancel</button><span class="small-label">Pause/resume require the job-control API.</span></div>';
|
||
const sourceRows = run.sources || run.source_health || [];
|
||
const health = sourceRows.length ? '<section class="source-health"><div class="subheading"><h4>Source health</h4><span class="small-label">' + sourceRows.length + ' sources</span></div><div class="source-health-grid">' + sourceRows.map(source => '<article><strong>' + esc(source.name || source.source_name || ('Source ' + (source.source_id || ''))) + '</strong><span class="source-health-status ' + esc(source.status || source.health || 'unknown') + '">' + esc(source.status || source.health || 'Unknown') + '</span><small>' + esc(source.result_count ?? source.results ?? 0) + ' results · ' + esc(source.error || 'No errors') + '</small><div class="source-health-actions"><button class="button ghost compact" type="button" data-source-retry="' + esc(source.source_id || source.id || '') + '">Retry</button><button class="button ghost compact" type="button" data-source-circuit="' + esc(source.source_id || source.id || '') + '">Circuit</button></div></article>').join('') + '</div></section>' : '<section class="source-health"><div class="subheading"><h4>Source health</h4></div><p class="muted">Per-source health will appear when the API returns source telemetry.</p></section>';
|
||
const log = events.length ? events.map(event => '<li><span class="timeline-dot"></span><div><strong>' + esc(event.message || event.type || 'Run event') + '</strong><small>' + esc(event.created_at || event.timestamp || '') + '</small></div></li>').join('') : '<li class="muted">No persisted events returned yet.</li>';
|
||
const stats = '<div class="discovery-stat-grid"><div><small>Progress</small><strong>' + progress + '%</strong></div><div><small>Records</small><strong>' + esc(run.result_count ?? candidates.length) + '</strong></div><div><small>Daily limit</small><strong>' + esc(run.daily_limit ?? '—') + '</strong></div><div><small>Errors</small><strong>' + (Array.isArray(errors) ? errors.length : 0) + '</strong></div></div>';
|
||
const cards = candidates.length ? candidates.slice(0, 50).map(item => '<article class="discovery-candidate"><strong>' + esc(item.name || item.business_name || item.title || 'Unnamed candidate') + '</strong><span>' + esc(item.website || item.url || item.domain || 'No URL returned') + '</span><small>' + esc(item.source_url || item.source || 'Source provenance unavailable') + ' · ' + esc(item.confidence || item.score || 'Confidence unknown') + '</small></article>').join('') : '<p class="muted">No candidates returned by this run.</p>';
|
||
if (['running','queued','paused'].includes(status)) { el.innerHTML = controls + stats + '<p>' + esc(job?.progress_message || 'Processing approved sources within bounded limits.') + '</p><div class="progress-track"><span style="width:' + progress + '%"></span></div><div class="discovery-two-col"><section class="live-log"><h4>Live log</h4><ol>' + log + '</ol></section>' + health + '</div>'; return; }
|
||
const partial = status === 'partial' || run.partial === true || result.partial === true;
|
||
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>';
|
||
}
|
||
async function discoveryAction(action,run){ if(!run?.id)return; if(action!=='cancel'&&!['pause','resume'].includes(action))return; if(action==='cancel'&&!window.confirm('Cancel this discovery run? The current partial result will remain reviewable.'))return; try{await jsonRequest(`/api/v1/discovery-runs/${encodeURIComponent(run.id)}/${action}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});message('directDiscoveryMessage',`Discovery run ${action} requested.`);await loadDiscoveryRuns();}catch(error){if(error.message!=='unauthorized')message('directDiscoveryMessage',error.message,true);} }
|
||
async function loadDiscoveryRun(run) { selectedDiscoveryRunId=run.id; renderDiscoveryResult(run); try { const job=await jsonRequest(`/api/v1/jobs/${encodeURIComponent(run.job_id)}`); renderDiscoveryResult(run,job); if(['queued','running','paused'].includes(discoveryStatus(job))){clearTimeout(discoveryPollTimer);discoveryPollTimer=setTimeout(loadDiscoveryRuns,3000);} } catch(error){if(error.message!=='unauthorized')renderDiscoveryResult(run,{status:'error',message:error.message});} }
|
||
function renderDiscoveryRuns() { const el=$('directDiscoveryRunsState'); if(!discoveryRuns.length){el.innerHTML='<div class="discovery-empty">No discovery runs yet. Start with a market brief and approved sources.</div>';return;} el.innerHTML=discoveryRuns.map(run=>`<button class="discovery-run-row ${String(run.id)===String(selectedDiscoveryRunId)?'selected':''}" type="button" data-discovery-run-id="${esc(run.id)}"><span><strong>Run ${esc(run.id)}</strong><small>${esc(run.criteria?.keywords||run.criteria?.keyword||'Discovery brief')} · ${esc(run.criteria?.city||run.criteria?.location||'Global')}</small></span><span class="discovery-run-status ${esc(discoveryStatus(run))}">${esc(discoveryLabel(discoveryStatus(run)))}</span><small>${esc(run.result_count??discoveryCandidates(run).length)} records</small></button>`).join(''); }
|
||
async function loadDiscoveryRuns() { const state=$('directDiscoveryRunsState'); if(state)state.innerHTML='<div class="detail-loading" aria-live="polite">Loading discovery runs…</div>'; try { const payload=await jsonRequest('/api/v1/discovery-runs?page_size=50'); discoveryRuns=payloadItems(payload,['items','runs']); const jobsById=new Map(await Promise.all(discoveryRuns.map(async run=>{try{return [String(run.job_id),await jsonRequest(`/api/v1/jobs/${encodeURIComponent(run.job_id)}`)]}catch{return [String(run.job_id),{}]}}))); discoveryRuns=discoveryRuns.map(run=>({...run,status:jobsById.get(String(run.job_id))?.status||run.status||'queued'})); renderDiscoveryRuns(); const selected=discoveryRuns.find(run=>String(run.id)===String(selectedDiscoveryRunId))||discoveryRuns[0]; if(selected)loadDiscoveryRun(selected); } catch(error) { if(error.message!=='unauthorized'&&state)state.innerHTML=`<div class="detail-error" role="alert"><strong>Unable to load discovery runs</strong><p>${esc(error.message)}</p><button class="button ghost compact" id="retryDiscoveryRunsBtn" type="button">Try again</button></div>`; } }
|
||
async function submitDirectDiscovery(event) { event.preventDefault(); const form=event.currentTarget, button=$('directDiscoveryRunBtn'), fields=Object.fromEntries(new FormData(form).entries()), seeds=fields.seed_urls.split(/\r?\n|,/).map(value=>value.trim()).filter(Boolean), dryRun=$('directDiscoveryDryRun')?.checked; if(!fields.keywords.trim()){message('directDiscoveryMessage','Enter a category or keyword.',true);return;} if(seeds.length>5){message('directDiscoveryMessage','Use no more than 5 public seed URLs.',true);return;} if(dryRun){message('directDiscoveryMessage',`Plan validated: ${selectedSourceIds().length||'criteria-first'} source selection, max ${fields.max_candidates} records, ${fields.daily_limit}/day. No job created.`);return;} button.disabled=true; message('directDiscoveryMessage','Starting bounded discovery job…'); try { const criteria={keywords:fields.keywords.trim(),city:fields.city.trim(),province:fields.province.trim(),country:fields.country.trim()}; const payload={criteria,source_ids:selectedSourceIds(),daily_limit:Number(fields.daily_limit),schedule:fields.schedule,max_pages:Number(fields.max_pages),max_candidates:Number(fields.max_candidates),idempotency_key:`discovery-${Date.now()}-${Math.random().toString(36).slice(2)}`}; if(seeds.length)payload.seed_urls=seeds; const job=await jsonRequest('/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.'); await loadDiscoveryRuns(); const run=discoveryRuns.find(item=>String(item.job_id)===String(job.id)); if(run)loadDiscoveryRun(run); } catch(error) { if(error.message!=='unauthorized')message('directDiscoveryMessage',error.message||'Discovery request failed.',true); } finally {button.disabled=false;} }
|
||
|
||
function parseCsv(text){const lines=text.trim().split(/\r?\n/).filter(Boolean),cells=line=>line.match(/("[^"]*(?:""[^"]*)*"|[^,]+)(?=,|$)/g)?.map(x=>x.replace(/^"|"$/g,'').replaceAll('""','"'))||[];if(!lines.length)return[];const headers=cells(lines[0]);return lines.slice(1,11).map(l=>Object.fromEntries(cells(l).map((v,i)=>[headers[i]||`column_${i+1}`,v])));}
|
||
function renderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='<span>⊞</span><p>No data rows found</p>';return;}const h=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>`;}
|
||
async function login(event){event.preventDefault();const form=event.currentTarget,messageEl=$('loginMessage'),data=Object.fromEntries(new FormData(form).entries());messageEl.textContent='Signing in…';messageEl.className='form-message';try{const res=await fetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)});const body=await res.json().catch(()=>({}));if(!res.ok)throw new Error(body.error||'Invalid email or password.');await bootstrap();}catch(e){if(e.message!=='unauthorized'){messageEl.textContent=e.message;messageEl.className='form-message error';}}}
|
||
async function logout(){try{await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}}
|
||
async function bootstrap(){let res;try{res=await fetch(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;}let user;try{user=await res.json();}catch(e){showLogin('Unable to read the workspace session. Try again.');return;}showDashboard(user.user||user);const panelLoads=[loadData(),loadJobs(),loadSources(),loadDiscoveryRuns(),loadScorePanels(),loadSavedFilters(),loadReviewQueue(),loadCrmPipeline(),loadReports(),loadSuppressions(),loadProviderPolicy(),loadAiProviderSettings()];const results=await Promise.allSettled(panelLoads);const rejected=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);}}
|
||
document.addEventListener('submit',e=>{if(e.target.id==='contactForm')saveContact(e.target);if(e.target.id==='noteForm')saveNote(e.target);if(e.target.id==='pipelineForm')saveStage(e.target);});
|
||
document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='recalculateScoreBtn')recalculateScore();if(e.target.id==='generateAiSuggestionBtn')generateAiSuggestion();if(e.target.id==='createOutreachDraftBtn')createOutreachDraft();const outreachButton=e.target.closest?.('[data-outreach-action]');if(outreachButton)approveOutreachDraft(outreachButton.dataset.outreachId);const aiButton=e.target.closest?.('[data-ai-action]');if(aiButton)aiAction(aiButton.dataset.aiAction,aiButton.dataset.aiId);const scoreEdit=e.target.closest?.('[data-score-edit]');if(scoreEdit)updateScoreRule(scoreEdit.dataset.scoreEdit,scoreEdit.dataset.scorePoints);if(e.target.id==='extractContactsBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='refreshContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='retryContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId);if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);});
|
||
$('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('directDiscoveryForm').addEventListener('submit',submitDirectDiscovery);$('directDiscoveryRefreshBtn').addEventListener('click',loadDiscoveryRuns);$('directDiscoveryRunsState').addEventListener('click',e=>{const row=e.target.closest?.('[data-discovery-run-id]');if(row){const run=discoveryRuns.find(item=>String(item.id)===String(row.dataset.discoveryRunId));if(run)loadDiscoveryRun(run);}if(e.target.id==='retryDiscoveryRunsBtn')loadDiscoveryRuns();});$('searchInput').addEventListener('input',()=>{page=1;renderRows();});['scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id).addEventListener('change',()=>{page=1;loadData();}));$('pageSize').addEventListener('change',e=>{pageSize=Number(e.target.value);page=1;loadData();});$('nextPageBtn').addEventListener('click',()=>{if(hasNextPage){page+=1;loadData();}});$('refreshBtn').addEventListener('click',loadData);$('jobsRefreshBtn').addEventListener('click',()=>loadJobs());$('startDemoJobBtn').addEventListener('click',startDemoJob);$('sourcesRefreshBtn').addEventListener('click',loadSources);$('sourceForm').addEventListener('submit',saveSource);$('sourceType').addEventListener('change',e=>{$('sourceCsvField').hidden=e.target.value!=='csv';});$('discoveryForm').addEventListener('submit',e=>{e.preventDefault();runDiscovery(true);});$('discoveryRunBtn').addEventListener('click',()=>runDiscovery(false));$('sourcesList').addEventListener('click',e=>{const button=e.target.closest?.('[data-source-action]');if(button)sourceAction(button.dataset.sourceId,button.dataset.sourceAction);});$('addForm').addEventListener('submit',addProspect);$('csvInput').addEventListener('change',e=>{const file=e.target.files[0];if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}});$('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open'));document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView()));
|
||
$('savedFilterForm').addEventListener('submit',saveCurrentFilters);$('savedFilterSelect').addEventListener('change',e=>{ $('deleteSavedFilterBtn').disabled=!e.target.value;if(e.target.value)loadSavedFilter(e.target.value);});$('deleteSavedFilterBtn').addEventListener('click',deleteSavedFilter);$('reviewQueueState').addEventListener('change',e=>{const input=e.target.closest?.('[data-review-id]');if(input){if(input.checked)selectedReviewIds.add(String(input.dataset.reviewId));else selectedReviewIds.delete(String(input.dataset.reviewId));updateBulkState();}});$('selectAllReview').addEventListener('change',e=>{reviewQueue.forEach(p=>e.target.checked?selectedReviewIds.add(String(p.id)):selectedReviewIds.delete(String(p.id)));renderReviewQueue({items:reviewQueue,count:$('reviewQueueCount').textContent});});$('bulkVerifyBtn').addEventListener('click',()=>bulkReview('verify'));$('bulkRejectBtn').addEventListener('click',()=>bulkReview('reject'));$('reviewQueueState').addEventListener('click',e=>{if(e.target.id==='retryReviewQueueBtn')loadReviewQueue();});document.querySelectorAll('[data-dashboard-filter]').forEach(card=>card.addEventListener('click',()=>{const kind=card.dataset.dashboardFilter;if(kind==='review'||kind==='suppressed')applyFilters({status:kind});else if(kind==='high')applyFilters({score:'high'});else if(kind==='fresh')applyFilters({});else applyFilters({status:'all',score:'all'});}));
|
||
['sourceFilter','geographyFilter','categoryFilter','contactStatusFilter','scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id)?.addEventListener('change',()=>{page=1;loadData();}));
|
||
$('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=>{const selectButton=e.target.closest?.('[data-crm-select]');if(selectButton){selectedId=Number(selectButton.dataset.crmSelect);selectProspect(selectedId);loadInteractions(selectedId);$('crmActivity').scrollIntoView({behavior:'smooth',block:'start'});}const stageButton=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=>{const input=e.target.closest?.('[data-suppression-id]');if(input){if(input.checked)selectedSuppressionIds.add(String(input.dataset.suppressionId));else selectedSuppressionIds.delete(String(input.dataset.suppressionId));updateSuppressionSelection();}});$('suppressionState').addEventListener('click',e=>{const button=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=>{const action=e.target.closest?.('[data-run-action]');if(action){const run=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=>{const selectButton=e.target.closest?.('[data-crm-select]');if(selectButton){selectedId=Number(selectButton.dataset.crmSelect);selectProspect(selectedId);loadInteractions(selectedId);$('crmActivity').scrollIntoView({behavior:'smooth',block:'start'});}const stageButton=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=>{const input=e.target.closest?.('[data-suppression-id]');if(input){if(input.checked)selectedSuppressionIds.add(String(input.dataset.suppressionId));else selectedSuppressionIds.delete(String(input.dataset.suppressionId));updateSuppressionSelection();}});$('suppressionState').addEventListener('click',e=>{const button=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);
|
||
bootstrap();
|
||
})();
|