add crm pipeline and suppression center

This commit is contained in:
Marco0300
2026-09-03 12:07:58 +02:00
parent de97a2337d
commit c93dbd1ab4
12 changed files with 377 additions and 14 deletions
+12
View File
@@ -96,6 +96,18 @@ The UI must expose audit context for saved-filter changes, queue decisions, bulk
The current Phase 11 client now renders saved-view controls, a review queue capped at 100 visible records, selectable rows, and explicit verify/reject bulk review actions. It also renders clickable dashboard metric cards. Current limitations are material: saved views can be created/loaded/deleted in the client but update is not exposed; queue selection is visible-row-only and the UI does not show a server maximum/preview/per-record outcomes; dashboard links use client filter shortcuts rather than a complete server predicate; and suppression/merge eligibility and audit results still depend on the API response. No bulk action sends outreach or auto-merges.
## Phase 12 CRM UI contract
The Phase 12 UI presents a tenant-scoped pipeline, append-only interaction timeline, normalized outcomes, bounded reporting, and a suppression center. It must show the exact tenant/filter/as-of/timezone scope of every view and distinguish page counts, matching-set counts, event counts, and distinct-business counts. Loading, stale, unavailable, and error states are not zero. The API is authoritative; a hidden field, report ID, saved filter, or visible row cannot grant access.
Pipeline controls display the configured stages (`new`, `contacted`, `qualified`, `proposal`, `negotiation`, `won`, `lost`) and require an explicit reason for `won`, `lost`, and any configured reopen action. The UI must not offer direct jumps, edit historical transitions, or advance a stage merely because an interaction was added. Interactions show channel, actor, occurred time, provenance, safe summary, and outcome. Corrections are visibly appended/superseding, not destructive edits. `other` is distinct from a success or failure claim.
The outcome vocabulary is `connected`, `no_answer`, `left_message`, `meeting_booked`, `meeting_held`, `qualified`, `disqualified`, `won`, `lost`, and `other`. `other` is explicit uncertainty/catch-all metadata, not proof of success or failure; the UI must not invent an outcome for missing data. `do_not_contact` is a separate persistent suppression state, not a deliverability or engagement outcome, and must disable contact-related controls.
The suppression center shows normalized identifier, source, reason, scope, actor, effective time, and audit context. It must apply to records before display/export/report eligibility and must never silently delete a suppressed record. Unsuppression/removal is an explicit authorized action with confirmation and reason. Report and export screens must show freshness, as-of, timezone, filter snapshot, retention class where applicable, and safe partial/per-record results; they must not imply deliverability or outreach permission.
There is no send button, message composer, SMTP probe, validation email, campaign, delivery scheduler, or automated follow-up in Phase 12. The browser never contacts a prospect. Suppression, pipeline, outcome, report, and audit controls are presentation layers over server enforcement. The UI remains pilot-grade until browser/API smoke coverage verifies transition rejection, append-only corrections, outcome taxonomy, suppression precedence, report semantics, retention states, and cross-tenant non-disclosure.
## Remaining limitations
The static client has no client-side crawler, scanner, contact extractor, enrichment scheduler, outreach integration, availability provider, or SSE delivery. It can display server-provided Phase 9 observations, but production still requires server-side official-site scoping, SSRF/DNS-rebinding/redirect controls, hard extraction/page/byte/time/candidate budgets, durable history/cache isolation and retention/deletion, abuse/rate controls, suppression regression tests, and authenticated provenance/audit coverage. For domain intelligence, display registrable-domain/PSL version and unresolved reasons, DNS status and freshness/TTL (not “available”), independent MX/NS/TXT uncertainty, and association confidence with explainable evidence. Never auto-attach candidate domains or treat `nxdomain`/`no_data` as availability. CSV preview is capped for display and is not an import workflow.
+25 -2
View File
@@ -160,7 +160,29 @@
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';}}}
let jobs = [], selectedJobId = null, jobPollTimer = null;
// Phase 12 CRM surfaces: all actions are authenticated internal records; no send/outreach capability.
let crmPipelineItems = [], crmListMode = false, selectedSuppressionIds = new Set();
const crmStages = ['new','qualified','review','contacted','meeting','won','lost','suppressed'];
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(next===crmStage(item))return;const suppressed=statusOf(item)==='suppressed';if(suppressed&&next!=='suppressed'){window.alert('Suppressed prospects must remain in Suppressed. Remove the suppression rule first if policy allows.');select.value='suppressed';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.type||item.kind||'Interaction')}</strong><span class="outcome-chip">${esc(crmLabel(item.outcome||'No outcome'))}</span><p>${esc(item.summary||item.notes||item.body||'No summary')}</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.summary.trim()){message('interactionMessage','Add an internal summary 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({...data,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'));
@@ -207,10 +229,11 @@
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();await loadJobs();await loadSources();await loadScorePanels();await loadSavedFilters();await loadReviewQueue();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}}
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();await loadJobs();await loadSources();await loadScorePanels();await loadSavedFilters();await loadReviewQueue();await loadCrmPipeline();await loadReports();await loadSuppressions();}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==='recalculateScoreBtn')recalculateScore();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()));
$('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);
bootstrap();
})();
+22
View File
@@ -32,6 +32,9 @@
<a class="nav-item" href="#add"><span></span> Add prospects</a>
<a class="nav-item" href="#jobs" data-nav="jobs"><span></span> Jobs</a>
<a class="nav-item" href="#sources" data-nav="sources"><span></span> Sources</a>
<a class="nav-item" href="#crmPipeline" data-nav="crm"><span></span> CRM pipeline</a>
<a class="nav-item" href="#crmReports" data-nav="reports"><span></span> Reports</a>
<a class="nav-item" href="#suppressionCenter" data-nav="suppression"><span></span> Suppressions</a>
<a class="nav-item" href="#scoreRules" data-nav="score-rules"><span></span> Score rules</a>
</nav>
<div class="sidebar-foot"><div class="live-dot"></div><div><strong>Workspace live</strong><small>Data sync is healthy</small></div></div>
@@ -86,6 +89,25 @@
</section>
<section class="lower-grid" id="add"><article class="panel add-panel"><div class="panel-heading"><div><p class="eyebrow">INTAKE</p><h2>Add a prospect</h2></div><span class="small-label">Manual entry</span></div><form id="addForm"><div class="form-grid"><label>Company name<input required name="name" placeholder="Acme Inc."></label><label>Website <span class="optional">optional</span><input name="website" type="url" placeholder="https://acme.com"></label><label>Location<input name="location" placeholder="Cape Town, ZA"></label><label>Notes <span class="optional">optional</span><input name="description" placeholder="Why this is a fit…"></label></div><div class="form-footer"><p id="formMessage" class="form-message" role="status"></p><button class="button primary" type="submit">Add to review queue</button></div></form></article>
<article class="panel csv-panel"><div class="panel-heading"><div><p class="eyebrow">BULK INTAKE</p><h2>CSV preview</h2></div><label class="button ghost upload-label" for="csvInput">↑ Choose CSV</label><input id="csvInput" type="file" accept=".csv,text/csv" hidden></div><p class="muted">Preview rows before adding them to your review queue.</p><div id="csvPreview" class="csv-empty"><span></span><p>No file selected</p><small>CSV stays in your browser until you confirm.</small></div></article></section>
<section class="crm-section" id="crmPipeline" aria-labelledby="crmPipelineTitle" data-smoke="crm-pipeline">
<div class="crm-header panel"><div><p class="eyebrow">CRM WORKFLOW</p><h2 id="crmPipelineTitle">Pipeline board</h2><p class="muted">Move prospects through explicit human stages. Stage changes never send outreach.</p></div><div class="crm-actions"><button class="button ghost" id="crmRefreshBtn" type="button">↻ Refresh CRM</button><button class="button ghost" id="pipelineViewToggle" type="button" aria-pressed="false">☷ List view</button></div></div>
<div class="crm-safety" role="note"><strong>No outreach from this workspace.</strong> Pipeline actions, interactions, and follow-ups are internal CRM records only. Suppressed or unreviewed prospects remain unavailable for contact.</div>
<p id="crmMessage" class="crm-message" role="status" aria-live="polite"></p>
<div id="pipelineBoard" class="pipeline-board" aria-live="polite"><div class="detail-loading">Sign in to load the pipeline.</div></div>
</section>
<section class="crm-section" id="crmActivity" aria-labelledby="crmActivityTitle" data-smoke="crm-interactions">
<div class="crm-header panel"><div><p class="eyebrow">RELATIONSHIP HISTORY</p><h2 id="crmActivityTitle">Interactions &amp; follow-ups</h2><p class="muted">Capture outcomes and next steps without contacting anyone.</p></div></div>
<div class="crm-two-col"><article class="panel interaction-panel"><div id="interactionState" class="detail-loading">Select a prospect to load interactions.</div></article><article class="panel"><div class="panel-heading"><div><p class="eyebrow">ADD RECORD</p><h3>Log an interaction</h3></div><span class="small-label">Internal only</span></div><form id="interactionForm" class="crm-form"><label>Type<select name="type"><option value="note">Note</option><option value="call">Call</option><option value="meeting">Meeting</option><option value="email">Email (record only)</option></select></label><label>Outcome<select name="outcome"><option value="">Choose outcome</option><option value="no_response">No response</option><option value="interested">Interested</option><option value="not_a_fit">Not a fit</option><option value="follow_up">Follow-up requested</option></select></label><label>Follow-up date <span class="optional">optional</span><input name="follow_up_at" type="date"></label><label>Summary<textarea name="summary" rows="4" required placeholder="What happened? Keep this an internal record."></textarea></label><p id="interactionMessage" class="form-message" role="status"></p><button class="button primary" type="submit">Save interaction</button></form></article></div>
</section>
<section class="crm-section" id="crmReports" aria-labelledby="crmReportsTitle" data-smoke="crm-reports">
<div class="crm-header panel"><div><p class="eyebrow">REPORTING</p><h2 id="crmReportsTitle">CRM reports</h2><p class="muted">Tenant-scoped pipeline, outcomes, and activity summaries from the API.</p></div><button class="button ghost" id="reportsRefreshBtn" type="button">↻ Refresh reports</button></div>
<div class="reports-grid"><article class="panel report-panel" id="pipelineReport"><div class="detail-loading">Loading pipeline report…</div></article><article class="panel report-panel" id="outcomesReport"><div class="detail-loading">Loading outcomes report…</div></article><article class="panel report-panel" id="activityReport"><div class="detail-loading">Loading activity report…</div></article></div>
</section>
<section class="crm-section" id="suppressionCenter" aria-labelledby="suppressionTitle" data-smoke="suppression-center">
<div class="crm-header panel"><div><p class="eyebrow">SAFETY CENTER</p><h2 id="suppressionTitle">Suppression center</h2><p class="muted">Review and maintain tenant-scoped do-not-contact rules.</p></div><button class="button ghost" id="suppressionRefreshBtn" type="button">↻ Refresh suppressions</button></div>
<div class="suppression-warning" role="alert"><strong>Suppression always wins.</strong> Suppressed contacts and domains cannot be contacted, regardless of score, stage, or interaction outcome.</div>
<div class="crm-two-col"><article class="panel"><div class="panel-heading"><div><p class="eyebrow">ADD RULE</p><h3>Add suppression</h3></div><span class="small-label">Explicit confirmation required</span></div><form id="suppressionForm" class="crm-form"><label>Kind<select name="kind"><option value="email">Email</option><option value="domain">Domain</option><option value="phone">Phone</option></select></label><label>Value<input name="value" required placeholder="person@example.com"></label><label>Reason <span class="optional">optional</span><input name="reason" placeholder="Customer request / policy"></label><p id="suppressionMessage" class="form-message" role="status"></p><button class="button danger" type="submit">Add suppression</button></form></article><article class="panel"><div class="panel-heading"><div><p class="eyebrow">REGISTER</p><h3>Current suppressions</h3></div><div class="suppression-bulk-actions"><label class="checkbox-label"><input id="selectAllSuppressions" type="checkbox"> Select all</label><button class="button ghost compact" id="bulkReviewSuppressionsBtn" type="button" disabled>Review selected</button></div></div><div id="suppressionState" class="detail-loading">Sign in to load suppressions.</div></article></div>
</section>
<footer><span>ProspectOS</span><span>Evidence-led prospecting · <a href="#explorer">Review queue</a></span></footer>
</div>
</main>
+5
View File
@@ -53,5 +53,10 @@ frame.onload=async()=>{const d=frame.contentDocument;const js=await fetch('app.j
,['Phase 11 authenticated saved-view and bulk-review contracts',()=>js.includes('/api/v1/saved-filters')&&js.includes('/api/v1/review-queue')&&js.includes('/api/v1/businesses/bulk-review')&&js.includes('jsonRequest')&&js.includes('window.confirm')]
,['Phase 11 bounded selection and safe labels',()=>js.includes('slice(0,100)')&&js.includes('selected visible')&&js.includes('no outreach will be sent')&&d.querySelector('#reviewQueueCount')]
,['Phase 11 dashboard cards link to filters and responsive styles',()=>d.querySelectorAll('[data-dashboard-filter]').length>=5&&js.includes('applyFilters')&&js.includes('metricSuppressed')&&js.includes('saved-view-controls')&&js.includes('@media')]
,['Phase 12 CRM pipeline board/list and explicit transitions',()=>!!d.querySelector('[data-smoke="crm-pipeline"]')&&!!d.querySelector('#pipelineBoard')&&!!d.querySelector('#pipelineViewToggle')&&js.includes('/api/v1/pipeline-entries')&&js.includes('Stage transition saved')&&js.includes('outreach:false')&&js.includes('window.confirm')]
,['Phase 12 interaction timeline and metadata form',()=>!!d.querySelector('[data-smoke="crm-interactions"]')&&!!d.querySelector('#interactionForm')&&!!d.querySelector('#interactionState')&&js.includes('/api/v1/interactions?business_id=')&&js.includes('follow_up_at')&&js.includes('outcome')&&js.includes('No message was sent')]
,['Phase 12 reports panels and API states',()=>!!d.querySelector('[data-smoke="crm-reports"]')&&['pipelineReport','outcomesReport','activityReport'].every(id=>!!d.querySelector('#'+id))&&['/api/v1/reports/pipeline','/api/v1/reports/outcomes','/api/v1/reports/activity'].every(path=>js.includes(path))&&js.includes('No')&&js.includes('Unable to load')]
,['Phase 12 suppression center controls and safety',()=>!!d.querySelector('[data-smoke="suppression-center"]')&&!!d.querySelector('#suppressionForm')&&!!d.querySelector('#bulkReviewSuppressionsBtn')&&js.includes('/api/v1/suppressions')&&js.includes('data-remove-suppression')&&js.includes('Suppression always wins')&&js.includes('do-not-contact')]
,['Phase 12 CRM responsive styles and authenticated requests',()=>js.includes('credentials:\'include\'')&&js.includes('crm-two-col')&&js.includes('reports-grid')&&js.includes('pipeline-board')&&js.includes('@media')]
];let passed=0;document.querySelector('#checks').innerHTML=checks.map(([name,test])=>{const ok=test();if(ok)passed++;return `<li class="${ok?'pass':'fail'}">${ok?'PASS':'FAIL'}${name}</li>`}).join('');document.querySelector('#summary').textContent=`${passed}/${checks.length} checks passed`;};
</script>
+1
View File
@@ -7,5 +7,6 @@
.website-scan-actions .button{min-height:32px}.contact-extraction-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.contact-extraction-heading h4{margin:.1rem 0}.contact-extraction-actions{display:flex;gap:7px;flex-wrap:wrap}.contact-extraction-safety{border:1px solid #dcd8ff;border-radius:8px;background:var(--violet-soft);color:#5145a7;padding:9px 11px;font-size:12px;margin:10px 0}.extracted-contact-list{display:grid;gap:9px}.extracted-contact{border:1px solid var(--line);border-radius:9px;padding:11px;background:#fff}.extracted-contact-head{display:flex;justify-content:space-between;gap:10px;align-items:start}.extracted-contact-head strong{overflow-wrap:anywhere}.contact-confidence{color:var(--violet);font-size:11px;font-weight:700;white-space:nowrap}.extracted-contact-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;margin:10px 0 0}.extracted-contact-facts div{border:1px solid var(--line);border-radius:7px;padding:7px}.extracted-contact-facts dt{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}.extracted-contact-facts dd{margin:3px 0 0;font-size:12px;overflow-wrap:anywhere}.extracted-contact-facts a{color:var(--violet)}.contact-source{grid-column:1 / -1}.suppression-status{color:var(--green)}.contact-extraction-empty{padding:18px 4px}.contact-extraction-empty strong{color:var(--muted)}@media(max-width:700px){.website-scan-heading{flex-direction:column}.website-scan-actions{width:100%}.website-scan-actions .button{flex:1}.website-scan-grid{grid-template-columns:1fr}.signal-list{grid-template-columns:repeat(2,minmax(0,1fr))}.contact-extraction-heading{flex-direction:column}.contact-extraction-actions{width:100%}.contact-extraction-actions .button{flex:1}.extracted-contact-facts{grid-template-columns:repeat(2,minmax(0,1fr))}.contact-source{grid-column:1 / -1}}
.metric-link{color:inherit;text-decoration:none}.metric-link:hover{border-color:#c9c3ff;transform:translateY(-1px)}.saved-view-controls{display:grid;grid-template-columns:minmax(220px,1fr) 180px auto;gap:8px;align-items:center;padding:12px 0;border-top:1px solid var(--line)}.saved-view-controls .form-message{grid-column:1 / -1;margin:0}.saved-view-controls input,.saved-view-controls select{border:1px solid var(--line);border-radius:7px;padding:8px;font:inherit;min-width:0}.review-queue{margin:4px 0 14px;padding:14px;background:#fbfbff;border:1px solid var(--line);border-radius:10px}.queue-heading{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.queue-heading h3{margin:.1rem 0}.queue-state{margin-top:8px}.queue-list{display:grid;gap:6px;max-height:300px;overflow:auto}.queue-row{display:flex;align-items:center;gap:10px;padding:8px;border:1px solid var(--line);border-radius:7px;background:#fff;cursor:pointer}.queue-row input{flex:0 0 auto}.queue-row>span:nth-child(2){display:flex;flex-direction:column;min-width:0;flex:1}.queue-row small{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.queue-boundary{font-size:11px;color:var(--muted);margin:8px 0 0}.bulk-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px;padding-top:10px;border-top:1px solid var(--line)}.bulk-actions .checkbox-label{margin:0}.explorer-state{min-height:0;color:var(--green);font-size:12px;padding:4px 0}.explorer-state.error{color:var(--red)}
.status.review{background:var(--amber-soft);color:var(--amber)}
.crm-section{margin-top:28px;scroll-margin-top:24px}.crm-header{display:flex;align-items:flex-start;justify-content:space-between;gap:18px}.crm-header h2{margin:.15rem 0 .25rem}.crm-actions{display:flex;gap:8px;flex-wrap:wrap}.crm-safety,.suppression-warning{margin:14px 0;padding:12px 15px;border:1px solid #dcd8ff;border-radius:9px;background:var(--violet-soft);color:#5145a7}.suppression-warning{border-color:#f1d7a5;background:var(--amber-soft);color:#76500d}.crm-message{min-height:22px;color:var(--green);padding:6px 2px}.crm-message.error{color:var(--red)}.pipeline-board{display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:12px;overflow-x:auto;align-items:start}.pipeline-column{background:#f1f2f8;border:1px solid var(--line);border-radius:10px;padding:10px;min-height:180px}.pipeline-column-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.pipeline-column-head h3{margin:0;font-size:13px}.pipeline-card{background:#fff;border:1px solid var(--line);border-radius:9px;padding:10px;margin:8px 0;box-shadow:var(--shadow)}.pipeline-card.is-suppressed{border-color:#e9b8bd;background:#fffafa}.pipeline-card-link{display:grid;gap:3px;width:100%;border:0;background:none;text-align:left;color:inherit;padding:0;cursor:pointer}.pipeline-card-link small,.pipeline-card-link .score{font-size:11px;color:var(--muted)}.pipeline-card-actions{display:flex;gap:6px;margin-top:9px}.pipeline-card-actions select{min-width:0;flex:1;border:1px solid var(--line);border-radius:6px;padding:6px;font:inherit;font-size:12px}.pipeline-list-view{display:block}.pipeline-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px}.crm-column-empty,.crm-empty{color:var(--muted);font-size:12px;text-align:center;padding:20px 8px}.suppression-inline,.review-inline{font-size:11px;color:var(--red);margin:8px 0 0}.review-inline{color:var(--amber)}.crm-two-col{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(300px,.9fr);gap:18px}.crm-form{display:grid;gap:10px}.crm-form label{display:grid;gap:5px;font-size:12px;font-weight:650}.crm-form input,.crm-form select,.crm-form textarea{border:1px solid var(--line);border-radius:7px;padding:9px;font:inherit;font-weight:400}.crm-form textarea{resize:vertical}.crm-timeline{list-style:none;padding:0;margin:12px 0}.crm-timeline li{display:flex;gap:10px;border-top:1px solid var(--line);padding:12px 0}.crm-timeline li>div{display:grid;gap:4px;min-width:0}.crm-timeline p{margin:0;color:var(--muted)}.crm-timeline small{color:var(--muted);font-size:11px}.timeline-dot{width:9px;height:9px;flex:0 0 9px;margin-top:6px;border-radius:50%;background:var(--violet);box-shadow:0 0 0 4px var(--violet-soft)}.outcome-chip{font-size:11px;color:var(--violet);background:var(--violet-soft);border-radius:999px;padding:2px 7px;width:max-content}.reports-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.report-rows{display:grid;gap:8px}.report-rows div{display:flex;justify-content:space-between;gap:10px;border-top:1px solid var(--line);padding:9px 0}.report-rows strong{color:var(--violet)}.report-note{font-size:11px;color:var(--muted)}.suppression-bulk-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.suppression-bulk-actions .checkbox-label{margin:0}.suppression-list{display:grid}.suppression-row{display:flex;justify-content:space-between;gap:10px;align-items:center;border-top:1px solid var(--line);padding:11px 0}.suppression-row .checkbox-label{margin:0;flex:1}.suppression-row .checkbox-label span{display:grid;gap:2px;min-width:0}.suppression-row small{color:var(--muted);overflow-wrap:anywhere}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:1000px){.pipeline-board{grid-template-columns:repeat(4,minmax(220px,1fr))}.reports-grid{grid-template-columns:1fr 1fr}}@media(max-width:700px){.crm-header{flex-direction:column}.crm-actions,.crm-actions .button{width:100%}.crm-actions .button{flex:1}.crm-two-col,.reports-grid{grid-template-columns:1fr}.pipeline-board{grid-template-columns:repeat(4,minmax(235px,1fr))}.pipeline-list{grid-template-columns:1fr}.suppression-row{align-items:flex-start}.suppression-bulk-actions{width:100%}}
@media(max-width:700px){.saved-view-controls{grid-template-columns:1fr}.saved-view-controls .inline-form{display:flex}.saved-view-controls .inline-form input{flex:1}.queue-row{align-items:flex-start}.bulk-actions .button{flex:1}.metric-link{min-width:0}}
.score-overview{margin-top:18px}.score-distribution-panel{min-width:0}.score-rules-section{margin-top:18px;scroll-margin-top:24px}.score-breakdown-panel{background:#fbfbff;border-radius:10px;padding:15px}.score-panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.score-panel-heading h4{margin:.1rem 0}.score-breakdown-summary{display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:8px;margin:12px 0}.score-breakdown-summary>div,.score-meta div{border:1px solid var(--line);border-radius:8px;background:#fff;padding:10px}.score-breakdown-summary small,.score-breakdown-summary b{display:block}.score-breakdown-summary small,.score-meta dt{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.05em}.score-breakdown-summary b{margin-top:4px}.score-total{display:block;font-size:28px;line-height:1.1;margin-top:3px}.score-total small{display:inline;font-size:12px;margin-left:3px}.score-meta{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:0}.score-meta dd{margin:3px 0 0}.score-rules-explanation{margin-top:13px}.score-rules-explanation h5{margin:0 0 7px}.score-rule-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 8px;border-top:1px solid var(--line);padding:9px 0}.score-rule-row span{font-weight:650}.score-rule-row strong{color:var(--violet);font-size:12px}.score-rule-row small{grid-column:1 / -1;color:var(--muted)}.score-safety,.score-config-note{border:1px solid #dcd8ff;border-radius:8px;background:var(--violet-soft);color:#5145a7;padding:9px 11px;font-size:12px;margin:12px 0 0}.score-rule-table{border-top:1px solid var(--line);margin-top:12px}.score-config-row{display:grid;grid-template-columns:minmax(140px,1.4fr) .8fr .7fr .7fr auto;gap:10px;align-items:center;padding:11px 0;border-bottom:1px solid var(--line)}.score-config-row span{font-size:12px}.score-config-row span:nth-child(2){color:var(--green);font-weight:700}.score-config-row small{color:var(--muted)}.score-config-row button:disabled{opacity:.7;cursor:not-allowed}.score-distribution-list{display:grid;gap:10px;margin-top:12px}.distribution-row{display:grid;grid-template-columns:minmax(100px,1fr) 42px minmax(100px,2fr);gap:10px;align-items:center}.distribution-row strong{text-align:right}.distribution-track{height:8px;border-radius:99px;background:var(--line);overflow:hidden}.distribution-track i{display:block;height:100%;background:var(--violet);border-radius:inherit}@media(max-width:700px){.score-breakdown-summary{grid-template-columns:1fr 1fr}.score-breakdown-summary>div:first-child{grid-column:1 / -1}.score-config-row{grid-template-columns:1fr 1fr}.score-config-row strong{grid-column:1 / -1}.score-config-row button{justify-self:start}.distribution-row{grid-template-columns:minmax(95px,1fr) 34px minmax(80px,1fr)}}