Wire frontend discovery and AI enrichment workflows

This commit is contained in:
Marco0300
2026-09-03 19:54:55 +02:00
parent 6269cdd6f1
commit 74871b5006
5 changed files with 52 additions and 14 deletions
+18 -6
View File
@@ -128,19 +128,21 @@
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;
const body=item?.suggestion || item?.text || item?.content || item?.recommendation || item?.message || 'No suggestion was returned.';
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><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>`;
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'].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 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']);
@@ -263,14 +265,24 @@
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) 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',stale:'Stale'})[state] || state.replaceAll('-',' ');
const discoveryCandidates = run => payloadItems(run?.result||{}, ['items','candidates','businesses','prospects','results']);
function renderDiscoveryResult(run, job) { const el=$('directDiscoveryResultState'), status=discoveryStatus(job||run), candidates=discoveryCandidates(run), result=run?.result||{}; $('directDiscoveryResultTitle').textContent=`Run ${run.id} · ${run.criteria?.keywords||run.criteria?.keyword||'Discovery brief'}`; $('directDiscoveryResultStatus').textContent=discoveryLabel(status); if(status==='running'||status==='queued'){el.innerHTML=`<div class="discovery-progress"><strong>${esc(discoveryLabel(status))}</strong><p>${esc(job?.progress_message||'The bounded discovery job is processing approved seed URLs.')}</p><div class="progress-track"><span style="width:${Math.max(0,Math.min(100,Number(job?.progress||0)))}%"></span></div></div>`;return;} const partial=status==='partial'||run.partial===true||result.partial===true; el.innerHTML=`${partial?'<div class="discovery-partial" role="status"><strong>Partial results</strong><p>The job reached a bounded limit or returned an incomplete source response. 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>`:''}<div class="discovery-result-meta"><span>${candidates.length} candidate${candidates.length===1?'':'s'}</span><span>Seed URLs: ${Array.isArray(run.seed_urls)?run.seed_urls.length:0}</span><span>${run.completed_at||run.updated_at||'Completion time unavailable'}</span></div>${candidates.length?`<div class="discovery-candidate-list">${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('')}</div>`:'<p class="muted">No candidates returned. This is distinct from a successful empty result.</p>'}`; }
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'].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 public seed URLs.</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')}</small></span><span class="discovery-run-status ${esc(discoveryStatus(run))}">${esc(discoveryLabel(discoveryStatus(run)))}</span><small>${esc(run.result_count??discoveryCandidates(run).length)} results</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); if(!fields.keywords.trim()||!seeds.length||seeds.length>5){message('directDiscoveryMessage','Enter criteria and 15 seed URLs.',true);return;} button.disabled=true; message('directDiscoveryMessage','Starting bounded discovery job…'); try { const job=await jsonRequest('/api/v1/discovery',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({criteria:{keywords:fields.keywords.trim(),location:fields.location.trim()},seed_urls:seeds,max_pages:Number(fields.max_pages),max_candidates:Number(fields.max_candidates),idempotency_key:`discovery-${Date.now()}-${Math.random().toString(36).slice(2)}`})}); 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);try{await loadData();await loadJobs();await loadSources();await loadScorePanels();await loadSavedFilters();await loadReviewQueue();await loadCrmPipeline();await loadReports();await loadSuppressions();await loadProviderPolicy();}catch(e){console.error('Workspace dashboard initialization failed',e);$('apiStatus').textContent='● Workspace partially loaded';$('apiStatus').classList.remove('live');setExplorerState('Some workspace panels could not be loaded. Use Refresh to retry.',true);}}
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);try{await loadData();await loadJobs();await loadSources();await loadDiscoveryRuns();await loadScorePanels();await loadSavedFilters();await loadReviewQueue();await loadCrmPipeline();await loadReports();await loadSuppressions();await loadProviderPolicy();}catch(e){console.error('Workspace dashboard initialization failed',e);$('apiStatus').textContent='● Workspace partially loaded';$('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);$('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()));
$('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'});}));
$('interactionForm').addEventListener('submit',saveInteraction);$('suppressionForm').addEventListener('submit',addSuppression);$('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();
+14 -5
View File
@@ -1,13 +1,22 @@
{
"schema": 1,
"version": "phase-16",
"entrypoints": ["config.js", "app.js", "styles.css"],
"publicAssets": ["index.html", "health.html", "error.html", "healthz"],
"entrypoints": [
"config.js",
"app.js",
"styles.css"
],
"publicAssets": [
"index.html",
"health.html",
"error.html",
"healthz"
],
"integrity": {
"config.js": "sha256-f9c7b4db3eab4cf54146bd25891b5103b09ae75da93c57b548cf57ae93e4a3f6",
"app.js": "sha256-979a3efe97234febdd0a3c1c68c00ef346d1601326ac2202f223e3438532e236",
"styles.css": "sha256-f665cd32e3ff089c04b27afaa37bde2ec34afbb88050ac9f24ed52bc17ac422a",
"index.html": "sha256-a40abc8af0b30ea9bb0005897d2231614eae3e26ceda0dc82dab1a16a78a634a",
"app.js": "sha256-c296037e7dfdb11543a18764f91cef8398f9c41c37805dfcd377acb4e089fd82",
"styles.css": "sha256-56ca598c8e8cbbcb91d81013c6a498f75193de79e33dd2d8d117df836fc43d57",
"index.html": "sha256-c25a164ebf59bc923f89dd5af17af4124fc0daf503ea52f34ff36ed52b3d5b4a",
"health.html": "sha256-c352a6f37aa24628cfc8d5709a70ff2d94181d192ed5fe00916cca9378a61d81",
"error.html": "sha256-f3cc28d2dfc9e8af112d0257c6ee47b9e9146e5da8590a754dd589bf43702aaf",
"healthz": "sha256-dc51b8c96c2d745df3bd5590d990230a482fd247123599548e0632fdbf97fc22"
+10
View File
@@ -44,6 +44,16 @@
<header class="topbar"><button class="mobile-menu" id="menuBtn" aria-label="Toggle navigation"></button><div class="crumb">Workspace <span>/</span> Growth pipeline</div><div class="top-actions"><span class="api-status" id="apiStatus">● Connecting…</span><span class="user-identity" id="userIdentity"></span><button class="icon-button" aria-label="Notifications"></button><button class="logout-button" id="logoutBtn" type="button">Log out</button><div class="avatar" id="userAvatar">?</div></div></header>
<div class="content">
<section class="hero" id="dashboard"><div><p class="eyebrow">EVIDENCE-LED PROSPECTING</p><h1>Good morning, Alex <span></span></h1><p class="hero-sub">Your pipeline has <strong id="heroCount">0 prospects</strong> ready for review.</p></div><button class="button primary" data-scroll="#add"> Add prospect</button></section>
<section class="direct-discovery-section" id="discoveryWorkspace" aria-labelledby="discoveryWorkspaceTitle" data-smoke="direct-discovery">
<div class="discovery-hero panel"><div><p class="eyebrow">BUILT-IN DISCOVERY</p><h2 id="discoveryWorkspaceTitle">Find prospects from approved seed sites</h2><p class="muted">Describe the market, provide up to five public seed URLs, and let the bounded workspace job return reviewable candidates. No manual source registration required.</p></div><span class="discovery-badge">Bounded · review first</span></div>
<div class="discovery-workspace-grid">
<article class="panel direct-discovery-form-panel"><div class="panel-heading"><div><p class="eyebrow">DISCOVERY BRIEF</p><h3>Set your criteria</h3></div><span class="small-label">Max 20 pages · 50 candidates</span></div>
<form id="directDiscoveryForm"><label>Keywords or market<input id="directDiscoveryKeywords" name="keywords" required maxlength="240" placeholder="e.g. solar installers, Cape Town"></label><label>Location <span class="optional">optional</span><input id="directDiscoveryLocation" name="location" maxlength="120" placeholder="Western Cape, South Africa"></label><label>Seed URLs <span class="optional">up to 5 · one per line</span><textarea id="directDiscoverySeeds" name="seed_urls" rows="4" required placeholder="https://directory.example/&#10;https://association.example/members"></textarea></label><div class="discovery-limit-grid"><label>Page limit<select id="directDiscoveryMaxPages" name="max_pages"><option value="5">5 pages</option><option value="10" selected>10 pages</option><option value="20">20 pages</option></select></label><label>Candidate limit<select id="directDiscoveryMaxCandidates" name="max_candidates"><option value="10">10 candidates</option><option value="25" selected>25 candidates</option><option value="50">50 candidates</option></select></label></div><div class="form-footer"><p id="directDiscoveryMessage" class="form-message" role="status" aria-live="polite"></p><button class="button primary" id="directDiscoveryRunBtn" type="submit">Run discovery <span aria-hidden="true"></span></button></div></form>
</article>
<aside class="panel discovery-runs-panel"><div class="panel-heading"><div><p class="eyebrow">RUN HISTORY</p><h3>Discovery runs</h3></div><button class="button ghost compact" id="directDiscoveryRefreshBtn" type="button">↻ Refresh</button></div><div id="directDiscoveryRunsState" class="discovery-runs-state" aria-live="polite"><div class="detail-loading">Sign in to load discovery runs.</div></div></aside>
</div>
<div class="panel discovery-results-panel" id="directDiscoveryResultsPanel"><div class="panel-heading"><div><p class="eyebrow">RESULT STATUS</p><h3 id="directDiscoveryResultTitle">Select a run to inspect results</h3></div><span id="directDiscoveryResultStatus" class="small-label">No run selected</span></div><div id="directDiscoveryResultState" aria-live="polite"><p class="muted">Results appear here with job state, partial-result handling, and candidate provenance.</p></div></div>
</section>
<section class="metrics" aria-label="Dashboard metrics">
<a class="metric-card metric-link" data-dashboard-filter="all" href="#explorer"><div class="metric-icon violet"></div><div><p>Total prospects</p><h2 id="metricTotal">0</h2><span class="trend neutral">● Current workspace</span></div></a>
<a class="metric-card metric-link" data-dashboard-filter="review" href="#explorer"><div class="metric-icon amber"></div><div><p>Needs review</p><h2 id="metricReview">0</h2><span class="trend neutral">● Human verification</span></div></a>
+2 -2
View File
@@ -57,7 +57,7 @@ const expectedIds = [
'loginScreen', 'loginForm', 'loginEmail', 'loginPassword', 'dashboardShell', 'logoutBtn', 'apiStatus',
'explorer', 'detailPanel', 'reviewQueueCount', 'reviewQueueState', 'savedFilterForm', 'savedFilterSelect',
'deleteSavedFilterBtn', 'bulkVerifyBtn', 'bulkRejectBtn', 'nextPageBtn', 'jobs', 'jobsList', 'jobDetailPanel',
'sources', 'sourcesList', 'sourceRecordsList', 'discoveryForm', 'crmPipeline', 'pipelineBoard', 'crmActivity',
'sources', 'sourcesList', 'sourceRecordsList', 'discoveryForm', 'discoveryWorkspace', 'directDiscoveryForm', 'directDiscoveryRunsState', 'directDiscoveryResultState', 'crmPipeline', 'pipelineBoard', 'crmActivity',
'interactionState', 'interactionForm', 'crmReports', 'pipelineReport', 'outcomesReport', 'activityReport',
'suppressionCenter', 'suppressionForm', 'suppressionState', 'outreachSettings', 'providerPolicyPanel',
'scoreRules', 'scoreRulesPanel', 'scoreDistributionPanel', 'mergeDialog', 'confirmMergeBtn'
@@ -73,7 +73,7 @@ check('dom.dynamic-markers', 'detail safety panels define dynamic smoke markers'
const routeContracts = [
'/api/v1/auth/me', '/api/v1/auth/login', '/api/v1/auth/logout', '/api/v1/dashboard/summary', '/api/v1/businesses',
'/api/v1/review-queue', '/api/v1/saved-filters', '/api/v1/businesses/bulk-review', '/api/v1/jobs', '/api/v1/sources',
'/api/v1/source-records', '/api/v1/discovery-queries', '/api/v1/merge-history', '/api/v1/scoring/summary', '/api/v1/score-rules',
'/api/v1/source-records', '/api/v1/discovery', '/api/v1/discovery-runs', '/api/v1/discovery-queries', '/api/v1/merge-history', '/api/v1/scoring/summary', '/api/v1/score-rules',
'/api/v1/pipeline-entries', '/api/v1/interactions', '/api/v1/reports/pipeline', '/api/v1/reports/outcomes', '/api/v1/reports/activity',
'/api/v1/suppressions', '/api/v1/ai-runs', '/api/v1/outreach/drafts', '/api/v1/outreach/provider-config',
'/matches', '/domains/check', '/domain-candidates', '/websites/scan', '/contacts/extract', '/score/recalculate', '/pipeline', '/verify'
+8 -1
View File
@@ -241,4 +241,11 @@ footer { display: flex; justify-content: space-between; gap: 16px; margin-top: 3
@media (max-width: 1100px) { .metrics { grid-template-columns: repeat(3, 1fr); } .filters { grid-template-columns: repeat(3, 1fr); } .filters .search-wrap { grid-column: 1 / -1; } .workspace-grid { grid-template-columns: 1fr; } .detail-panel { min-height: 0; } .reports-grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 820px) { .sidebar { width: 224px; } .main { margin-left: 224px; } .top-actions { gap: 9px; } .user-identity { display: none; } .jobs-grid, .sources-grid, .crm-two-col, .lower-grid { grid-template-columns: 1fr; } .pipeline-board { grid-template-columns: repeat(4, minmax(180px, 1fr)); } }
@media(max-width:700px) { .sidebar { width: min(286px, 86vw); transform: translateX(-105%); transition: transform .2s ease; } .sidebar.open { transform: translateX(0); } .main { margin-left: 0; } .mobile-menu { display: block; } .topbar { padding: 0 18px; } .crumb { display: none; } .top-actions { margin-left: auto; } .api-status { display: none; } .content { padding: 28px 16px 20px; } .hero { align-items: flex-start; flex-direction: column; margin-bottom: 22px; } .hero .button { width: 100%; } .metrics { grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; } .metric-card { padding: 13px; gap: 9px; } .metric-icon { width: 30px; height: 30px; font-size: 16px; } .metric-card h2 { font-size: 23px; } .filters { grid-template-columns: 1fr; } .filters .search-wrap { grid-column: auto; } .saved-view-controls { grid-template-columns: 1fr; } .saved-view-controls .inline-form { display: flex; } .saved-view-controls .inline-form input { flex: 1; } .form-grid, .score-breakdown-summary, .score-meta { grid-template-columns: 1fr; } .form-footer, .jobs-header, .sources-header, .crm-header, .panel-heading { align-items: stretch; flex-direction: column; } .form-footer .button, .jobs-actions .button, .crm-actions .button, .website-scan-actions .button, .contact-extraction-actions .button { width: 100%; } .table-meta { align-items: flex-start; flex-direction: column; } .table-meta > span:first-child { margin-right: 0; } .source-facts, .provider-policy-row dl { grid-template-columns: repeat(2, 1fr); } .provider-policy-row { grid-template-columns: 1fr; } .provider-policy-row .provider-status { justify-self: start; } .reports-grid { grid-template-columns: 1fr; } .website-scan-grid { grid-template-columns: 1fr; } .signal-list { grid-template-columns: repeat(2, 1fr); } .dns-capabilities { grid-template-columns: repeat(2, 1fr); } .dns-capabilities div:last-child { grid-column: 1 / -1; } .extracted-contact-facts { grid-template-columns: repeat(2, 1fr); } .contact-source { grid-column: 1 / -1; } .score-config-row { grid-template-columns: 1fr 1fr; } .score-config-row strong { grid-column: 1 / -1; } .pipeline-board { grid-template-columns: repeat(4, minmax(210px, 1fr)); } footer { align-items: flex-start; flex-direction: column; } .login-card { padding: 30px 24px; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
/* Direct discovery workspace */
.direct-discovery-section { margin-top: 22px; scroll-margin-top: 90px; }
.discovery-hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 20px; }
.discovery-hero h2 { margin: 2px 0 5px; }.discovery-hero p { max-width: 700px; margin: 0; }.discovery-badge { padding: 7px 10px; border: 1px solid #c8e2de; border-radius: 999px; color: var(--teal); background: var(--teal-soft); font-size: 11px; font-weight: 800; white-space: nowrap; }
.discovery-workspace-grid { display: grid; grid-template-columns: minmax(0, .95fr) minmax(330px, 1.05fr); gap: 18px; margin-top: 12px; }.direct-discovery-form-panel, .discovery-runs-panel, .discovery-results-panel { padding: 20px; }.direct-discovery-form-panel form { display: grid; gap: 13px; margin-top: 16px; }.discovery-limit-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }.discovery-runs-state { margin-top: 14px; border-top: 1px solid var(--line); }.discovery-run-row { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 10px; align-items: center; padding: 13px 0; border: 0; border-bottom: 1px solid var(--line); color: inherit; background: transparent; text-align: left; }.discovery-run-row:hover, .discovery-run-row.selected { background: #f4f9f8; }.discovery-run-row > span:first-child { display: grid; min-width: 0; gap: 2px; }.discovery-run-row small { overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }.discovery-run-status { padding: 4px 8px; border-radius: 999px; color: var(--blue); background: var(--blue-soft); font-size: 10px; font-weight: 800; white-space: nowrap; }.discovery-run-status.failed { color: var(--red); background: var(--red-soft); }.discovery-run-status.succeeded { color: var(--green); background: var(--green-soft); }.discovery-results-panel { margin-top: 12px; }.discovery-result-meta { display: flex; flex-wrap: wrap; gap: 14px; margin: 16px 0 10px; color: var(--muted); font-size: 11px; }.discovery-candidate-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 9px; }.discovery-candidate { display: grid; gap: 3px; padding: 11px; border: 1px solid var(--line); border-radius: 9px; background: #fff; }.discovery-candidate span, .discovery-candidate small { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; }.discovery-partial { margin: 12px 0; padding: 11px; border: 1px solid #ead6a9; border-radius: 9px; color: #77500c; background: var(--amber-soft); font-size: 12px; }.discovery-partial p { margin: 4px 0 0; }.discovery-empty { padding: 30px 10px; color: var(--muted); text-align: center; }
.ai-facts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 7px; margin: 13px 0; }.ai-facts div { padding: 8px; border: 1px solid var(--line); border-radius: 8px; background: #fff; }.ai-facts dt { color: var(--muted); font-size: 9px; font-weight: 800; text-transform: uppercase; }.ai-facts dd { margin: 3px 0 0; overflow-wrap: anywhere; font-size: 11px; }
@media(max-width:700px) { .discovery-hero { flex-direction: column; }.discovery-workspace-grid { grid-template-columns: 1fr; }.discovery-limit-grid, .ai-facts { grid-template-columns: 1fr 1fr; }.discovery-run-row { grid-template-columns: 1fr auto; }.discovery-run-row > small { grid-column: 1 / -1; } }