← 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 &amp; 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>