expand manual prospect intelligence workflows
This commit is contained in:
+33
-63
@@ -3,72 +3,42 @@
|
||||
'use strict';
|
||||
const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, '');
|
||||
const endpoint = (path) => `${API_BASE}${path}`;
|
||||
let prospects = [];
|
||||
let selectedId = null;
|
||||
let currentUser = null;
|
||||
let prospects = [], selectedId = null, selectedDetail = null, currentUser = null;
|
||||
let page = 1, pageSize = 10, hasNextPage = false;
|
||||
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.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 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'); }
|
||||
return response;
|
||||
}
|
||||
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.name || currentUser.full_name || currentUser.email || 'Workspace member';
|
||||
const role = currentUser.role || currentUser.roles?.[0] || 'Member';
|
||||
$('userIdentity').textContent = `${name} · ${role}`;
|
||||
$('userAvatar').textContent = name.split(/\s+/).map(x => x[0]).join('').slice(0,2).toUpperCase();
|
||||
$('loginScreen').hidden = true; $('dashboardShell').hidden = false;
|
||||
}
|
||||
function renderMetrics(summary) {
|
||||
const total = Number(summary?.businesses ?? summary?.total ?? prospects.length);
|
||||
const high = prospects.filter(p => scoreFor(p) >= 80).length;
|
||||
const review = prospects.filter(p => statusOf(p) === 'review').length;
|
||||
const 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 = summary?.needs_review ?? review; $('metricHigh').textContent = summary?.high_fit ?? high; $('metricFresh').textContent = `${summary?.freshness_under_7d ?? fresh}%`;
|
||||
}
|
||||
function filtered() {
|
||||
const q = $('searchInput').value.trim().toLowerCase(), sf = $('scoreFilter').value, st = $('statusFilter').value;
|
||||
return prospects.filter(p => { const s=scoreFor(p), text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(); return (!q || text.includes(q)) && (sf==='all' || (sf==='high'&&s>=80) || (sf==='medium'&&s>=60&&s<80) || (sf==='low'&&s<60)) && (st==='all' || statusOf(p)===st); });
|
||||
}
|
||||
function renderRows() {
|
||||
const rows = filtered(); $('resultCount').textContent = `Showing ${rows.length} prospect${rows.length===1?'':'s'}`;
|
||||
$('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="${p.id===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>';
|
||||
document.querySelectorAll('#prospectRows tr[data-id]').forEach(row => row.addEventListener('click', () => { selectedId = Number(row.dataset.id); renderRows(); renderDetail(); }));
|
||||
}
|
||||
function renderDetail() {
|
||||
const p = prospects.find(x => Number(x.id) === Number(selectedId)); if (!p) return;
|
||||
const s=scoreFor(p), st=statusOf(p), f=freshness(p), factors=p.score_factors||p.factors||[], blocked=st==='review' || st==='suppressed';
|
||||
$('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}">${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}</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><div class="detail-block"><h4>Evidence & signals</h4>${factors.length ? factors.map(x=>`<p class="evidence-line"><span>✓ ${esc(labelFactor(x))}</span><span class="confidence">${esc(p.confidence || 'Medium')}</span></p>`).join('') : '<p>Limited evidence available for this record.</p>'}</div><div class="detail-block"><h4>Data quality</h4><p class="evidence-line"><span>Last checked</span><span>${f.label}</span></p><p class="evidence-line"><span>Website</span><span>${p.website_domain?'Detected':'no detected website'}</span></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>`:''}`;
|
||||
}
|
||||
async function loadData() {
|
||||
$('apiStatus').textContent='● Connecting…'; $('apiStatus').classList.remove('live');
|
||||
try { const [listRes, summaryRes] = await Promise.all([request('/api/v1/businesses'), 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||[]); renderMetrics(summary); $('apiStatus').textContent='● API connected'; $('apiStatus').classList.add('live'); } catch (error) { if (error.message === 'unauthorized') { return; } prospects=[]; renderMetrics(null); $('apiStatus').textContent='● API unavailable'; }
|
||||
renderRows();
|
||||
}
|
||||
async function addProspect(event) { event.preventDefault(); const data=Object.fromEntries(new FormData(event.currentTarget).entries()); const msg=$('formMessage'); try { const res=await request('/api/v1/businesses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); const body=await res.json(); if(!res.ok) throw new Error(body.error||'Could not add prospect'); 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'; } } }
|
||||
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, message=$('loginMessage'); const data=Object.fromEntries(new FormData(form).entries()); message.textContent='Signing in…'; message.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') { message.textContent=e.message; message.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() { try { const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'}); if(res.status===401) { showLogin(); return; } if(!res.ok) throw new Error('Could not verify session.'); const user=await res.json(); showDashboard(user.user || user); await loadData(); } catch(e) { if(e.message !== 'unauthorized') showLogin('Unable to connect to the workspace. Try again.'); } }
|
||||
$('loginForm').addEventListener('submit',login); $('logoutBtn').addEventListener('click',logout); $('searchInput').addEventListener('input',renderRows); $('scoreFilter').addEventListener('change',renderRows); $('statusFilter').addEventListener('change',renderRows); $('refreshBtn').addEventListener('click',loadData); $('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()));
|
||||
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');} 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.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;}
|
||||
function renderMetrics(summary){const total=Number(summary?.businesses??summary?.total??prospects.length),high=prospects.filter(p=>scoreFor(p)>=80).length,review=prospects.filter(p=>statusOf(p)==='review').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=summary?.needs_review??review;$('metricHigh').textContent=summary?.high_fit??high;$('metricFresh').textContent=`${summary?.freshness_under_7d??fresh}%`;}
|
||||
function filterValues(){return {q:$('searchInput').value.trim(),score:$('scoreFilter').value,status:$('statusFilter').value,website_class:$('websiteClassFilter').value,pipeline_stage:$('pipelineFilter').value};}
|
||||
function filtered(){const {q,score:sf,status:st,website_class:wc,pipeline_stage:ps}=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';return(!q||text.includes(q.toLowerCase()))&&(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);});}
|
||||
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.website_class!=='all')params.set('website_class',f.website_class);if(f.pipeline_stage!=='all')params.set('pipeline_stage',f.pipeline_stage);return `?${params}`;}
|
||||
async function loadData(){ $('apiStatus').textContent='● Connecting…';$('apiStatus').classList.remove('live');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||[]);hasNextPage=Boolean(list.has_next??list.next_page??list.next??list.next_cursor);renderMetrics(summary);$('apiStatus').textContent='● API connected';$('apiStatus').classList.add('live');}catch(error){if(error.message==='unauthorized')return;prospects=[];hasNextPage=false;renderMetrics(null);$('apiStatus').textContent='● API unavailable';}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>`;
|
||||
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><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="qualified" ${stage==='qualified'?'selected':''}>Qualified</option><option value="review" ${stage==='review'?'selected':''}>Review</option><option value="suppressed" ${stage==='suppressed'?'selected':''}>Suppressed</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><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>`:''}`;}
|
||||
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';}}}
|
||||
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(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}}
|
||||
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==='retryDetailBtn'&&selectedId)loadDetail(selectedId);});
|
||||
$('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);$('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()));
|
||||
bootstrap();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user