← 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 →