← 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=>({'&':'&','<':'<','>':'>','"':'"'}[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 →