[object Object]

← back to Rentv

crm: add faceted filter panel to left rail (City/Source/List/Segment)

38f6ecc262bf099928741ac232c282f0dab5b97a · 2026-08-12 23:16:44 -0700 · Steve Abrams

Collapsible facet groups with live cross-filtered counts, multi-select
(OR within a facet, AND across), top-12 + show-more, localStorage-persisted,
Clear-all. Hidden in DRE registry search mode. Verified in real Chrome:
0 console errors, City=Beverly Hills -> 102/5013, cross-facet AND + faceted
count recompute confirmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 38f6ecc262bf099928741ac232c282f0dab5b97a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 12 23:16:44 2026 -0700

    crm: add faceted filter panel to left rail (City/Source/List/Segment)
    
    Collapsible facet groups with live cross-filtered counts, multi-select
    (OR within a facet, AND across), top-12 + show-more, localStorage-persisted,
    Clear-all. Hidden in DRE registry search mode. Verified in real Chrome:
    0 console errors, City=Beverly Hills -> 102/5013, cross-facet AND + faceted
    count recompute confirmed.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/crm.html | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 79 insertions(+), 2 deletions(-)

diff --git a/public/crm.html b/public/crm.html
index ba680a28..054fe4c9 100644
--- a/public/crm.html
+++ b/public/crm.html
@@ -24,6 +24,26 @@
   .rail button.chip{display:flex;align-items:center;gap:10px;width:100%;background:none;border:0;color:#c3cbd3;font:700 13px 'Inter';padding:9px 12px;border-radius:9px;cursor:pointer;text-align:left}
   .rail button.chip:hover{background:#12161c}.rail button.chip.on{background:#12161c;color:#fff;box-shadow:inset 3px 0 0 var(--red)}
   .rail button.chip .i{font-size:15px;width:18px;text-align:center}.rail .c{margin-left:auto;font-size:11px;color:#8b939b}
+  /* ── left-rail faceted filters ── */
+  .rail .fhead{display:flex;align-items:center;justify-content:space-between;padding:14px 10px 2px}
+  .rail .fhead .h{padding:0;font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:#6b747d}
+  .rail .clr{background:none;border:0;color:#8b939b;font:700 11px 'Inter';cursor:pointer;padding:2px 7px;border-radius:6px;display:none}
+  .rail .clr:hover{color:#fff;background:#12161c}.rail .clr.show{display:inline-block}
+  .facet{border-top:1px solid #171d24}
+  .facet>.fg{display:flex;align-items:center;gap:7px;width:100%;background:none;border:0;color:#c3cbd3;font:800 11px 'Inter';letter-spacing:.05em;text-transform:uppercase;padding:9px 10px;cursor:pointer}
+  .facet>.fg:hover{color:#fff}.facet>.fg .caret{color:#6b747d;font-size:9px;transition:transform .18s}.facet.open>.fg .caret{transform:rotate(90deg)}
+  .facet>.fg .fc{margin-left:auto;font-size:10px;color:#7a838c;font-weight:700}
+  .facet .fvals{display:none;flex-direction:column;gap:2px;padding:0 4px 8px}
+  .facet.open .fvals{display:flex}
+  .fval{display:flex;align-items:center;gap:8px;width:100%;background:none;border:0;color:#aeb7c0;font:600 12px 'Inter';padding:6px 10px;border-radius:8px;cursor:pointer;text-align:left}
+  .fval:hover{background:#12161c;color:#e7ecf1}
+  .fval.on{background:#12161c;color:#fff;box-shadow:inset 3px 0 0 var(--red)}
+  .fval .ck{flex:0 0 auto;width:13px;height:13px;border:1.5px solid #333c46;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;line-height:1;color:#fff}
+  .fval.on .ck{background:var(--red);border-color:var(--red)}
+  .fval .fl{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  .fval .n{margin-left:auto;font-size:10.5px;color:#7a838c}
+  .facet .more{background:none;border:0;color:#8fb7ff;font:700 11px 'Inter';cursor:pointer;padding:5px 10px;text-align:left}
+  .facet .more:hover{text-decoration:underline}
   .content{overflow:hidden;position:relative;display:flex;flex-direction:column}
   /* control bar */
   .controls{display:flex;flex-wrap:wrap;align-items:center;gap:10px;padding:11px 14px;border-bottom:1px solid var(--line);background:#0b0e12}
@@ -153,6 +173,8 @@
   <nav class="rail" id="rail">
     <div class="h">Contact Types</div>
     <div id="typeChips"></div>
+    <div class="fhead"><div class="h">Filters</div><button class="clr" id="facetClear">✕ Clear</button></div>
+    <div id="facetGroups"></div>
     <div class="h">Registries</div>
     <div id="regChips"></div>
   </nav>
@@ -220,13 +242,26 @@ const A=(h,i,l)=>h?`<a class="act" href="${h}" target="_blank" rel="noopener nor
 const LS=(k,d)=>{try{const v=localStorage.getItem('crm.'+k);return v==null?d:v;}catch{return d;}};
 const setLS=(k,v)=>{try{localStorage.setItem('crm.'+k,v);}catch{}};
 const FIELDS=[['type','Type'],['firm','Firm'],['email','Email'],['phone','Phone'],['city','City'],['created','Created'],['drill','Actions']];
+// ── left-rail facet filters: each derives its values (with live counts) from the loaded contacts ──
+const prettySeg=v=>String(v||'').replace(/^[a-z]+:/,'').replace(/[-_]/g,' ').replace(/\b\w/g,m=>m.toUpperCase());
+const FACETS=[
+  {key:'city',          label:'City',    get:c=>c.city?[c.city]:[]},
+  {key:'source',        label:'Source',  get:c=>c.source?[c.source]:[]},
+  {key:'list_category', label:'List',    get:c=>c.list_category?[c.list_category]:[]},
+  {key:'segment',       label:'Segment', get:c=>c.segments||[], fmt:prettySeg},
+];
+const FACET_TOPN=12;
 let S={
   type: LS('type','all'), q:'', sort: LS('sort','newest'), mode: LS('mode','list'),
   dens: +LS('dens','3'), sel:null, sortDir: LS('sortDir','asc'),
   tcolW: (()=>{try{return JSON.parse(LS('tcolW',''))||{};}catch{return {};}})(),
   tcolHidden: (()=>{try{return JSON.parse(LS('tcolHidden',''))||{};}catch{return {};}})(),
   fields: (()=>{ try{return JSON.parse(LS('fields',''))||null;}catch{return null;} })() || {type:1,firm:1,email:1,phone:1,city:1,created:1,drill:1},
+  facet: (()=>{ try{return JSON.parse(LS('facet',''))||null;}catch{return null;} })() || {},
+  facetOpen: (()=>{ try{return JSON.parse(LS('facetOpen',''))||null;}catch{return null;} })() || {city:1},
+  facetMore: {},   // transient "show all values" per facet, not persisted
 };
+FACETS.forEach(f=>{ if(!Array.isArray(S.facet[f.key])) S.facet[f.key]=[]; if(S.facetOpen[f.key]==null) S.facetOpen[f.key]=0; });
 let DATA={contacts:[],counts:{all:0},types:[],registries:[],segments:[]};
 let REG={};   // cached registry search results by type
 
@@ -292,11 +327,52 @@ async function currentRows(){
     }
     return {rows:REG[key], hint:REG[key].length?'':'No matches in the '+S.type+' registry for “'+esc(term)+'”.'};
   }
+  const rows=baseContacts().filter(c=>facetPass(c,null));
+  return {rows, hint:''};
+}
+// contacts after the type-chip + search filters (shared by the list and the facet tallies)
+function baseContacts(){
   let rows=DATA.contacts.slice();
   if(S.type!=='all') rows=rows.filter(c=>c.type===S.type);
   const term=S.q.trim().toLowerCase();
   if(term) rows=rows.filter(c=>(c._s||[c.name,c.firm,c.email,c.city].filter(Boolean).join(' ').toLowerCase()).includes(term));
-  return {rows, hint:''};
+  return rows;
+}
+// does a contact satisfy every active facet? (exceptKey lets us compute a facet's own value counts)
+function facetPass(c, exceptKey){
+  return FACETS.every(f=>{
+    if(f.key===exceptKey) return true;
+    const act=S.facet[f.key]; if(!act||!act.length) return true;
+    const vals=f.get(c); return act.some(v=>vals.includes(v));   // OR within a facet, AND across facets
+  });
+}
+const activeFacetCount=()=>FACETS.reduce((n,f)=>n+((S.facet[f.key]||[]).length),0);
+// ── render the rail facet groups with live, cross-filtered value counts ──
+function renderFacets(){
+  const box=$('facetGroups'); if(!box) return;
+  const fh=document.querySelector('.rail .fhead');
+  if(isRegistry(S.type)){ box.innerHTML=''; if(fh) fh.style.display='none'; return; }  // registry = server search, not client-facetable
+  if(fh) fh.style.display='flex';
+  const base=baseContacts();
+  box.innerHTML=FACETS.map(f=>{
+    const tally=new Map();
+    for(const c of base){ if(!facetPass(c,f.key)) continue; for(const v of f.get(c)){ if(v==null||v==='') continue; tally.set(v,(tally.get(v)||0)+1); } }
+    const act=new Set(S.facet[f.key]||[]);
+    act.forEach(v=>{ if(!tally.has(v)) tally.set(v,0); });   // keep an active value visible even at 0 in the current context
+    const vals=[...tally.entries()].sort((a,b)=> b[1]-a[1] || String(a[0]).localeCompare(String(b[0])));
+    if(!vals.length) return '';
+    const showAll=S.facetMore[f.key], shown=showAll?vals:vals.slice(0,FACET_TOPN), fmt=f.fmt||(x=>x);
+    const rowsHTML=shown.map(([v,n])=>`<button class="fval ${act.has(v)?'on':''}" data-fk="${esc(f.key)}" data-fv="${esc(v)}"><span class="ck">${act.has(v)?'✓':''}</span><span class="fl" title="${esc(fmt(v))}">${esc(fmt(v))}</span><span class="n">${n.toLocaleString()}</span></button>`).join('');
+    const more=vals.length>FACET_TOPN ? `<button class="more" data-more="${esc(f.key)}">${showAll?'− Show less':'+ '+(vals.length-FACET_TOPN)+' more'}</button>` : '';
+    return `<div class="facet ${S.facetOpen[f.key]?'open':''}" data-facet="${esc(f.key)}">
+      <button class="fg" data-fg="${esc(f.key)}"><span class="caret">▶</span>${esc(f.label)}<span class="fc">${act.size?act.size+' sel':vals.length}</span></button>
+      <div class="fvals">${rowsHTML}${more}</div>
+    </div>`;
+  }).join('');
+  $('facetClear').classList.toggle('show', activeFacetCount()>0);
+  box.querySelectorAll('.fg').forEach(b=>b.onclick=()=>{ const k=b.dataset.fg; S.facetOpen[k]=S.facetOpen[k]?0:1; setLS('facetOpen',JSON.stringify(S.facetOpen)); renderFacets(); });
+  box.querySelectorAll('.fval').forEach(b=>b.onclick=()=>{ const k=b.dataset.fk,v=b.dataset.fv,arr=S.facet[k]; const i=arr.indexOf(v); if(i>=0)arr.splice(i,1); else arr.push(v); setLS('facet',JSON.stringify(S.facet)); S.sel=null; render(); });
+  box.querySelectorAll('.more').forEach(b=>b.onclick=()=>{ const k=b.dataset.more; S.facetMore[k]=!S.facetMore[k]; renderFacets(); });
 }
 // ── cross-reference the LABJ directory contacts against the OTHER RE-build DB already in this
 //    console: CRCP commercial brokers + LA-assessor owners (fetched by /api/crm). Match by
@@ -399,7 +475,7 @@ function rowHTML(c){
   </div>`;
 }
 async function render(){
-  renderFieldToggles(); applyDensity();
+  renderFieldToggles(); renderFacets(); applyDensity();
   $('sort').value=S.sort; $('dens').value=S.dens;
   document.querySelectorAll('#modeToggle button').forEach(b=>b.classList.toggle('on',b.dataset.mode===S.mode));
   const {rows,hint}=await currentRows();
@@ -474,6 +550,7 @@ let qt; $('q').addEventListener('input',e=>{ clearTimeout(qt); qt=setTimeout(()=
 $('sort').onchange=e=>{ S.sort=e.target.value; setLS('sort',S.sort); render(); };
 $('dens').oninput=e=>{ S.dens=+e.target.value; setLS('dens',S.dens); applyDensity(); };
 document.querySelectorAll('#modeToggle button').forEach(b=>b.onclick=()=>{ S.mode=b.dataset.mode; setLS('mode',S.mode); render(); });
+$('facetClear').onclick=()=>{ FACETS.forEach(f=>S.facet[f.key]=[]); setLS('facet',JSON.stringify(S.facet)); S.sel=null; render(); };
 
 // ── inline preview panel ── route web action links into a right-dock iframe instead of a new tab
 const PV={t:null};

← 08851796 5x: verify prod public surface — nginx Basic Auth returns 40  ·  back to Rentv  ·  crm: in-facet type-ahead for large filter facets (City/List/ 1ca6cbbc →