← back to Commercialrealestate
public/licensed-agents.html
470 lines
<!doctype html>
<html lang="en">
<head>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='light'?'light':'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>
<script>/* credential-safe fetch (2026-08-18): resolve EVERY relative fetch (with or without a leading slash) against a credential-free base so creds embedded in the page URL (http://user:pass@host/...) don't poison document.baseURI and throw "Request cannot be constructed from a URL that includes credentials". */(function(){var of=window.fetch,O=location.origin;window.fetch=function(u,o){try{if(typeof u==='string'){if(/^[a-z][a-z0-9+.-]*:/i.test(u)){var x=new URL(u);if(x.username||x.password){x.username='';x.password='';u=x.href;}}else{u=new URL(u,O+location.pathname+location.search).href;}}else if(u&&typeof u.url==='string'){var y=new URL(u.url,O+location.pathname+location.search);if(y.username||y.password){y.username='';y.password='';u=new Request(y.href,u);}}}catch(e){}return of.call(this,u,o);};try{if(location.href.indexOf('@')>=0){history.replaceState(null,'',location.pathname+location.search+location.hash);}}catch(e){}})();</script>
<!-- Self-heal: nav-agent grid controls can persist a "removed"/"collapsed" state that hides the
whole grid with no obvious way back. Clear it before nav-agent runs so a grid can never stay hidden. -->
<script>(function(){try{for(var i=localStorage.length-1;i>=0;i--){var k=localStorage.key(i);if(k&&k.indexOf('navagent:')===0){var v=localStorage.getItem(k);if(v&&v.indexOf('"removed":true')>=0){localStorage.removeItem(k);}}}}catch(e){}})();</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Licensed Agents & Brokers — CRCP</title>
<style>
:root{ --bg:#0e1116; --card:#161b22; --line:#2a313c; --ink:#e6edf3; --mut:#8b949e; --acc:#3fb950; --warn:#d29922; --bad:#f85149; --blue:#58a6ff; }
*{box-sizing:border-box;} body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}
header{display:flex;align-items:center;gap:12px;padding:11px 18px;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:40;background:rgba(14,17,22,.98);backdrop-filter:blur(8px);flex-wrap:wrap;}
header h1{font-size:16px;margin:0;} header h1 b{color:var(--acc);}
header .nav{display:flex;gap:12px;margin-left:6px;} header .nav a{color:var(--blue);text-decoration:none;font-size:13px;} header .nav a:hover{color:var(--warn);}
.search{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:8px 12px;font-size:14px;min-width:240px;}
select,#density{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:6px 8px;font-size:12px;}
label.densl{display:inline-flex;align-items:center;gap:6px;color:var(--mut);font-size:12px;}
.count{color:var(--mut);font-size:12px;}
.bar{display:flex;gap:8px;flex-wrap:wrap;padding:10px 18px;border-bottom:1px solid var(--line);align-items:center;}
.pill{display:inline-flex;gap:6px;align-items:center;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:5px 11px;font-size:12px;color:var(--mut);cursor:pointer;}
.pill b{color:var(--ink);font-variant-numeric:tabular-nums;} .pill.on{border-color:var(--blue);color:var(--ink);}
.note{color:var(--mut);font-size:11.5px;padding:8px 18px;border-bottom:1px solid var(--line);}
main{padding:14px 18px;}
.grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(var(--cw,320px),1fr));}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:13px 14px;}
.card .top{display:flex;justify-content:space-between;gap:8px;align-items:flex-start;}
.card .nm{font-weight:700;font-size:14px;min-width:0;overflow-wrap:anywhere;}
.card .bk{color:var(--mut);font-size:12px;margin-top:1px;}
/* max-width + ellipsis so a stray long status value (a.status sometimes holds a full DRE sentence) can't blow the card off-screen */
.badge{font-size:10.5px;font-weight:700;padding:3px 8px;border-radius:12px;white-space:nowrap;text-transform:uppercase;letter-spacing:.3px;flex:0 1 auto;min-width:0;max-width:60%;overflow:hidden;text-overflow:ellipsis;}
.b-active{background:rgba(63,185,80,.14);color:var(--acc);border:1px solid rgba(63,185,80,.5);}
.b-un{background:rgba(139,148,158,.12);color:var(--mut);border:1px solid var(--line);}
.b-warn{background:rgba(210,153,34,.14);color:var(--warn);border:1px solid rgba(210,153,34,.5);}
.b-bad{background:rgba(248,81,73,.14);color:var(--bad);border:1px solid rgba(248,81,73,.5);}
.kv{margin-top:10px;display:grid;grid-template-columns:auto 1fr;gap:3px 10px;font-size:12px;}
.kv .k{color:var(--mut);} .kv .v{font-variant-numeric:tabular-nums;}
.kv .v a{color:var(--blue);text-decoration:none;} .kv .v a:hover{text-decoration:underline;}
.conf{font-size:10px;color:var(--mut);margin-top:7px;}
.verified{color:var(--acc);font-weight:700;}
/* ALWAYS-VISIBLE agent contact (Steve HARD RULE 2026-08-18): phone + email on every card */
.contact{display:flex;flex-wrap:wrap;gap:6px 12px;margin-top:9px;padding-top:8px;border-top:1px solid var(--line);font-size:12px;}
.contact a{color:var(--blue);text-decoration:none;} .contact a:hover{text-decoration:underline;}
.contact .none{color:var(--mut);}
#modal .contact{font-size:13px;}
.modes{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;}
.modes button{background:var(--card);color:var(--mut);border:0;padding:6px 12px;font-size:12px;cursor:pointer;}
.modes button.on{background:var(--blue);color:#0e1116;font-weight:700;}
.bstat{display:flex;gap:8px;margin-top:10px;font-size:11px;}
.bstat span{background:rgba(139,148,158,.1);border:1px solid var(--line);border-radius:10px;padding:2px 8px;}
.bstat .sv{color:var(--acc);} .bstat .sp{color:var(--warn);} .bstat .su{color:var(--mut);}
.agnum{font-size:26px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums;}
.agnum small{font-size:11px;font-weight:500;color:var(--mut);}
.card{cursor:pointer;} .card:hover{border-color:var(--blue);}
/* show / hide data points (Steve rule 2026-07-31: START TIGHT, every field toggleable) */
.fieldbar{display:flex;flex-wrap:wrap;gap:6px 8px;align-items:center;padding:9px 18px;border-bottom:1px solid var(--line);}
.fieldbar .fbl{font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);margin-right:2px;}
.ftog{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--ink);cursor:pointer;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:3px 9px;user-select:none;}
.ftog:hover{border-color:var(--blue);} .ftog input{accent-color:var(--blue);margin:0;}
.ftog.off{color:var(--mut);opacity:.7;}
.minibtn{background:var(--card);color:var(--mut);border:1px solid var(--line);border-radius:12px;padding:3px 9px;font-size:11px;cursor:pointer;} .minibtn:hover{border-color:var(--warn);color:var(--ink);}
/* card-expand detail modal — all fields */
#ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:60;align-items:flex-start;justify-content:center;padding:40px 14px;overflow-y:auto;}
#ov.on{display:flex;}
#modal{width:min(560px,96vw);background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;position:relative;}
#modal h3{margin:0 0 2px;font-size:19px;} #modal .msub{color:var(--mut);font-size:13px;margin-bottom:10px;}
#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>
<body>
<header>
<h1>🎫 Licensed <b>Agents & Brokers</b></h1>
<div class="nav"><a href="/mls.html">← Listings</a><a href="/">Explorer</a><a href="/broker-grid.html">Broker Grid</a><a href="/crcp.html">🛰️ CRCP</a></div>
<div class="modes" id="modes"><button class="mode on" data-m="agents">👤 Agents</button><button class="mode" data-m="brokers">🏢 Brokers</button></div>
<input class="search" id="q" type="text" placeholder="search ALL fields — name, brokerage, license #, type, city, status, broker… (space = AND)">
<select id="sortsel" title="Sort">
<option value="name">Sort: Name A→Z</option>
<option value="status">Sort: Status</option>
<option value="type">Sort: License type</option>
<option value="exp">Sort: Expiration (soonest)</option>
<option value="brokerage">Sort: Brokerage</option>
<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>
<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', 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
// phone/email, so join client-side against /api/brokers/all ({name,firm,phone,email,website})
// by agent NAME **confirmed by matching FIRM** — the agents (CA DRE) and brokers (portal
// graph) are DIFFERENT populations that merely overlap on names, so name-alone mis-joins.
// Mirrors just-listed.html's helpers. Never fabricates OR mis-attributes a contact.
const clean=v=>{const s=(v==null?'':String(v)).trim();return(!s||/^null/i.test(s))?null:s;}; // drop stringified "null"
const weburl=w=>{w=clean(w);return w?(/^https?:\/\//i.test(w)?w:'http://'+w):null;};
const nn=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,''); // name key
const normFirm=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'').replace(/(inc|llc|corp|corporation|company|co|realty|realestate|group|properties|partners|associates)$/,'');
let NAMEIDX={}, FIRMIDX={}, contactsLoaded=false;
async function loadContacts(){
if(contactsLoaded) return; contactsLoaded=true;
try{
const r=await (await fetch('/api/brokers/all')).json();
const arr=(r&&(r.brokers||r.results||r))||[];
(Array.isArray(arr)?arr:[]).forEach(b=>{
const ph=clean(b.phone), em=clean(b.email);
const rec={name:clean(b.name),firm:clean(b.firm),phone:ph,email:em,website:weburl(b.website)};
const has=ph||em;
const nk=nn(b.name);
if(nk && (!NAMEIDX[nk] || (has && !(NAMEIDX[nk].phone||NAMEIDX[nk].email)))) NAMEIDX[nk]=rec;
const fk=normFirm(b.firm||b.name);
if(fk && (!FIRMIDX[fk] || (has && !(FIRMIDX[fk].phone||FIRMIDX[fk].email)))) FIRMIDX[fk]=rec;
});
}catch(e){}
}
// Return a contact ONLY when we can trust it belongs to THIS agent (Cody gate 2026-08-18):
// a name match is trustworthy only if the matched broker's FIRM also equals the agent's
// brokerage — a name-alone match routinely hits a DIFFERENT person who shares the name, and
// the old firm-only fallback glued one broker's number onto every agent at that firm. Both
// published wrong contacts as fact. If the firm doesn't confirm, return null -> DRE fallback.
function contactFor(a){
if(!a) return null;
const bn=NAMEIDX[nn(a.agent)];
if(bn && (bn.phone||bn.email) && bn.firm && normFirm(bn.firm)===normFirm(a.brokerage)) return bn;
return null; // no firm-confirmed personal contact — caller falls back to the agent's DRE profile
}
// Always-visible phone + email markup. Falls back to the agent's DRE profile / directory when no contact exists.
function contactHtml(a){
const b=contactFor(a);
const ph=b&&b.phone
? `<a href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">📞 ${esc(b.phone)}</a>`
: (a.dre_url?`<a href="${esc(a.dre_url)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">📞 via DRE ↗</a>`:'<span class="none">📞 contact via directory</span>');
const em=b&&b.email
? `<a href="mailto:${esc(b.email)}" onclick="event.stopPropagation()">✉️ ${esc(b.email)}</a>`
: (a.dre_url?`<a href="${esc(a.dre_url)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">✉️ via DRE ↗</a>`:'<span class="none">✉️ contact via directory</span>');
const site=b&&b.website?` <a href="${esc(b.website)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">🌐 site</a>`:'';
return `<div class="contact">${ph}${em}${site}</div>`;
}
// ── Card data-point fields (Steve rule 2026-07-31: show/hide toggles, START TIGHT, expand for detail) ──
// Every value the /api/agents/licensed feed carries per agent. `on` = the tight default set.
const AGENT_FIELDS=[
{k:'license', l:'License'},
{k:'license_type', l:'Type'},
{k:'expiration', l:'Expires'},
{k:'responsible_broker', l:'Resp. broker'},
{k:'discipline', l:'Discipline'},
{k:'license_city', l:'City'},
{k:'issued', l:'Issued'},
{k:'status', l:'Status (raw)'},
{k:'match_confidence', l:'Match confidence'},
];
// START TIGHT — only these show by default; the user toggles the rest on. A saved pref always wins.
const FIELD_DEFAULT_ON=new Set(['license','license_type','expiration','responsible_broker','discipline']);
let FIELDVIS=(function(){let s={};try{s=JSON.parse(localStorage.getItem('laFields')||'{}');}catch(e){}return s;})();
function fieldVis(k){ if(k in FIELDVIS) return !!FIELDVIS[k]; return FIELD_DEFAULT_ON.has(k); }
function saveFields(){ try{localStorage.setItem('laFields',JSON.stringify(FIELDVIS));}catch(e){} }
function visFields(){ return AGENT_FIELDS.filter(f=>fieldVis(f.k)); }
// Render one field value for a card / modal row (blue link for the license, plain otherwise).
function fieldVal(a,k){
if(k==='license') return a.dre_url?`<a href="${esc(a.dre_url)}" target="_blank" rel="noopener noreferrer">${esc(a.license||'—')} ↗</a>`:esc(a.license||'—');
if(k==='responsible_broker') return esc((a.responsible_broker||'—').split(/\s\d/)[0]).slice(0,60);
return esc(a[k]||'—');
}
function buildFieldBar(){
const fb=$('#fieldbar');
fb.innerHTML='<span class="fbl">Data points:</span>'+
AGENT_FIELDS.map(f=>`<label class="ftog${fieldVis(f.k)?'':' off'}"><input type="checkbox" data-fk="${f.k}" ${fieldVis(f.k)?'checked':''}>${esc(f.l)}</label>`).join('')+
'<button class="minibtn" id="fAll">All</button><button class="minibtn" id="fNone">None</button><button class="minibtn" id="fReset" title="Reset data points to the tight default">↺ Reset</button>';
}
// Card-expand: full detail of one agent — ALL fields (visible or not).
function openAgent(a){
const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on');
mb.innerHTML='<h3>'+esc(a.agent)+(a.dre_verified?' <span class="verified" title="Verified against CA DRE public license record">✔</span>':'')+'</h3>'
+'<div class="msub">'+esc(a.brokerage||'—')+' · <span class="badge '+statusClass(a)+'">'+esc(statusText(a))+'</span></div>'
+contactHtml(a)
+'<div class="kv">'+AGENT_FIELDS.map(f=>'<span class="k">'+esc(f.l)+'</span><span class="v">'+fieldVal(a,f.k)+'</span>').join('')+'</div>';
}
function statusClass(a){
if(a.dre_match==='probable') return 'b-warn';
if(!a.dre_verified) return a.status_label==='error'?'b-bad':'b-un';
const s=(a.status_label||'').toLowerCase();
if(s==='active') return 'b-active';
if(/expire|surrender|cancel|revok|suspend/.test(s)) return 'b-bad';
return 'b-warn';
}
function statusText(a){
if(a.dre_match==='probable') return 'probable';
if(!a.dre_verified) return a.status_label==='error'?'DRE error':(a.match_confidence||'unmatched');
return a.status||a.status_label||'?';
}
// 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; }
// ── 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;
if(filt==='broker'&&!/broker/i.test(a.license_type||'')) return false;
if(filt==='salesperson'&&!/salesperson/i.test(a.license_type||'')) return false;
if(filt==='unmatched'&&a.license) return false;
if(q){
// Search ALL data (Steve rule 2026-07-31): every field the API returned on the row —
// not just a hand-picked few, incl. nested objects. Space = AND.
const hay=Object.keys(a).map(k=>{const v=a[k];return v==null?'':(typeof v==='object'?JSON.stringify(v):String(v));}).join(' ').toLowerCase();
if(!q.split(/\s+/).filter(Boolean).every(t=>hay.includes(t))) return false;
}
return true;
});
const cmp={
name:(a,b)=>a.agent.localeCompare(b.agent),
status:(a,b)=>(b.dre_verified-a.dre_verified)||(a.status_label||'').localeCompare(b.status_label||''),
type:(a,b)=>(a.license_type||'zz').localeCompare(b.license_type||'zz'),
exp:(a,b)=>expNum(a)-expNum(b),
brokerage:(a,b)=>(a.brokerage||'zz').localeCompare(b.brokerage||'zz'),
conf:(a,b)=>(a.match_confidence||'zz').localeCompare(b.match_confidence||'zz'),
}[sortKey];
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.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}">
<div class="top">
<div><div class="nm">${esc(a.agent)}${a.dre_verified?' <span class="verified" title="Verified against CA DRE public license record">✔</span>':''}</div>
<div class="bk">${esc(a.brokerage||'—')}</div></div>
<span class="badge ${statusClass(a)}">${esc(statusText(a))}</span>
</div>
${fields.length?`<div class="kv">`+fields.map(f=>`<span class="k">${esc(f.l)}</span><span class="v">${fieldVal(a,f.k)}</span>`).join('')+`</div>`:''}
${contactHtml(a)}
</div>`;
}
function pills(){
const n=k=>DATA.filter(k).length;
const defs=[
['all','All',()=>true],
['verified','✔ DRE-verified',a=>a.dre_verified],
['probable','Probable',a=>a.dre_match==='probable'],
['broker','Brokers',a=>/broker/i.test(a.license_type||'')],
['salesperson','Salespersons',a=>/salesperson/i.test(a.license_type||'')],
['unmatched','Unmatched',a=>!a.license],
];
$('#pills').innerHTML=defs.map(([k,l,f])=>`<span class="pill ${filt===k?'on':''}" data-f="${k}">${l} <b>${n(f)}</b></span>`).join('');
document.querySelectorAll('.pill').forEach(p=>p.onclick=()=>{filt=p.dataset.f;pills();render();});
}
let aLoaded=false, AGENT_NOTE='';
async function load(){
await loadContacts(); // ensure phone/email join is ready before first paint (Steve HARD RULE: always show contact)
if(!aLoaded){
let d; try{ d=await (await fetch('/api/agents/licensed')).json(); }catch(e){ $('#note').textContent='Failed to load /api/agents/licensed'; return; }
DATA=d.agents||[]; META=d.meta||{};
const c=META.counts||{};
const proof=META.is_proof_run?` <b style="color:var(--warn)">⚠ PROOF RUN — ${c.in_snapshot||DATA.length} of ${META.total_agents} agents processed so far</b>`:'';
AGENT_NOTE=`Source: <b>${esc(d.source||'?')}</b> · ${esc(d.label||'')}${proof} `+
(c.verified!=null?`— <span class="verified">${c.verified} verified</span>, ${c.probable||0} probable, ${c.unmatched||0} unmatched, ${c.errors||0} errors. ${esc(META.cost||'')}`:'');
aLoaded=true;
}
$('#note').innerHTML=AGENT_NOTE;
pills(); render();
}
// ── Brokers view (rolled up from /api/brokers/licensed — the "broker database") ──
let mode='agents', BROKERS=[], bLoaded=false, bSort=localStorage.getItem('laBSort')||'agents';
const AGENT_SORTS=[['name','Name A→Z'],['status','Status'],['type','License type'],['exp','Expiration (soonest)'],['brokerage','Brokerage'],['conf','Match confidence']];
const BROKER_SORTS=[['agents','Agent count (most)'],['verified','Verified (most)'],['name','Broker A→Z']];
function fillSort(opts,cur){ $('#sortsel').innerHTML=opts.map(([v,l])=>`<option value="${v}">Sort: ${l}</option>`).join(''); $('#sortsel').value=cur; }
async function loadBrokers(){
if(bLoaded) return;
let d; try{ d=await (await fetch('/api/brokers/licensed')).json(); }catch(e){ $('#note').textContent='Failed to load /api/brokers/licensed'; return; }
BROKERS=d.brokers||[]; bLoaded=true;
const m=d.meta||{};
$('#note').innerHTML=`Source: <b>${esc(d.source||'?')}</b> · ${esc(d.label||'')} — <b>${m.broker_count||BROKERS.length}</b> supervising brokers (derived from the agents' responsible-broker field). ${esc(m.cost||'')}`+
` <span style="color:var(--mut)">Broker-licensed individuals in the set who supervise no one here appear under <b>Agents → Brokers</b> pill.</span>`;
}
function brokerCard(b){
const total=b.agent_count||0;
return `<div class="card">
<div class="top">
<div><div class="nm">${esc(b.broker||'—')}</div>
<div class="bk">${b.broker_license?('DRE #'+esc(b.broker_license)):'—'}${b.top_city?(' · '+esc(b.top_city)):''}</div></div>
<span class="agnum">${total}<br><small>agents</small></span>
</div>
<div class="bstat">
<span class="sv">✔ ${b.verified||0} verified</span>
<span class="sp">~ ${b.probable||0} probable</span>
<span class="su">? ${b.unmatched||0} unmatched</span>
</div>
</div>`;
}
function renderBrokers(){
let rows=BROKERS.filter(b=>{ if(!q) return true; return (b.broker+' '+(b.broker_license||'')+' '+(b.top_city||'')).toLowerCase().includes(q); });
const cmp={ agents:(a,b)=>b.agent_count-a.agent_count||a.broker.localeCompare(b.broker),
verified:(a,b)=>b.verified-a.verified||b.agent_count-a.agent_count,
name:(a,b)=>a.broker.localeCompare(b.broker) }[bSort];
rows.sort(cmp);
$('#grid').innerHTML=rows.map(brokerCard).join('')||'<div class="note">No brokers match.</div>';
$('#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
// 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));
// ── card-expand: click an agent card → detail modal (all fields) ──
$('#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) ──
$('#fieldbar').addEventListener('change',e=>{ const cb=e.target.closest('input[data-fk]'); if(!cb) return;
FIELDVIS[cb.dataset.fk]=cb.checked; saveFields(); buildFieldBar(); render(); });
$('#fieldbar').addEventListener('click',e=>{
if(e.target.id==='fAll'){ AGENT_FIELDS.forEach(f=>FIELDVIS[f.k]=true); saveFields(); buildFieldBar(); render(); }
if(e.target.id==='fNone'){ AGENT_FIELDS.forEach(f=>FIELDVIS[f.k]=false); saveFields(); buildFieldBar(); render(); }
if(e.target.id==='fReset'){ FIELDVIS={}; saveFields(); buildFieldBar(); render(); }
});
// controls
$('#q').oninput=e=>{q=e.target.value.toLowerCase().trim();draw();};
$('#sortsel').onchange=e=>{ if(mode==='brokers'){bSort=e.target.value;localStorage.setItem('laBSort',bSort);} else {sortKey=e.target.value;localStorage.setItem('laSort',sortKey);} draw();};
$('#density').oninput=e=>{document.documentElement.style.setProperty('--cw',e.target.value+'px');localStorage.setItem('laDens',e.target.value);};
// restore persisted
(()=>{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();
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 -->
<script src="/crcp-theme.js" defer></script><!-- CRCP day/night toggle (auto-injects into header) -->
</body>
</html>