← back to Commercialrealestate
public/licensed-agents.html
217 lines
<!doctype html>
<html lang="en">
<head>
<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;}
.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);}
</style>
</head>
<body>
<header>
<h1>🎫 Licensed <b>Agents & Brokers</b></h1>
<div class="nav"><a href="/mls.html">← MLS</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 name, brokerage, license #, responsible broker…">
<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 class="count" id="count">…</span>
</header>
<div class="bar" id="pills"></div>
<div class="note" id="note"></div>
<main><div class="grid" id="grid"></div></main>
<script>
const $=s=>document.querySelector(s);
let DATA=[], META={}, filt='all', q='', sortKey='name';
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
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; }
function render(){
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){ const hay=(a.agent+' '+(a.brokerage||'')+' '+(a.license||'')+' '+(a.responsible_broker||'')+' '+(a.license_type||'')).toLowerCase(); if(!hay.includes(q)) 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];
rows.sort(cmp);
$('#grid').innerHTML=rows.map(cardHtml).join('')||'<div class="note">No agents match.</div>';
$('#count').textContent=rows.length+' / '+DATA.length+' agents';
}
function cardHtml(a){
const dre=a.dre_url?`<a href="${esc(a.dre_url)}" target="_blank" rel="noopener noreferrer">${esc(a.license)} ↗</a>`:'—';
const rb=a.responsible_broker?esc(a.responsible_broker.split(/\s\d/)[0]).slice(0,60):'—';
return `<div class="card">
<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>
<div class="kv">
<span class="k">License</span><span class="v">${dre}</span>
<span class="k">Type</span><span class="v">${esc(a.license_type||'—')}</span>
<span class="k">Expires</span><span class="v">${esc(a.expiration||'—')}</span>
<span class="k">Resp. broker</span><span class="v">${rb}</span>
<span class="k">Discipline</span><span class="v">${esc(a.discipline||'—')}</span>
</div>
${a.match_confidence?`<div class="conf">match: ${esc(a.match_confidence)}</div>`:''}
</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(){
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(); }
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':'';
if(m==='brokers'){ await loadBrokers(); fillSort(BROKER_SORTS,bSort); }
else { fillSort(AGENT_SORTS,sortKey); load(); }
draw();
}
document.querySelectorAll('.mode').forEach(x=>x.onclick=()=>setMode(x.dataset.m));
// 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');})();
if(localStorage.getItem('laMode')==='brokers') setMode('brokers'); else load();
</script>
<script src="/col-resize.js" defer></script>
</body>
</html>