[object Object]

← back to Rentv

crm: adjustable table columns — drag-to-resize + show/hide (field toggles) + sort, all persisted

1e9b1f96dd53c757d2d497d9a30c16be76b49fab · 2026-08-12 17:06:58 -0700 · Steve Abrams

- every column resizable via a header edge handle (colgroup widths, table-layout:fixed), width saved to localStorage
- field-toggle chips become column show/hide in table mode (name stays as anchor); persisted
- pairs with the existing click-header-to-sort (asc/desc); all localStorage-backed

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

Files touched

Diff

commit 1e9b1f96dd53c757d2d497d9a30c16be76b49fab
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 12 17:06:58 2026 -0700

    crm: adjustable table columns — drag-to-resize + show/hide (field toggles) + sort, all persisted
    
    - every column resizable via a header edge handle (colgroup widths, table-layout:fixed), width saved to localStorage
    - field-toggle chips become column show/hide in table mode (name stays as anchor); persisted
    - pairs with the existing click-header-to-sort (asc/desc); all localStorage-backed
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/crm.html | 40 +++++++++++++++++++++++++++++++++-------
 1 file changed, 33 insertions(+), 7 deletions(-)

diff --git a/public/crm.html b/public/crm.html
index facd6b63..0e308299 100644
--- a/public/crm.html
+++ b/public/crm.html
@@ -87,7 +87,9 @@
   .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)}
+  table.tbl{border-collapse:collapse;width:max-content;min-width:100%;font-size:calc(var(--fs) - .5px);table-layout:fixed}
+  .tbl .rsz{position:absolute;top:0;right:-4px;width:9px;height:100%;cursor:col-resize;z-index:6;user-select:none}
+  .tbl .rsz:hover{background:linear-gradient(90deg,transparent,rgba(200,16,46,.65))}
   .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}
@@ -252,8 +254,17 @@ function renderRail(){
   document.querySelectorAll('#rail .chip').forEach(b=>b.onclick=()=>{ S.type=b.dataset.type; setLS('type',S.type); S.sel=null; document.querySelectorAll('#rail .chip').forEach(x=>x.classList.toggle('on',x===b)); render(); });
 }
 function renderFieldToggles(){
-  $('fieldToggles').innerHTML=FIELDS.map(([k,l])=>`<button class="ft ${S.fields[k]?'on':''}" data-f="${k}">${l}</button>`).join('');
-  document.querySelectorAll('#fieldToggles .ft').forEach(b=>b.onclick=()=>{ S.fields[b.dataset.f]=S.fields[b.dataset.f]?0:1; setLS('fields',JSON.stringify(S.fields)); b.classList.toggle('on'); render(); });
+  // In table mode the chips show/hide the (sortable, resizable) columns; in list/grid they toggle card fields.
+  const inTable=S.mode==='table';
+  const list=inTable?TABLE_COLS.filter(([k])=>k!=='photo'&&k!=='name'):FIELDS;
+  const isOn=k=>inTable?!S.tcolHidden[k]:S.fields[k];
+  $('fieldToggles').innerHTML=list.map(([k,l])=>`<button class="ft ${isOn(k)?'on':''}" data-f="${k}">${esc(l||k)}</button>`).join('');
+  document.querySelectorAll('#fieldToggles .ft').forEach(b=>b.onclick=()=>{
+    const k=b.dataset.f;
+    if(inTable){ S.tcolHidden[k]=S.tcolHidden[k]?0:1; setLS('tcolHidden',JSON.stringify(S.tcolHidden)); }
+    else { S.fields[k]=S.fields[k]?0:1; setLS('fields',JSON.stringify(S.fields)); }
+    render();
+  });
 }
 const isRegistry=k=>(DATA.registries||[]).some(r=>r.key===k);
 
@@ -303,10 +314,14 @@ function buildXref(){
 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>'; }
+const TCOL_W={photo:48,list_category:170,rank:56,name:180,title:160,firm:200,xref:132,city:110,phone:132,email:190,website:170,employees:98,year_founded:84,hq:240,address:220,source:150,created_at:150};
+function tcols(){ return TABLE_COLS.filter(([k])=> k==='name' || !S.tcolHidden[k]); } // name is the anchor column, always shown
 function tableHTML(rows){
+  const cols=tcols();
   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])=>{
+  const colg='<colgroup>'+cols.map(([k])=>`<col data-c="${k}" style="width:${(S.tcolW[k]||TCOL_W[k]||140)}px">`).join('')+'</colgroup>';
+  const head=cols.map(([k,l])=>`<th data-col="${k}" class="${S.sort===k?'sorted':''}">${esc(l)}${arrow(k)}<span class="rsz" data-rk="${k}" title="Drag to resize"></span></th>`).join('');
+  const cell=c=>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>`;}
@@ -316,7 +331,7 @@ function tableHTML(rows){
     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':''} ${c.li_follow?'lifollow':''}">${cell(c)}</tr>`).join('')}</tbody></table></div>`;
+  return `<div class="tblwrap"><table class="tbl">${colg}<thead><tr>${head}</tr></thead><tbody>${rows.map(c=>`<tr data-id="${esc(c.id)}" class="${S.sel===c.id?'sel':''} ${c.li_follow?'lifollow':''}">${cell(c)}</tr>`).join('')}</tbody></table></div>`;
 }
 function sortRows(rows){
   let key=S.sort, dir=S.sortDir||'asc';
@@ -386,12 +401,23 @@ async function render(){
   if(nfollow) $('countline').innerHTML = esc($('countline').textContent)+` · <span style="color:#8affc0;font-weight:800">🟢 ${nfollow.toLocaleString()} already following on LinkedIn</span>`;
   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=()=>{
+    $('rows').querySelectorAll('.tbl thead th[data-col]').forEach(th=>th.onclick=ev=>{
+      if(ev.target.classList.contains('rsz')) return;            // clicking the resize handle isn't a sort
       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 .rsz').forEach(h=>{        // drag a header edge to adjust column width
+      h.onmousedown=e=>{ e.preventDefault(); e.stopPropagation();
+        const k=h.dataset.rk, col=$('rows').querySelector('col[data-c="'+k+'"]');
+        const sx=e.clientX, sw=col?col.getBoundingClientRect().width:140;
+        const mv=ev=>{ if(col) col.style.width=Math.max(40,sw+(ev.clientX-sx))+'px'; };
+        const up=ev=>{ document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up);
+          S.tcolW[k]=Math.max(40,Math.round(sw+(ev.clientX-sx))); setLS('tcolW',JSON.stringify(S.tcolW)); };
+        document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
+      };
+    });
     $('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 {

← 1c5d23ba auto-data-snapshot: 2026-08-12T17:03:39 (8 data files) — 5x/  ·  back to Rentv  ·  docs: /5x + contrarian gate report for the desk inline-panel b839b823 →