[object Object]

← back to Commercialrealestate

licensed: add 3-view + all-field sort per tab (TK-10703)

e3492114bcb6ddc976e93760e10c9ba5954cf0d7 · 2026-08-18 19:43:34 -0700 · Steve Abrams

Files touched

Diff

commit e3492114bcb6ddc976e93760e10c9ba5954cf0d7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 19:43:34 2026 -0700

    licensed: add 3-view + all-field sort per tab (TK-10703)
---
 public/licensed.html | 126 +++++++++++++++++++++++++++++++++++++++++++++------
 1 file changed, 113 insertions(+), 13 deletions(-)

diff --git a/public/licensed.html b/public/licensed.html
index 472d646..542809a 100644
--- a/public/licensed.html
+++ b/public/licensed.html
@@ -63,7 +63,23 @@ input#q{min-width:230px}
 #modal h3{margin:0 0 3px;font-size:19px}#modal .x{position:absolute;top:11px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;line-height:1}
 #modal .mrow{display:flex;justify-content:space-between;gap:12px;padding:5px 0;border-bottom:1px solid var(--line);font-size:13px}
 #modal .mrow .mk{color:var(--mut);text-transform:capitalize}#modal .mrow .mv{text-align:right;word-break:break-word}
-</style>  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
+/* ── 3-view segmented [Grid|List|Table] + all-field sort (additive, mirrors residential-brokers.html) ── */
+#viewseg{display:inline-flex}
+#viewseg button{background:var(--card);color:var(--mut);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:#fff;background:var(--blue);border-color:var(--blue)}
+#tableView{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--card);max-height:calc(100vh - 200px)}
+#tableView table{border-collapse:collapse;width:100%;font-size:12.5px}
+#tableView thead th{position:sticky;top:0;background:var(--card);z-index:2;text-align:left;padding:9px 11px;border-bottom:1px solid var(--line);white-space:nowrap;color:var(--mut);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{cursor:pointer}
+#tableView tbody tr:hover{background:rgba(76,141,255,.07)}
+#tableView td a{color:var(--blue);text-decoration:none}
+#tableView td a:hover{text-decoration:underline}
+</style>  <link rel="stylesheet" href="/three-view.css"><!-- 3-view helper -->
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
   <link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
 </head><body>
 <header>
@@ -76,6 +92,8 @@ input#q{min-width:230px}
       <option value="count">Most agents</option>
       <option value="recent">License newest</option>
     </select>
+    <span data-tvseg id="viewseg"></span>
+    <select id="tvSort"></select>
     <span class="rng">Density <input type="range" id="dens" min="2" max="7" value="4"></span>
     <div class="dpwrap"><button class="dpbtn" id="dpBtn" title="Show / hide data points on the cards">⚙ Fields</button>
       <div class="dppop" id="dpPop"><div class="dphd">Data points on cards</div><div id="dpList"></div>
@@ -83,8 +101,9 @@ input#q{min-width:230px}
     <a href="/" class="sub">← CRCP</a>
   </div>
 </header>
-<div class="wrap"><div class="count" id="count">Loading…</div><div class="grid" id="grid"></div></div>
+<div class="wrap"><div class="count" id="count">Loading…</div><div class="grid" id="grid"></div><div id="tvList"></div><div id="tableView"><table><thead></thead><tbody></tbody></table></div></div>
 <div id="ov"><div id="modal"><button class="x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
+<script src="/three-view.js" defer></script><!-- 3-view + all-field sort helper (loaded before the page script's guarded startViews) -->
 <script>
 const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
 const $=s=>document.querySelector(s);
@@ -179,23 +198,36 @@ async function boot(){
   $('#scope').textContent=`· ${(ld.scope&&ld.scope.state)||'CA'} · ${(ld.scope&&ld.scope.metros||[]).join(', ')}`;
   const T=[['firms','Firms',DATA.firms.length],['title','Title',DATA.title.length],['escrow','Escrow',DATA.escrow.length],['agents','Agents',DATA.agents.length]];
   $('#tabs').innerHTML=T.map(([k,l,n])=>`<span class="tab${k===tab?' on':''}" data-tab="${k}">${l} <b>${n}</b></span>`).join('');
-  $('#tabs').onclick=e=>{const t=e.target.closest('[data-tab]');if(!t)return;tab=t.dataset.tab;document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x.dataset.tab===tab));buildFieldToggles();render();};
+  $('#tabs').onclick=e=>{const t=e.target.closest('[data-tab]');if(!t)return;tab=t.dataset.tab;document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x.dataset.tab===tab));buildFieldToggles();remountTV();};
   $('#q').oninput=render; $('#sort').value=sort; $('#sort').onchange=e=>{sort=e.target.value;localStorage.setItem('ld_sort',sort);render();};
   const dens=$('#dens'); dens.value=localStorage.getItem('ld_dens')||4; document.documentElement.style.setProperty('--cols',dens.value);
   dens.oninput=e=>{document.documentElement.style.setProperty('--cols',e.target.value);localStorage.setItem('ld_dens',e.target.value);};
   buildFieldToggles();
-  render();
+  mountTV();
+}
+// Re-mount ThreeView with the current tab's fields. Clear seg/sort DOM first so
+// each mount's fresh listeners don't stack (ThreeView adds a listener per mount).
+function remountTV(){
+  const seg=$('#viewseg'), sel=$('#tvSort');
+  if(seg){const n=seg.cloneNode(false);seg.parentNode.replaceChild(n,seg);}
+  if(sel){const n=sel.cloneNode(false);sel.parentNode.replaceChild(n,sel);}
+  TV=null; _tvTries=0; mountTV();
 }
 // ── ⚙ Fields popout wiring ──
 $('#dpBtn').onclick=e=>{e.stopPropagation();$('#dpPop').classList.toggle('on');};
 $('#dpPop').onclick=e=>e.stopPropagation();
 document.addEventListener('click',()=>$('#dpPop').classList.remove('on'));
-$('#dpList').addEventListener('change',e=>{const cb=e.target.closest('input[data-fk]');if(!cb)return;setFieldVis(cb.dataset.fk,cb.checked);render();});
-$('#dpAll').onclick=()=>{tabFieldKeys().forEach(k=>setFieldVis(k,true));buildFieldToggles();render();};
-$('#dpNone').onclick=()=>{tabFieldKeys().forEach(k=>setFieldVis(k,false));buildFieldToggles();render();};
-$('#dpReset').onclick=()=>{if(VISFIELDS[tab])delete VISFIELDS[tab];try{localStorage.setItem('ldFields',JSON.stringify(VISFIELDS));}catch(e){}buildFieldToggles();render();};
+// field-toggle changes which columns/list-fields/sort-options exist → re-mount so
+// list + table + sort menu track it (grid reads fieldVis live; re-mount also re-renders it).
+$('#dpList').addEventListener('change',e=>{const cb=e.target.closest('input[data-fk]');if(!cb)return;setFieldVis(cb.dataset.fk,cb.checked);remountTV();});
+$('#dpAll').onclick=()=>{tabFieldKeys().forEach(k=>setFieldVis(k,true));buildFieldToggles();remountTV();};
+$('#dpNone').onclick=()=>{tabFieldKeys().forEach(k=>setFieldVis(k,false));buildFieldToggles();remountTV();};
+$('#dpReset').onclick=()=>{if(VISFIELDS[tab])delete VISFIELDS[tab];try{localStorage.setItem('ldFields',JSON.stringify(VISFIELDS));}catch(e){}buildFieldToggles();remountTV();};
 $('#ov').addEventListener('click',e=>{if(e.target.id==='ov')$('#ov').classList.remove('on');});
-function render(){
+// Current tab's filtered + (page-default) sorted rows, capped. ThreeView's all-field
+// sort is layered ON TOP of this via applySort, so getRows keeps the page's own order.
+const CAP=300;
+function getRows(){
   const q=$('#q').value.trim().toLowerCase();
   let rows=DATA[tab]||[];
   if(q){
@@ -207,9 +239,74 @@ function render(){
   rows=[...rows].sort((a,b)=> sort==='count'?((b.agent_count||0)-(a.agent_count||0))
     : sort==='recent'?String(b.expiration||'').localeCompare(String(a.expiration||''))
     : String(a.name||'').localeCompare(String(b.name||'')));
-  const CAP=300, shown=rows.slice(0,CAP);
-  $('#count').textContent=`${rows.length.toLocaleString()} ${tab}${rows.length>CAP?` · showing first ${CAP} (search to narrow)`:''}`;
-  $('#grid').innerHTML = shown.length? shown.map(card).join('') : '<div class="empty">No matches.</div>';
+  return rows.slice(0,CAP);
+}
+function renderCount(rows){
+  // rows are already capped; recompute the uncapped total for the label
+  const q=$('#q').value.trim().toLowerCase();
+  let all=(DATA[tab]||[]);
+  if(q){const terms=q.split(/\s+/).filter(Boolean);all=all.filter(r=>{const hay=JSON.stringify(r).toLowerCase();return terms.every(t=>hay.includes(t));});}
+  const n=all.length;
+  $('#count').textContent=`${n.toLocaleString()} ${tab}${n>CAP?` · showing first ${CAP} (search to narrow)`:''}`;
+}
+// GRID view = the page's EXISTING card renderer (unchanged look + contact + IG + mark).
+function renderGrid(rows){
+  renderCount(rows);
+  $('#grid').innerHTML = rows.length? rows.map(card).join('') : '<div class="empty">No matches.</div>';
+}
+// TABLE view = the enabled data-point fields as columns + always-visible phone/email
+// (resolved through the SAME contactBlock()/brokerFor() path — guard intact).
+function renderTable(rows){
+  renderCount(rows);
+  const cols=tvFields();
+  const t=$('#tableView').querySelector('table');
+  t.querySelector('thead').innerHTML='<tr>'+cols.map(c=>`<th>${esc(c.l)}</th>`).join('')+'</tr>';
+  t.querySelector('tbody').innerHTML = rows.length
+    ? rows.map(r=>'<tr data-id="'+esc(r.id)+'">'+cols.map(c=>`<td>${c.html?c.calc(r):esc(c.calc(r))}</td>`).join('')+'</tr>').join('')
+    : `<tr><td colspan="${cols.length}"><div class="empty">No matches.</div></td></tr>`;
+}
+// Called by search/sort/density/field-toggle — delegates to ThreeView if mounted.
+function render(){ if(TV){TV.render();} else { const r=getRows(); renderGrid(r); renderTable(r); } }
+// ── ThreeView field metadata for the CURRENT tab ──
+// Data-point fields (the enabled ⚙ Fields keys) PLUS locked Phone + Email columns
+// that resolve via brokerFor()/contactBlock() — NEVER a firm-first or name-only join.
+function bkFor(r){return brokerFor(r);} // keep the Cody-gated guard as the single source
+function tvFields(){
+  const keys=tabFieldKeys().filter(fieldVis);
+  const fields=[{k:'name',l:'Name',calc:r=>r.name}];
+  keys.forEach(k=>fields.push({k,l:fieldLabel(k),calc:r=>{
+    const v=r[k]; if(v==null||v==='')return '';
+    if(k==='agent_count')return `${v} licensed agent${v===1?'':'s'}`;
+    if(k==='license_number')return '#'+v;
+    return String(v);
+  }}));
+  // ALWAYS-visible contact columns (Steve hard rule) — real tel:/mailto: only on a
+  // confirmed broker match (brokerFor); otherwise a graceful look-up link.
+  fields.push({k:'__phone',l:'Phone',lock:true,html:true,calc:r=>{const b=bkFor(r);const lic=clean(r.license_number);
+    const dre=lic?`https://www2.dre.ca.gov/PublicASP/pplinfo.asp?License_id=${encodeURIComponent(lic)}`:`https://www.google.com/search?q=${encodeURIComponent((r.name||'')+' real estate contact')}`;
+    return b&&b.phone?`<a href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">📞 ${esc(b.phone)}</a>`:`<a href="${esc(dre)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">📞 <span class="none">look up ↗</span></a>`;}});
+  fields.push({k:'__email',l:'Email',lock:true,html:true,calc:r=>{const b=bkFor(r);const lic=clean(r.license_number);
+    const dre=lic?`https://www2.dre.ca.gov/PublicASP/pplinfo.asp?License_id=${encodeURIComponent(lic)}`:`https://www.google.com/search?q=${encodeURIComponent((r.name||'')+' real estate contact')}`;
+    return b&&b.email?`<a href="mailto:${esc(b.email)}" onclick="event.stopPropagation()">✉️ ${esc(b.email)}</a>`:`<a href="${esc(dre)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">✉️ <span class="none">look up ↗</span></a>`;}});
+  return fields;
+}
+// mount/re-mount ThreeView for the current tab (fields differ per tab → re-mount on switch)
+let TV=null,_tvTries=0;
+function mountTV(){
+  if(typeof ThreeView==='undefined'){ if(++_tvTries>100){const r=getRows();renderGrid(r);renderTable(r);return;} return setTimeout(mountTV,40); }
+  TV=ThreeView.mount({
+    seg:'#viewseg', sortSel:'#tvSort',
+    listMount:'#tvList', gridMount:'#grid', tableMount:'#tableView',
+    fields:tvFields(),
+    getRows,
+    title:r=>({name:r.name||'—',sub:r.kind==='agent'?(r.firm||''):'',href:null}),
+    badgeHtml:r=>`<span class="kind ${esc(r.kind)}">${esc(r.kind)}</span>`,
+    fvis:k=>{const f=tvFields().find(x=>x.k===k);return f?(f.lock||fieldVis(k)):false;},
+    renderGrid, renderTable,
+    onSort:()=>{}, rowClick:r=>openDetail(r.id),
+    storageKey:'ldview', defaultView:'grid', views:['grid','list','table'],
+  });
+  TV.render();
 }
 // Card meta = only the ENABLED data points (⚙ Fields), rendered "label: value".
 function fieldDisplay(r,k){
@@ -261,7 +358,10 @@ document.addEventListener('click',async e=>{
   }
   // card click (not on a button, anchor, or IG thumb) → expand-to-detail modal (all fields)
   const cardEl=e.target.closest('.card');
-  if(cardEl && !e.target.closest('button,a,.figt')) openDetail(cardEl.dataset.id);
+  if(cardEl && !e.target.closest('button,a,.figt')){ openDetail(cardEl.dataset.id); return; }
+  // table-row click (not on a link) → same detail modal
+  const trEl=e.target.closest('#tableView tbody tr[data-id]');
+  if(trEl && !e.target.closest('a')) openDetail(trEl.dataset.id);
 });
 boot().catch(e=>{$('#count').textContent='Failed to load: '+e.message;});
 </script>  <script src="/global-search.js" defer></script>

← b439ff0 licensed-agents: add 3-view + all-field sort (TK-10703)  ·  back to Commercialrealestate  ·  CRCP: morning broker-feed sync — scrape all firms + rsync da 2bf64b6 →