[object Object]

← back to Rentv

crm: full-width Table view — every scanned field a sortable column + Cross-Ref against the RE build DB (CRCP brokers + owners)

ae07d2ff799ec7cc76f9e9b8a638643fd2dacb83 · 2026-08-12 16:48:07 -0700 · Steve Abrams

- Table mode (List|Grid|Table): 17 columns (photo, List, #, Name, Title, Firm, Cross-Ref, City, Phone, Email, Website, Employees, Founded, Detail, Address, Source, Created)
- click any column header to sort (asc/desc toggle), sticky header + sticky first col, horizontal scroll, localStorage-persisted
- Cross-Ref: matches each LABJ contact against CRCP commercial brokers + LA-assessor owners by normalized firm/name; badge shows match count, click to search, detail panel lists the matches

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

Files touched

Diff

commit ae07d2ff799ec7cc76f9e9b8a638643fd2dacb83
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 12 16:48:07 2026 -0700

    crm: full-width Table view — every scanned field a sortable column + Cross-Ref against the RE build DB (CRCP brokers + owners)
    
    - Table mode (List|Grid|Table): 17 columns (photo, List, #, Name, Title, Firm, Cross-Ref, City, Phone, Email, Website, Employees, Founded, Detail, Address, Source, Created)
    - click any column header to sort (asc/desc toggle), sticky header + sticky first col, horizontal scroll, localStorage-persisted
    - Cross-Ref: matches each LABJ contact against CRCP commercial brokers + LA-assessor owners by normalized firm/name; badge shows match count, click to search, detail panel lists the matches
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/crm.html | 103 +++++++++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 91 insertions(+), 12 deletions(-)

diff --git a/public/crm.html b/public/crm.html
index 380d71b2..d8a0dbb4 100644
--- a/public/crm.html
+++ b/public/crm.html
@@ -76,6 +76,22 @@
   .seg-chip{background:#0e141a;border:1px solid #2a333d;border-radius:8px;padding:4px 9px;font-size:11.5px;font-weight:700;color:#c3cbd3}
   .empty{padding:50px 20px;text-align:center;color:#6b747d}
   .hint{padding:9px 14px;font-size:12px;color:#8b939b;background:#10161d;border-bottom:1px solid var(--line)}
+  /* ── table mode: every scanned field a sortable column, full-width across the screen ── */
+  .content.tablemode .listwrap{flex:1;min-height:0;overflow:hidden;display:flex}
+  .content.tablemode #rows{flex:1;min-height:0}
+  .tblwrap{overflow:auto;width:100%;height:100%}
+  table.tbl{border-collapse:collapse;width:max-content;min-width:100%;font-size:calc(var(--fs) - .5px)}
+  .tbl thead th{position:sticky;top:0;z-index:3;background:#0d1014;border-bottom:1px solid var(--line);padding:9px 12px;text-align:left;white-space:nowrap;cursor:pointer;color:#9aa4ad;font-weight:800;user-select:none}
+  .tbl thead th:first-child{position:sticky;left:0;z-index:4}
+  .tbl thead th:hover,.tbl thead th.sorted{color:#fff}
+  .tbl tbody td{padding:7px 12px;border-bottom:1px solid #171d24;white-space:nowrap;max-width:340px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}
+  .tbl tbody td:first-child{position:sticky;left:0;background:#0d1014;z-index:1}
+  .tbl tbody tr{cursor:pointer}.tbl tbody tr:hover td{background:#141b22}.tbl tbody tr.sel td{background:#11161c}
+  .tbl .dim{color:#6b747d}
+  .tbl td.cph{padding:4px 8px}
+  .tbl .tav{width:34px;height:34px;border-radius:7px;object-fit:cover;object-position:center top;border:1px solid var(--line);display:block}
+  .tbl a.act{color:#8fb7ff;text-decoration:none}.tbl a.act:hover{text-decoration:underline}
+  .xref{background:#12161c;border:1px solid var(--red);color:#e7ecf1;border-radius:20px;padding:3px 9px;font:800 11px 'Inter';cursor:pointer;white-space:nowrap}.xref:hover{background:var(--red);color:#fff}
   /* ── inline preview panel (right dock) ── opens web action links in an iframe instead of a new tab */
   .pv-scrim{position:fixed;inset:0;background:rgba(6,9,12,.5);opacity:0;pointer-events:none;transition:.2s;z-index:970}.pv-scrim.open{opacity:1;pointer-events:auto}
   .pv{position:fixed;top:0;right:0;bottom:0;width:46vw;min-width:380px;max-width:820px;background:#0b0e12;border-left:1px solid var(--line);transform:translateX(102%);transition:transform .26s cubic-bezier(.4,0,.2,1);z-index:971;display:flex;flex-direction:column;box-shadow:-2px 0 44px rgba(0,0,0,.5)}
@@ -138,6 +154,7 @@
       <div class="seg-toggle" id="modeToggle">
         <button data-mode="list" class="on">☰ List</button>
         <button data-mode="grid">▦ Grid</button>
+        <button data-mode="table">▤ Table</button>
       </div>
       <label title="Density">🔍 <input type="range" id="dens" min="1" max="6" step="1" value="3"></label>
       <div class="fieldtoggles" id="fieldToggles"></div>
@@ -189,7 +206,7 @@ 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']];
 let S={
   type: LS('type','all'), q:'', sort: LS('sort','newest'), mode: LS('mode','list'),
-  dens: +LS('dens','3'), sel:null,
+  dens: +LS('dens','3'), sel:null, sortDir: LS('sortDir','asc'),
   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},
 };
 let DATA={contacts:[],counts:{all:0},types:[],registries:[],segments:[]};
@@ -210,6 +227,7 @@ function applyDensity(){
 async function load(){
   try{ const r=await fetch('/api/crm',{credentials:'same-origin'}); DATA=await r.json(); }
   catch{ DATA={contacts:[],counts:{all:0},types:[],registries:[],segments:[],error:'load failed'}; }
+  try{ buildXref(); }catch(e){ console.warn('xref build failed', e); }
   renderRail(); render();
 }
 function renderRail(){
@@ -249,14 +267,61 @@ async function currentRows(){
   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:''};
 }
+// ── 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
+//    normalized firm name and by full person name. Annotates c.xref={count,ids,labels}. ──
+const _xnorm=s=>String(s||'').toLowerCase().replace(/\b(inc|llc|llp|lp|ltd|co|corp|corporation|company|group|the|and|of|assoc|associates|partners|realty|real|estate)\b/g,' ').replace(/[^a-z0-9]+/g,' ').trim();
+function buildXref(){
+  const byFirm=new Map(), byName=new Map();
+  const push=(m,k,v)=>{ if(!k)return; const a=m.get(k); if(a)a.push(v); else m.set(k,[v]); };
+  for(const c of (DATA.contacts||[])){
+    if(c.type==='commercial'||c.type==='owner'){
+      push(byFirm,_xnorm(c.firm),c);
+      const nn=_xnorm(c.name); if(nn.split(' ').length>=2) push(byName,nn,c);
+    }
+  }
+  for(const c of (DATA.contacts||[])){
+    if(c.type!=='directory') continue;
+    const seen=new Set(), hits=[];
+    for(const m of (byFirm.get(_xnorm(c.firm))||[])) if(!seen.has(m.id)){seen.add(m.id);hits.push(m);}
+    const nn=_xnorm(c.name);
+    if(nn.split(' ').length>=2) for(const m of (byName.get(nn)||[])) if(!seen.has(m.id)){seen.add(m.id);hits.push(m);}
+    if(hits.length) c.xref={count:hits.length, ids:hits.map(h=>h.id), labels:hits.slice(0,8).map(h=>`${h.name||h.firm} — ${typeLabel(h.type)}${h.dre_license?' · DRE '+h.dre_license:''}${h.city?' · '+h.city:''}`)};
+  }
+}
+// ── table mode columns: EVERY scanned field, each sortable ──
+const TABLE_COLS=[['photo',''],['list_category','List'],['rank','#'],['name','Name'],['title','Title'],['firm','Firm'],['xref','Cross-Ref'],['city','City'],['phone','Phone'],['email','Email'],['website','Website'],['employees','Employees'],['year_founded','Founded'],['hq','Detail'],['address','Address'],['source','Source'],['created_at','Created']];
+const NUMERIC=new Set(['rank','employees','year_founded','xref','segments','listings_count','agent_count']);
+function xrefBadge(c){ return (c.xref&&c.xref.count)?`<button class="xref" data-xref="${esc(c.firm||c.name)}" title="${esc(c.xref.labels.join(' | '))}">⇄ ${c.xref.count} in RE DB</button>`:'<span class="dim">—</span>'; }
+function tableHTML(rows){
+  const arrow=k=> S.sort===k?(S.sortDir==='desc'?' ▼':' ▲'):'';
+  const head=TABLE_COLS.map(([k,l])=>`<th data-col="${k}" class="${S.sort===k?'sorted':''}">${esc(l)}${arrow(k)}</th>`).join('');
+  const cell=c=>TABLE_COLS.map(([k])=>{
+    if(k==='photo') return `<td class="cph">${c.photo?`<img class="tav" src="${esc(c.photo)}" loading="lazy" onerror="this.remove()">`:''}</td>`;
+    if(k==='xref') return `<td>${xrefBadge(c)}</td>`;
+    if(k==='website'&&c.website){const u=/^https?:/i.test(c.website)?c.website:'https://'+c.website; return `<td><a class="act" href="${esc(u)}" target="_blank" rel="noopener noreferrer" data-pv="1" onclick="event.stopPropagation()">${esc(c.website)}</a></td>`;}
+    if(k==='email'&&c.email) return `<td><a class="act" href="mailto:${esc(c.email)}" onclick="event.stopPropagation()">${esc(c.email)}</a></td>`;
+    if(k==='phone'&&c.phone) return `<td><a class="act" href="tel:${esc(String(c.phone).replace(/[^\\d+]/g,''))}" onclick="event.stopPropagation()">${esc(c.phone)}</a></td>`;
+    if(k==='created_at') return `<td class="dim" title="${esc(c.created_at||'')}">${esc(fmtWhen(c.created_at))}</td>`;
+    let v=c[k]; if(k==='rank'&&v!=null) v='#'+v;
+    return `<td title="${esc(v==null?'':v)}">${esc(v==null?'':v)}</td>`;
+  }).join('');
+  return `<div class="tblwrap"><table class="tbl"><thead><tr>${head}</tr></thead><tbody>${rows.map(c=>`<tr data-id="${esc(c.id)}" class="${S.sel===c.id?'sel':''}">${cell(c)}</tr>`).join('')}</tbody></table></div>`;
+}
 function sortRows(rows){
-  const s=S.sort, cp=rows.slice(), cmp=(a,b)=>String(a||'').localeCompare(String(b||''));
-  if(s==='name') cp.sort((a,b)=>cmp(a.name,b.name));
-  else if(s==='firm') cp.sort((a,b)=>cmp(a.firm,b.firm)||cmp(a.name,b.name));
-  else if(s==='type') cp.sort((a,b)=>cmp(a.type,b.type)||cmp(a.name,b.name));
-  else if(s==='city') cp.sort((a,b)=>cmp(a.city,b.city)||cmp(a.name,b.name));
-  else if(s==='segments') cp.sort((a,b)=>((b.segments||[]).length-(a.segments||[]).length)||cmp(a.name,b.name));
-  else cp.sort((a,b)=>String(b.created_at||'').localeCompare(String(a.created_at||''))); // newest
+  let key=S.sort, dir=S.sortDir||'asc';
+  if(key==='newest'){ key='created_at'; dir='desc'; }
+  if(key==='segments') dir='desc';
+  const get=r=> key==='segments'?(r.segments||[]).length : key==='xref'?(r.xref?r.xref.count:0) : r[key];
+  const num=NUMERIC.has(key);
+  const cp=rows.slice();
+  cp.sort((a,b)=>{
+    let av=get(a),bv=get(b),c;
+    if(num) c=(Number(av)||0)-(Number(bv)||0);
+    else c=String(av==null?'':av).localeCompare(String(bv==null?'':bv));
+    if(!c) c=String(a.name||'').localeCompare(String(b.name||''));
+    return dir==='desc'?-c:c;
+  });
   return cp;
 }
 function drillFor(c){
@@ -300,13 +365,27 @@ async function render(){
   document.querySelectorAll('#modeToggle button').forEach(b=>b.classList.toggle('on',b.dataset.mode===S.mode));
   const {rows,hint}=await currentRows();
   const sorted=sortRows(rows);
-  document.querySelector('.content').classList.toggle('grid', S.mode==='grid');
+  const content=document.querySelector('.content');
+  content.classList.toggle('grid', S.mode==='grid');
+  content.classList.toggle('tablemode', S.mode==='table');
   $('hint').style.display=hint?'block':'none'; $('hint').innerHTML=hint;
   $('countline').textContent = isRegistry(S.type)
     ? `${sorted.length.toLocaleString()} match${sorted.length===1?'':'es'} · ${typeLabel(S.type)} (registry search)`
     : `${sorted.length.toLocaleString()} of ${Number(DATA.counts[S.type]??DATA.counts.all??0).toLocaleString()} · ${S.type==='all'?'all contacts':typeLabel(S.type)}`;
-  $('rows').innerHTML = sorted.length ? sorted.map(rowHTML).join('') : (hint?'':'<div class="empty">No contacts match.</div>');
-  document.querySelectorAll('#rows .row').forEach(el=>el.onclick=()=>openDetail(el.dataset.id, sorted));
+  if(S.mode==='table'){
+    $('rows').innerHTML = sorted.length ? tableHTML(sorted) : (hint?'':'<div class="empty">No contacts match.</div>');
+    $('rows').querySelectorAll('.tbl thead th[data-col]').forEach(th=>th.onclick=()=>{
+      const k=th.dataset.col; if(!k) return;
+      if(S.sort===k){ S.sortDir=S.sortDir==='asc'?'desc':'asc'; }
+      else { S.sort=k; S.sortDir=(k==='created_at'||k==='rank'||k==='xref'||k==='employees'||k==='year_founded')?'desc':'asc'; }
+      setLS('sort',S.sort); setLS('sortDir',S.sortDir); render();
+    });
+    $('rows').querySelectorAll('.tbl tbody tr').forEach(el=>el.onclick=()=>openDetail(el.dataset.id, sorted));
+    $('rows').querySelectorAll('.xref[data-xref]').forEach(b=>b.onclick=e=>{ e.stopPropagation(); $('q').value=b.dataset.xref; S.q=b.dataset.xref; S.type='all'; document.querySelectorAll('#rail .chip').forEach(x=>x.classList.toggle('on',x.dataset.type==='all')); render(); });
+  } else {
+    $('rows').innerHTML = sorted.length ? sorted.map(rowHTML).join('') : (hint?'':'<div class="empty">No contacts match.</div>');
+    document.querySelectorAll('#rows .row').forEach(el=>el.onclick=()=>openDetail(el.dataset.id, sorted));
+  }
 }
 function openDetail(id, rows){
   const c=(rows||DATA.contacts).find(x=>x.id===id); if(!c) return;
@@ -316,7 +395,7 @@ function openDetail(id, rows){
   const add=(k,v)=>{ if(v!=null&&v!=='') cells.push(`<div class="cell"><div class="kk">${k}</div><div class="vv">${esc(v)}</div></div>`); };
   add('Type',typeLabel(c.type)); add('Firm',c.firm); add('Email',c.email); add('Phone',c.phone);
   add('City',[c.city,c.state].filter(Boolean).join(', ')); add('Title',c.title);
-  if(c.type==='directory'){ add('List',c.list_category); if(c.rank!=null) add('Rank','#'+c.rank); add('Address',c.address); add('Website',c.website); add('Employees',c.employees); add('Founded',c.year_founded); add('Detail',c.hq); }
+  if(c.type==='directory'){ add('List',c.list_category); if(c.rank!=null) add('Rank','#'+c.rank); add('Address',c.address); add('Website',c.website); add('Employees',c.employees); add('Founded',c.year_founded); add('Detail',c.hq); if(c.xref&&c.xref.count) add('Cross-Ref (RE DB)', c.xref.count+' match'+(c.xref.count>1?'es':'')+': '+c.xref.labels.join(' | ')); }
   if(c.dre_license) add('DRE License',c.dre_license);
   if(c.assessed_total) add('Assessed', '$'+Number(c.assessed_total).toLocaleString());
   if(c.sqft) add('SqFt', Number(c.sqft).toLocaleString());

← a364d190 crm: open contact action links inline in a right-dock iframe  ·  back to Rentv  ·  crm: cross-reference mind map (/crm-mindmap) — force-directe a1db61ae →