[object Object]

← back to Commercialrealestate

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

b439ff05ab17f06326544ea230536eb9118705e7 · 2026-08-18 19:42:39 -0700 · Steve Abrams

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

Files touched

Diff

commit b439ff05ab17f06326544ea230536eb9118705e7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 19:42:39 2026 -0700

    licensed-agents: add 3-view + all-field sort (TK-10703)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/licensed-agents.html | 131 +++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 123 insertions(+), 8 deletions(-)

diff --git a/public/licensed-agents.html b/public/licensed-agents.html
index 4aae10a..ac70448 100644
--- a/public/licensed-agents.html
+++ b/public/licensed-agents.html
@@ -69,7 +69,22 @@
   #modal .x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;}
   #modal .kv{margin-top:8px;display:grid;grid-template-columns:auto 1fr;gap:5px 12px;font-size:13px;}
   #modal .kv .k{color:var(--mut);} #modal .kv .v a{color:var(--blue);text-decoration:none;}
+  /* ── 3-view segmented [Grid|List|Table] + all-field sort (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:var(--bg);background:var(--blue);border-color:var(--blue);font-weight:700;}
+  /* ── table view ── */
+  #tableView{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--card);max-height:calc(100vh - 220px);}
+  #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:hover{background:rgba(88,166,255,.06);}
+  #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 (Grid/List/Table) -->
   <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>
@@ -88,16 +103,22 @@
     <option value="conf">Sort: Match confidence</option>
   </select>
   <label class="densl" title="Card density">⤢<input type="range" id="density" min="240" max="460" step="20" value="320"></label>
+  <span data-tvseg id="viewseg"></span>
+  <select id="tvSort" title="Sort every field"></select>
   <span class="count" id="count">…</span>
 </header>
 <div class="bar" id="pills"></div>
 <div class="fieldbar" id="fieldbar" style="display:none"></div>
 <div class="note" id="note"></div>
-<main><div class="grid" id="grid"></div></main>
+<main>
+  <div class="grid" id="grid"></div>
+  <div id="tvList" style="display:none"></div>
+  <div id="tableView" style="display:none"><table><thead></thead><tbody></tbody></table></div>
+</main>
 <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>
 const $=s=>document.querySelector(s);
-let DATA=[], META={}, filt='all', q='', sortKey='name';
+let DATA=[], META={}, filt='all', q='', sortKey='name', TV=null;
 const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
 
 // ── Agent CONTACT join (Steve HARD RULE 2026-08-18): the /api/agents/licensed feed carries NO
@@ -206,7 +227,47 @@ function statusText(a){
 // expiration mm/dd/yy -> sortable
 function expNum(a){ const m=/(\d{2})\/(\d{2})\/(\d{2})/.exec(a.expiration||''); return m?(2000+ +m[3])*10000+ +m[1]*100+ +m[2]:99999999; }
 
-function render(){
+// ── Phone / Email cells for List + Table — SAME contactFor()/contactHtml() path (Cody gate:
+//    firm-confirmed contact only; DRE fallback otherwise). NO name-only join anywhere. ──
+function phoneCell(a){
+  const b=contactFor(a);
+  if(b&&b.phone) return `<a href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">${esc(b.phone)}</a>`;
+  return a.dre_url?`<a href="${esc(a.dre_url)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">via DRE ↗</a>`:'<span class="contact"><span class="none">—</span></span>';
+}
+function emailCell(a){
+  const b=contactFor(a);
+  if(b&&b.email) return `<a href="mailto:${esc(b.email)}" onclick="event.stopPropagation()">${esc(b.email)}</a>`;
+  return a.dre_url?`<a href="${esc(a.dre_url)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">via DRE ↗</a>`:'<span class="contact"><span class="none">—</span></span>';
+}
+// ThreeView field metadata: the agent columns. phone/email are lock:true (always visible,
+// never hideable) and go through the firm-confirmed contact path — sort value is the
+// resolved phone/email so the all-field sort still works. html:true = value is raw HTML.
+const TV_FIELDS=[
+  {k:'agent',        l:'Agent',   calc:a=>esc(a.agent||'—')},
+  {k:'brokerage',    l:'Brokerage',calc:a=>esc(a.brokerage||'—')},
+  {k:'license',      l:'License', calc:a=>fieldVal(a,'license'), html:true},
+  {k:'license_type', l:'Type',    calc:a=>esc(a.license_type||'—')},
+  {k:'license_city', l:'City',    calc:a=>esc(a.license_city||'—')},
+  {k:'status',       l:'Status',  calc:a=>esc(statusText(a))},
+  {k:'phone',        l:'Phone',   lock:true, calc:a=>phoneCell(a), html:true, sortval:a=>{const b=contactFor(a);return b&&b.phone?b.phone:'';}},
+  {k:'email',        l:'Email',   lock:true, calc:a=>emailCell(a), html:true, sortval:a=>{const b=contactFor(a);return b&&b.email?b.email:'';}},
+];
+// Table columns = the visible TV fields (locks always shown; others follow the ⚙ Fields toggles
+// where they overlap, else default-shown for the always-relevant columns).
+function TABLE_COLS(){ return TV_FIELDS.filter(f=>f.lock || tvFieldVis(f.k)); }
+// Column visibility for List/Table: locks always on; fields that mirror a ⚙ toggle follow it;
+// the rest (agent/brokerage/status) are always shown.
+function tvFieldVis(k){
+  if(k==='license') return fieldVis('license');
+  if(k==='license_type') return fieldVis('license_type');
+  if(k==='license_city') return fieldVis('license_city');
+  if(k==='status') return fieldVis('status');
+  return true; // agent, brokerage always shown; phone/email handled by lock upstream
+}
+
+// Filtered + page-sorted rows (pills + search + the page's own #sortsel). ThreeView's
+// #tvSort applies an additional all-field sort ON TOP of this via applySort.
+function filteredRows(){
   let rows=DATA.filter(a=>{
     if(filt==='verified'&&!a.dre_verified) return false;
     if(filt==='probable'&&a.dre_match!=='probable') return false;
@@ -229,10 +290,29 @@ function render(){
     brokerage:(a,b)=>(a.brokerage||'zz').localeCompare(b.brokerage||'zz'),
     conf:(a,b)=>(a.match_confidence||'zz').localeCompare(b.match_confidence||'zz'),
   }[sortKey];
-  rows.sort(cmp);
-  $('#grid').innerHTML=rows.map((a,i)=>cardHtml(a,DATA.indexOf(a))).join('')||'<div class="note">No agents match.</div>';
+  if(cmp) rows.sort(cmp);
+  return rows;
+}
+// GRID = the page's EXISTING card renderer (unchanged look).
+function renderAgentGrid(rows){
+  $('#grid').innerHTML=rows.map(a=>cardHtml(a,DATA.indexOf(a))).join('')||'<div class="note">No agents match.</div>';
+  $('#count').textContent=rows.length+' / '+DATA.length+' agents';
+}
+// TABLE = simple table of the same rows/fields; Phone + Email columns ALWAYS present
+// (lock:true) and rendered via the SAME contactFor()/contactHtml() path — no name-only join.
+function renderAgentTable(rows){
+  const cols=TABLE_COLS();
+  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.slice(0,600).map(a=>{
+    return `<tr data-idx="${DATA.indexOf(a)}" style="cursor:pointer">`+cols.map(c=>`<td>${c.calc(a)}</td>`).join('')+'</tr>';
+  }).join('');
   $('#count').textContent=rows.length+' / '+DATA.length+' agents';
 }
+// Render agents via ThreeView if mounted, else fall back to the plain grid.
+function render(){
+  if(TV){ TV.render(); } else { renderAgentGrid(filteredRows()); }
+}
 function cardHtml(a,idx){
   const fields=visFields();
   return `<div class="card" data-idx="${idx}">
@@ -311,13 +391,43 @@ function renderBrokers(){
   $('#count').textContent=rows.length+' / '+BROKERS.length+' brokers';
 }
 function draw(){ mode==='brokers'?renderBrokers():render(); }
+// ── ThreeView: Grid / List / Table + all-field sort (agents view only; guarded — three-view.js is deferred) ──
+function mountThreeView(){
+  if(TV || typeof ThreeView==='undefined') return;
+  TV=ThreeView.mount({
+    seg:'#viewseg', sortSel:'#tvSort',
+    listMount:'#tvList', gridMount:'#grid', tableMount:'#tableView',
+    fields:TV_FIELDS,
+    getRows:filteredRows,
+    title:a=>({name:a.agent||'—',sub:a.brokerage||'',href:null}),
+    badgeHtml:a=>`<span class="badge ${statusClass(a)}">${esc(statusText(a))}</span>`,
+    fvis:tvFieldVis,
+    renderGrid:renderAgentGrid, renderTable:renderAgentTable,
+    onSort:()=>{ /* helper owns the sort; just re-render */ },
+    rowClick:a=>{ if(a) openAgent(a); },
+    storageKey:'la', defaultView:'grid', views:['grid','list','table'],
+  });
+}
+let _tvTries=0;
+function startViews(){
+  if(typeof ThreeView==='undefined'){ if(++_tvTries>100) return; return setTimeout(startViews,40); }
+  mountThreeView();
+  if(mode!=='brokers') draw();
+}
 async function setMode(m){
   mode=m; localStorage.setItem('laMode',m);
   document.querySelectorAll('.mode').forEach(x=>x.classList.toggle('on',x.dataset.m===m));
   $('#pills').style.display=m==='brokers'?'none':'';
   $('#fieldbar').style.display=m==='brokers'?'none':'flex';   // data-point toggles are agents-only
-  if(m==='brokers'){ await loadBrokers(); fillSort(BROKER_SORTS,bSort); }
-  else { fillSort(AGENT_SORTS,sortKey); load(); }
+  // 3-view controls (seg/sort) + list/table containers are agents-only
+  $('#viewseg').style.display=m==='brokers'?'none':'';
+  $('#tvSort').style.display=m==='brokers'?'none':'';
+  if(m==='brokers'){
+    $('#tvList').style.display='none'; $('#tableView').style.display='none'; $('#grid').style.display='';
+    await loadBrokers(); fillSort(BROKER_SORTS,bSort);
+  } else {
+    fillSort(AGENT_SORTS,sortKey); load();  // load() -> render() -> TV.render() restores the right view/containers
+  }
   draw();
 }
 document.querySelectorAll('.mode').forEach(x=>x.onclick=()=>setMode(x.dataset.m));
@@ -326,6 +436,9 @@ document.querySelectorAll('.mode').forEach(x=>x.onclick=()=>setMode(x.dataset.m)
 $('#grid').addEventListener('click',e=>{ if(mode==='brokers') return; const card=e.target.closest('.card'); if(!card) return;
   if(e.target.tagName==='A') return;                       // let license links through
   const a=DATA[+card.dataset.idx]; if(a) openAgent(a); });
+// table-view row click → same detail modal
+$('#tableView').addEventListener('click',e=>{ if(mode==='brokers') return; const tr=e.target.closest('tr[data-idx]'); if(!tr) return;
+  if(e.target.tagName==='A') return; const a=DATA[+tr.dataset.idx]; if(a) openAgent(a); });
 $('#ov').addEventListener('click',e=>{ if(e.target.id==='ov') $('#ov').classList.remove('on'); });
 document.addEventListener('keydown',e=>{ if(e.key==='Escape') $('#ov').classList.remove('on'); });
 // ── data-point show/hide toggles (agents view only) ──
@@ -344,8 +457,10 @@ $('#density').oninput=e=>{document.documentElement.style.setProperty('--cw',e.ta
 (()=>{const s=localStorage.getItem('laSort'); if(s){sortKey=s;$('#sortsel').value=s;}
   const dv=localStorage.getItem('laDens')||'320'; $('#density').value=dv; document.documentElement.style.setProperty('--cw',dv+'px');})();
 buildFieldBar();
-if(localStorage.getItem('laMode')==='brokers') setMode('brokers'); else { $('#fieldbar').style.display='flex'; load(); }
+startViews();  // mount ThreeView when the deferred /three-view.js is ready (guarded, retries)
+if(localStorage.getItem('laMode')==='brokers') setMode('brokers'); else { $('#fieldbar').style.display='flex'; fillSort(AGENT_SORTS,sortKey); load(); }
 </script>
+  <script src="/three-view.js" defer></script><!-- 3-view helper (Grid/List/Table) — defines window.ThreeView -->
   <script src="/col-resize.js" defer></script>
 <script src="/corner-nav.js" defer></script>
   <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->

← 3971e26 residential-brokers: add 3-view (grid/list/table) + all-fiel  ·  back to Commercialrealestate  ·  licensed: add 3-view + all-field sort per tab (TK-10703) e349211 →