← 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
M public/licensed-agents.html
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=>({'&':'&','<':'<','>':'>','"':'"'}[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 →