[object Object]

← back to Commercialrealestate

residential-brokers: add 3-view (grid/list/table) + all-field sort + collapsed rails (TK-10703)

3971e26b634b2e6961ac2a4167d077a5721ec340 · 2026-08-18 19:02:59 -0700 · Steve Abrams

Files touched

Diff

commit 3971e26b634b2e6961ac2a4167d077a5721ec340
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 19:02:59 2026 -0700

    residential-brokers: add 3-view (grid/list/table) + all-field sort + collapsed rails (TK-10703)
---
 public/residential-brokers.html | 120 +++++++++++++++++++++++++++++++++-------
 1 file changed, 99 insertions(+), 21 deletions(-)

diff --git a/public/residential-brokers.html b/public/residential-brokers.html
index b21d2e1..5842908 100644
--- a/public/residential-brokers.html
+++ b/public/residential-brokers.html
@@ -38,7 +38,23 @@
   .contact .tier{font-size:9.5px;color:var(--muted);opacity:.8}
   .contact .none{color:var(--muted)}
   .empty{color:var(--muted);padding:40px;text-align:center}
+  /* ── 3-view segmented [Grid|List|Table] + all-field sort (mirrors just-listed.html) ── */
+  #viewseg{display:inline-flex}
+  #viewseg button{background:#0c1017;color:var(--muted);border:1px solid var(--line);padding:6px 11px;font-size:12px;cursor:pointer}
+  #viewseg button:first-child{border-radius:8px 0 0 8px}
+  #viewseg button:last-child{border-radius:0 8px 8px 0}
+  #viewseg button:not(:last-child){border-right:0}
+  #viewseg button.active{color:#0a0d13;background:var(--gold);border-color:var(--gold);font-weight:600}
+  /* ── table view ── */
+  #tableView{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--panel);max-height:calc(100vh - 260px)}
+  #tableView table{border-collapse:collapse;width:100%;font-size:12.5px}
+  #tableView thead th{position:sticky;top:0;background:#11161d;z-index:2;text-align:left;padding:9px 11px;border-bottom:1px solid var(--line);white-space:nowrap;color:var(--muted);font-weight:700;font-size:10.5px;text-transform:uppercase;letter-spacing:.4px}
+  #tableView tbody td{padding:7px 11px;border-bottom:1px solid var(--line);vertical-align:top;line-height:1.35}
+  #tableView tbody tr:hover{background:rgba(74,144,217,.06)}
+  #tableView td a{color:var(--accent);text-decoration:none}
+  #tableView td a:hover{text-decoration:underline}
 </style>
+  <link rel="stylesheet" href="/three-view.css">
   <link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
 </head>
 <body>
@@ -56,54 +72,72 @@
     <option value="FL">Florida</option><option value="TX">Texas</option>
     <option value="NY">New York</option><option value="AZ">Arizona</option>
   </select>
-  <select id="sort">
-    <option value="name">Sort: Name A→Z</option>
-    <option value="firm_name">Firm A→Z</option>
-    <option value="city">City A→Z</option>
-    <option value="license_state">License state</option>
-    <option value="license_no">License #</option>
-  </select>
+  <span data-tvseg id="viewseg"></span>
+  <select id="tvSort"></select>
   <label>Density <input id="dens" type="range" min="2" max="7" value="4"></label>
   <span class="count" id="count">loading…</span>
 </div>
 <div class="toggles" id="toggles"></div>
 
-<main><div class="grid" id="grid"></div><div class="empty" id="empty" hidden></div></main>
+<main>
+  <div class="grid" id="grid"></div>
+  <div id="tvList"></div>
+  <div id="tableView"><table><thead></thead><tbody></tbody></table></div>
+  <div class="empty" id="empty" hidden></div>
+</main>
 
+<script src="/three-view.js" defer></script>
 <script src="/corner-nav.js" defer></script>
 <script>
+// FIELDS drive the card body rows, the left-rail-style toggles, the table columns,
+// and the all-field sort menu. `get` = sortable/plain value; `render` = optional HTML.
+// `lock:true` = contact columns that can never be hidden (Steve hard rule).
 const FIELDS = [
+  {k:'name',    label:'Name',    get:r=>r.name},
   {k:'firm',    label:'Firm',    get:r=>r.firm_name},
   {k:'city',    label:'City',    get:r=>[r.city,r.state_code].filter(Boolean).join(', ')},
   {k:'license', label:'License', get:r=>r.license_no ? (r.license_no+(r.license_state?' · '+r.license_state:'')) : ''},
+  {k:'type',    label:'Type',    get:r=>r.license_type},
   {k:'status',  label:'Status',  get:r=>r.license_status},
   {k:'class',   label:'Class',   get:r=>r.asset_class},
+  {k:'phone',   label:'Phone',   lock:true, get:r=>brokerPhone(r),
+     render:r=>{const p=brokerPhone(r);return p?`<a href="tel:${esc(p.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">${esc(p)}</a>`:'<span class="none">—</span>';}},
+  {k:'email',   label:'Email',   lock:true, get:r=>brokerEmail(r),
+     render:r=>{const e=brokerEmail(r);return e?`<a href="mailto:${esc(e)}" onclick="event.stopPropagation()">${esc(e)}</a>`:'<span class="none">—</span>';}},
 ];
+const brokerPhone=r=>r.phone||r.broker_phone||r.firm_phone||'';
+const brokerEmail=r=>r.email||r.broker_email||r.firm_email||'';
 const LS='crcp-resbrokers';
 const state = Object.assign({state:'CA',sort:'name',q:'',cols:4,off:{}}, JSON.parse(localStorage.getItem(LS)||'{}'));
-let ROWS=[];
+let ROWS=[], TV=null;
 const $=id=>document.getElementById(id);
 function save(){ localStorage.setItem(LS, JSON.stringify(state)); }
+// contact columns can never be toggled off — Steve hard rule
+FIELDS.forEach(f=>{ if(f.lock) state.off[f.k]=false; });
 
 function renderToggles(){
   $('toggles').innerHTML='';
+  // 'name' is always the card title; locked contact fields (phone/email) can't be hidden
   for(const f of FIELDS){
+    if(f.k==='name'||f.lock) continue;
     const b=document.createElement('button');
     b.textContent=f.label; b.className = state.off[f.k] ? '' : 'on';
     b.onclick=()=>{ state.off[f.k]=!state.off[f.k]; save(); b.className=state.off[f.k]?'':'on'; draw(); };
     $('toggles').appendChild(b);
   }
 }
-function draw(){
+const fvis=k=>{const f=FIELDS.find(x=>x.k===k);return f?(f.lock||!state.off[k]):false;};
+// filtered+sorted rows (ThreeView owns the all-field sort via applySort; this keeps
+// the page's own default order for grid/table). getRows() = these rows.
+function sortedRows(){ return [...ROWS]; }
+
+// ── GRID cards (the page's EXISTING renderer — unchanged look) ──
+function renderGrid(rows){
   const grid=$('grid');
-  document.documentElement.style.setProperty('--cols', state.cols);
-  let rows=[...ROWS];
-  const key=state.sort;
-  rows.sort((a,b)=>String(a[key]||'').localeCompare(String(b[key]||''),undefined,{numeric:true}));
-  if(!rows.length){ grid.innerHTML=''; $('empty').hidden=false; $('empty').textContent='No residential brokers match.'; return; }
-  $('empty').hidden=true;
+  if(!rows.length){ grid.innerHTML=''; return; }
   grid.innerHTML = rows.map(r=>{
-    const lines = FIELDS.filter(f=>!state.off[f.k] && f.k!=='firm' && f.k!=='class').map(f=>{
+    // body rows: everything visible except name (title), firm (own line), class (pill), and locked contacts
+    const lines = FIELDS.filter(f=>!state.off[f.k] && !f.lock && f.k!=='name' && f.k!=='firm' && f.k!=='class').map(f=>{
       const v=f.get(r); return v ? `<div class="row">${f.label}: <b>${esc(v)}</b></div>` : '';
     }).join('');
     const firm = !state.off.firm && r.firm_name ? `<div class="fm">${esc(r.firm_name)}</div>` : '';
@@ -111,7 +145,28 @@ function draw(){
     return `<div class="card"><div class="nm">${esc(r.name||'—')}</div>${firm}${lines}${cls}${contact(r)}</div>`;
   }).join('');
 }
-function esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
+
+// ── TABLE view (simple table of the same rows/fields; phone+email always present) ──
+function tableCols(){ return FIELDS.filter(f=>f.lock || (!state.off[f.k] && f.k!=='class')); }
+function renderTable(rows){
+  const cols=tableCols(), t=$('tableView').querySelector('table');
+  t.querySelector('thead').innerHTML='<tr>'+cols.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
+  t.querySelector('tbody').innerHTML=rows.slice(0,600).map(r=>
+    '<tr>'+cols.map(c=>{
+      const cell=c.render?c.render(r):(c.get(r)||'—');
+      return `<td>${c.render?cell:esc(cell)}</td>`;
+    }).join('')+'</tr>').join('');
+}
+
+function draw(){
+  document.documentElement.style.setProperty('--cols', state.cols);
+  const rows=sortedRows();
+  const has=rows.length>0;
+  $('empty').hidden=has;
+  if(!has){ $('empty').textContent='No residential brokers match.'; }
+  if(TV){ TV.render(); } else { renderGrid(rows); renderTable(rows); }
+}
+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
 
 // ── broker contact (Steve HARD RULE: ALWAYS show broker phone + email, always visible) ──
 // The /api/residential-brokers row already resolves phone/email (broker→firm) with a phone_tier,
@@ -146,13 +201,36 @@ async function load(){
   }catch(e){ $('count').textContent='error'; $('empty').hidden=false; $('empty').textContent='Could not reach the registry.'; }
 }
 
+// ── ThreeView: Grid / List / Table + all-field sort (guarded — three-view.js is deferred) ──
+function mountThreeView(){
+  const fields=FIELDS.map(f=>({k:f.k,l:f.label,html:!!f.render,
+    calc:r=>f.render?f.render(r):(f.get(r)||'')}));
+  TV=ThreeView.mount({
+    seg:'#viewseg', sortSel:'#tvSort',
+    listMount:'#tvList', gridMount:'#grid', tableMount:'#tableView',
+    fields,
+    getRows:sortedRows,
+    title:r=>({name:r.name||'—',sub:[r.firm_name,[r.city,r.state_code].filter(Boolean).join(', ')].filter(Boolean).join(' · '),href:null}),
+    badgeHtml:r=>r.asset_class?`<span class="pill">${esc(r.asset_class)}</span>`:'',
+    fvis,
+    renderGrid, renderTable,
+    onSort:()=>{ /* helper owns the sort; just re-render */ },
+    rowClick:()=>{},
+    storageKey:'resb', defaultView:'grid', views:['grid','list','table'],
+  });
+}
+let _tvTries=0;
+function startViews(){
+  if(typeof ThreeView==='undefined'){ if(++_tvTries>100){renderGrid(sortedRows());renderTable(sortedRows());return;} return setTimeout(startViews,40); }
+  mountThreeView(); draw();
+}
+
 // wire controls
-$('q').value=state.q; $('state').value=state.state; $('sort').value=state.sort; $('dens').value=state.cols;
+$('q').value=state.q; $('state').value=state.state; $('dens').value=state.cols;
 let t; $('q').oninput=e=>{ state.q=e.target.value.trim(); save(); clearTimeout(t); t=setTimeout(load,300); };
 $('state').onchange=e=>{ state.state=e.target.value; save(); load(); };
-$('sort').onchange=e=>{ state.sort=e.target.value; save(); draw(); };
 $('dens').oninput=e=>{ state.cols=+e.target.value; save(); draw(); };
-renderToggles(); load();
+renderToggles(); startViews(); load();
 </script>
   <script src="/crcp-theme.js" defer></script><!-- CRCP day/night toggle (auto-injects into header) -->
 </body>

← 132b8b7 licensed-agents + licensed: require name+firm match for brok  ·  back to Commercialrealestate  ·  licensed-agents: add 3-view + all-field sort (TK-10703) b439ff0 →