← back to Commercialrealestate

public/broker-grid.html

345 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230c0f15'/%3E%3Ctext x='8' y='12' font-size='9' text-anchor='middle' fill='%23C8A24B'%3E%F0%9F%91%A5%3C/text%3E%3C/svg%3E">
<title>Brokers &amp; Agents — Grid / List</title>
<style>
  :root{ --bg:#0c0f15; --card:#141925; --line:#232a38; --ink:#E6E8EC; --mut:#8A93A2; --gold:#C8A24B; --acc:#70AD47; --blue:#58a6ff; --warn:#d29922; }
  *{box-sizing:border-box;} body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",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(12,15,21,.98);backdrop-filter:blur(8px);flex-wrap:wrap;}
  header h1{font-size:16px;margin:0;} header h1 b{color:var(--gold);}
  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(--gold);}
  .spacer{flex:1;}
  .search{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:8px 12px;font-size:14px;min-width:220px;}
  .toggle{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden;}
  .toggle button{background:var(--card);border:0;color:var(--mut);padding:8px 13px;font-size:13px;cursor:pointer;}
  .toggle button.on{background:var(--gold);color:#1a1205;font-weight:700;}
  .csvbtn{background:var(--card);border:1px solid var(--line);color:var(--acc);border-radius:9px;padding:8px 12px;font-size:13px;cursor:pointer;} .csvbtn:hover{border-color:var(--acc);}
  .count{color:var(--mut);font-size:12px;}
  .wrap{padding:24px 28px 60px;}
  .tblwrap{overflow:auto;max-height:calc(100vh - 130px);border:1px solid var(--line);border-radius:10px;}
  table.g{border-collapse:separate;border-spacing:0;width:100%;font-size:12.5px;white-space:nowrap;}
  table.g thead th{position:sticky;top:0;background:#1b2230;color:var(--mut);text-transform:uppercase;letter-spacing:.3px;font-size:10.5px;padding:8px 10px;border-bottom:1px solid var(--line);cursor:pointer;user-select:none;z-index:2;}
  table.g thead th:hover{color:var(--ink);}
  table.g thead th.asc::after{content:' ▲';color:var(--gold);} table.g thead th.desc::after{content:' ▼';color:var(--gold);}
  table.g tbody td{padding:7px 10px;border-bottom:1px solid #20262f;}
  table.g tbody tr:hover td{background:rgba(200,162,75,.07);}
  table.g td.n{text-align:right;font-variant-numeric:tabular-nums;}
  table.g a{color:var(--blue);text-decoration:none;} table.g a:hover{text-decoration:underline;}
  .atype{display:inline-block;font-size:10px;font-weight:700;text-transform:uppercase;padding:1px 6px;border-radius:8px;}
  .atype.res{background:rgba(88,166,255,.16);color:var(--blue);} .atype.com{background:rgba(200,162,75,.16);color:var(--gold);}
  .mut{color:var(--mut);} .miss{color:#5A6472;}
  .findmail{background:transparent;border:1px solid #5B9BD5;color:#5B9BD5;border-radius:9px;padding:2px 8px;font-size:11px;cursor:pointer;white-space:nowrap;}
  .findmail:hover{background:rgba(91,155,213,.12);}
  .hunting{color:#E0A33E;font-size:12px;}
  .geo{display:inline-block;font-size:10px;font-weight:700;padding:1px 7px;border-radius:8px;white-space:nowrap;}
  .geo.ca{background:rgba(127,174,106,.18);color:#8fd06f;border:1px solid rgba(127,174,106,.5);}
  .geo.non{background:rgba(248,81,73,.16);color:#f77;border:1px solid rgba(248,81,73,.5);}
  .geo.unk{background:rgba(138,147,162,.14);color:var(--mut);border:1px solid var(--line);}
  #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(700px,96vw);background:var(--card);border:1px solid var(--line);border-radius:15px;padding:20px 22px;position:relative;}
  #modal h3{margin:0 0 2px;font-size:20px;} #modal .mfirm{color:var(--gold);font-size:13px;}
  #modal .x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;}
  #modal .ci{display:flex;gap:16px;flex-wrap:wrap;margin:10px 0;font-size:14px;} #modal .ci a{color:var(--blue);text-decoration:none;}
  #modal .sec{margin-top:14px;} #modal .sec h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);}
  #modal .litem{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid #20262f;font-size:13px;} #modal .litem .m{color:var(--mut);}
  .mbtn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:7px 12px;font-size:13px;cursor:pointer;} .mbtn:hover{border-color:var(--blue);}
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card-min,280px),1fr));gap:20px;}
  #sortsel{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5px 8px;font-size:12px}
  #denswrap{display:none;align-items:center;gap:6px;font-size:13px;color:var(--mut)}
  #denswrap input[type=range]{width:92px;accent-color:var(--acc)}
  .gc{background:var(--card);border:2px solid #fff;border-radius:11px;padding:18px 20px;}
  .gc .nm{font-weight:700;} .gc .fm{color:var(--mut);font-size:12px;margin-bottom:6px;}
  .gc .row{display:flex;justify-content:space-between;gap:8px;font-size:12.5px;padding:2px 0;border-top:1px solid #20262f;}
  .gc .row .k{color:var(--mut);} .gc .ci{display:flex;gap:12px;margin-top:8px;flex-wrap:wrap;font-size:12.5px;}
  .hide{display:none;}
  .copybtn { background:none; border:0; color:var(--mut); font-size:14px; line-height:1; cursor:pointer; padding:0; }
  .copybtn:hover { color:var(--blue); }
  table.g td.actd, table.g th.acth { width:26px; text-align:center; padding:5px 4px; }
  .statsbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:9px 22px; border-bottom:1px solid var(--line); }
  .statsbar:empty { display:none; }
  .statpill { font-size:12px; color:var(--ink); background:var(--card); border:1px solid var(--line); border-radius:8px; padding:4px 10px; }
  .statpill .sl { color:var(--mut); }
  /* ---- Amazon-style left rail ---- */
  .shell{display:flex;align-items:flex-start;}
  .rail{flex:0 0 260px;background:#0a0d13;border-right:1px solid var(--line);align-self:stretch;position:sticky;top:53px;height:calc(100vh - 53px);overflow-y:auto;padding:0 0 40px;}
  .rail .rsec{padding:13px 16px;border-bottom:1px solid var(--line);}
  .rail h4{margin:0 0 9px;font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--mut);}
  .chips{display:flex;flex-wrap:wrap;gap:6px;}
  .chip{background:var(--card);color:var(--mut);border:1px solid var(--line);border-radius:16px;padding:4px 10px;font-size:12px;cursor:pointer;user-select:none;}
  .chip:hover{color:var(--ink);} .chip.active{border-color:var(--gold);color:var(--gold);background:rgba(200,162,75,.10);}
  .chip .ct{color:var(--mut);font-size:10px;margin-left:4px;}
  .fieldtoggles{display:flex;flex-direction:column;gap:1px;max-height:320px;overflow-y:auto;}
  .ftog{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--ink);cursor:pointer;padding:3px 4px;border-radius:6px;} .ftog:hover{background:rgba(200,162,75,.08);} .ftog input{accent-color:var(--gold);}
  .fgrp{font-size:9.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--gold);margin:9px 0 3px;font-weight:700;opacity:.85;} .fgrp:first-child{margin-top:0;}
  .minibtn{background:var(--card);color:var(--mut);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:11px;cursor:pointer;margin-right:5px;} .minibtn:hover{border-color:var(--gold);color:var(--ink);}
  .wrap{flex:1 1 auto;min-width:0;}
  @media(max-width:820px){ .rail{display:none;} }
  .rail .rsec > h4 { cursor:pointer; display:flex; align-items:center; justify-content:space-between; user-select:none; }
  .rail .rsec > h4::after { content:'▾'; color:var(--mut); font-size:11px; margin-left:8px; transition:transform .15s; }
  .rail .rsec.collapsed > h4::after { transform:rotate(-90deg); }
  .rail .rsec.collapsed > *:not(h4) { display:none !important; }
</style>
</head>
<body>
<header>
  <h1><b>👥 Brokers</b> &amp; Agents</h1>
  <div class="nav"><a href="/crcp.html">← CRCP</a><a href="/">Explorer</a><a href="/mls.html">📋 MLS</a><a href="/gov-agents.html">🏛️ Gov-Licensed</a><a href="/linkedin.html">🔗 LinkedIn</a></div>
  <span class="spacer"></span>
  <input class="search" id="q" type="text" placeholder="search name, firm, phone, email, specialty…">
  <div class="toggle" id="view"><button data-v="table" class="on">▦ List</button><button data-v="grid">▤ Grid</button></div>
  <select id="sortsel" title="Sort brokers"></select>
  <label id="denswrap" title="Card density — grid view">⤢<input type="range" id="density" min="1" max="10" step="1"></label>
  <button class="csvbtn" id="csv" title="Export current rows to CSV">⬇ CSV</button>
  <span class="count" id="count">…</span>
</header>
<div class="shell">
<aside class="rail">
  <div class="rsec"><h4>Agent type</h4><div class="chips" id="fType"></div></div>
  <div class="rsec"><h4>Company / firm</h4><div class="chips" id="fFirm"></div></div>
  <div class="rsec"><h4>Fields / columns — show / hide</h4>
    <div class="fieldtoggles" id="fFields"></div>
    <div style="margin-top:8px"><button class="minibtn" id="fAll">All</button><button class="minibtn" id="fNone">None</button></div>
  </div>
</aside>
<div class="wrap">
  <div class="statsbar" id="statsbar"></div>
  <div id="tableView" class="tblwrap"><table class="g"><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
  <div id="gridView" class="grid hide"></div>
</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>
const $=s=>document.querySelector(s);
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
const num=n=>(n==null||n==='')?'—':Number(n).toLocaleString();
const tel=v=>v?`<a href="tel:${esc(v)}">${esc(v)}</a>`:'<span class="miss">—</span>';
const mail=v=>v?`<a href="mailto:${esc(v)}">${esc(v)}</a>`:'<span class="miss">—</span>';
const url=(v,lbl)=>{ if(!v) return '<span class="miss">—</span>'; const h=/^https?:/.test(v)?v:'https://'+v; return `<a href="${esc(h)}" target="_blank" rel="noopener noreferrer">${lbl}</a>`; };
// Website links show the SITE NAME (domain), not a bare "site↗" — Steve 2026-07-13.
const siteName=u=>{ try{ return new URL(/^https?:/.test(u)?u:'https://'+u).hostname.replace(/^www\./,''); }catch(e){ return u; } };
const web=v=>{ if(!v) return '<span class="miss">—</span>'; const h=/^https?:/.test(v)?v:'https://'+v; return `<a href="${esc(h)}" target="_blank" rel="noopener noreferrer" title="${esc(h)}">${esc(siteName(v))}↗</a>`; };
// On-demand email hunt (the "✉ find email" button) — POSTs /api/broker/find-email, which visits
// the broker's site and extracts an email with the batch finder's guards. hunt[] keeps per-row
// state across re-renders; a success also writes the row in DATA so every view picks it up.
const hunt={};
function mailCell(r){
  if(r.email) return mail(r.email);
  const h=hunt[r.id];
  if(h){
    if(h.state==='hunting') return '<span class="hunting">⏳ hunting…</span>';
    if(h.email) return mail(h.email);
    return `<span class="miss" title="${esc(h.miss||'')}">not found</span>`;
  }
  if(r.website) return `<button class="findmail" data-id="${r.id}" title="Visit ${esc(siteName(r.website))} and hunt down an email address">✉ find email</button>`;
  return '<span class="miss">—</span>';
}
async function findEmail(id){
  const r=DATA.find(x=>+x.id===+id); if(!r) return;
  hunt[id]={state:'hunting'}; render();
  try{
    const d=await (await fetch('/api/broker/find-email',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:+id,name:r.name,website:r.website})})).json();
    if(d.email){ hunt[id]={email:d.email}; r.email=d.email; }
    else hunt[id]={miss:d.reason||d.error||'not found'};
  }catch(e){ hunt[id]={miss:e.message}; }
  render();
}
const dt=s=>{ if(!s) return '—'; try{ return new Date(s).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); }catch(e){ return '—'; } };
// Every field carries a group `g` (for the sort menu optgroups + organized rail toggles).
// Order below IS the column order. Every field is sortable (raw-value sort, any type).
const COLS=[
  // ── Identity ──
  {k:'name',l:'Name',t:'s',g:'Identity'},
  {k:'firm',l:'Company / Firm',t:'s',g:'Identity'},
  {k:'agent_type',l:'Type',t:'atype',g:'Identity'},
  {k:'title',l:'Title',t:'s',g:'Identity'},
  // ── Geography ── (added by the CA-DRE cross-ref work)
  {k:'state',l:'State',t:'geo',g:'Geography'},
  {k:'dre_match',l:'CA Confidence',t:'geo',g:'Geography'},
  {k:'license',l:'DRE / MLS #',t:'s',g:'Geography'},
  {k:'dre_license',l:'DRE License #',t:'s',g:'Geography',def:0},
  // ── Activity ──
  {k:'listings',l:'Listings',t:'n',g:'Activity'},
  {k:'total_assets',l:'Total Assets',t:'n',g:'Activity'},
  // ── Contact ──
  {k:'phone',l:'Phone',t:'tel',g:'Contact'},
  {k:'email',l:'Email',t:'mail',g:'Contact'},
  {k:'website',l:'Website',t:'web',g:'Contact'},
  {k:'linkedin',l:'LinkedIn',t:'li',g:'Contact'},
  // ── Details ──
  {k:'specialties',l:'Specialties',t:'s',g:'Details'},
  {k:'office_addr',l:'Office',t:'s',g:'Details'},
  {k:'source',l:'Source',t:'s',g:'Details'},
  {k:'created_at',l:'Added',t:'date',g:'Details'},
];
let DATA=[], view='table', sortKey='listings', sortDir=-1, q='';
const F={types:new Set(),firms:new Set()};
let VISCOL=(function(){let s={};try{s=JSON.parse(localStorage.getItem('brokerCols')||'{}');}catch(e){}return s;})();
function colVis(k){ if(k in VISCOL) return !!VISCOL[k]; const c=COLS.find(x=>x.k===k); return c?c.def!==0:true; }
function visCols(){ return COLS.filter(c=>colVis(c.k)); }
function saveCols(){ try{localStorage.setItem('brokerCols',JSON.stringify(VISCOL));}catch(e){} }
function tog(set,v){ set.has(v)?set.delete(v):set.add(v); }
function autoCols(){
  const known=new Set(COLS.map(c=>c.k)); const skip=new Set(['id']);
  const seen=new Set();
  DATA.slice(0,60).forEach(r=>Object.keys(r||{}).forEach(k=>{
    if(known.has(k)||skip.has(k)||seen.has(k)) return; const v=r[k]; if(v==null) return;
    if(typeof v==='object') return; seen.add(k);
    const isNum = typeof v==='number' || (v!==''&&!isNaN(+v));
    COLS.push({k, l:k.replace(/_/g,' ').replace(/\b\w/g,ch=>ch.toUpperCase()).slice(0,20), t:isNum?'n':'s', g:'Other', def:0});
  }));
}
function buildRail(){
  const tc={},fc={};
  DATA.forEach(r=>{ if(r.agent_type)tc[r.agent_type]=(tc[r.agent_type]||0)+1; if(r.firm)fc[r.firm]=(fc[r.firm]||0)+1; });
  const lbl={residential:'Residential',commercial:'Commercial'};
  $('#fType').innerHTML=Object.entries(tc).sort((a,b)=>b[1]-a[1]).map(([k,c])=>`<span class="chip" data-ty="${esc(k)}">${esc(lbl[k]||k)}<span class="ct">${c}</span></span>`).join('');
  $('#fFirm').innerHTML=Object.entries(fc).sort((a,b)=>b[1]-a[1]).slice(0,16).map(([k,c])=>`<span class="chip" data-fm="${esc(k)}">${esc(k)}<span class="ct">${c}</span></span>`).join('');
  buildFieldToggles();
}
function buildFieldToggles(){
  const groups={};
  COLS.forEach(c=>{ (groups[c.g||'Other']=groups[c.g||'Other']||[]).push(c); });
  $('#fFields').innerHTML=Object.entries(groups).map(([g,cs])=>
    `<div class="fgrp">${esc(g)}</div>`+cs.map(c=>`<label class="ftog"><input type="checkbox" data-ck="${c.k}" ${colVis(c.k)?'checked':''}><span>${esc(c.l)}</span></label>`).join('')
  ).join('');
}
function passFacets(r){
  if(F.types.size && !F.types.has(r.agent_type)) return false;
  if(F.firms.size && !F.firms.has(r.firm)) return false;
  return true;
}
function cell(r,c){
  const v=r[c.k];
  if(c.k==='name') return esc(v==null?'':v);
  if(c.t==='n') return num(v);
  if(c.t==='tel') return tel(v);
  if(c.t==='mail') return mailCell(r);
  if(c.t==='web') return web(v);
  if(c.t==='li') return url(v,'in↗');
  if(c.t==='date') return dt(v);
  if(c.t==='atype') return v==='residential'?'<span class="atype res">Residential</span>':'<span class="atype com">Commercial</span>';
  if(c.t==='geo'){
    if(c.k==='state'){
      if(v==='CA') return '<span class="geo ca">CA ✓</span>';
      if(v==null||v==='') return '<span class="geo unk">unknown</span>';
      return '<span class="geo non">'+esc(v)+'</span>';
    }
    // dre_match / CA-confidence provenance
    if(v==null||v==='') return '<span class="miss">—</span>';
    const map={verified:'ca',probable:'ca','probable-fuzzy':'ca','ca-by-city':'ca','ca-by-listing-activity':'ca','address-derived':'ca',unmatched:'unk',error:'non'};
    return '<span class="geo '+(map[v]||'unk')+'">'+esc(v)+'</span>';
  }
  return esc(v==null||v===''?'—':v);
}
function filtered(){
  let rows=DATA.filter(passFacets);
  if(q){ const t=q.toLowerCase(); rows=rows.filter(r=>COLS.some(c=>{const v=r[c.k];return v!=null&&String(v).toLowerCase().includes(t);})); }
  const c=COLS.find(x=>x.k===sortKey)||COLS[0];
  rows=rows.slice().sort((a,b)=>{
    let x=a[c.k], y=b[c.k];
    const xn=(x!=null&&x!==''&&!isNaN(+x)), yn=(y!=null&&y!==''&&!isNaN(+y));
    if(xn&&yn){ x=+x; y=+y; } else { x=String(x==null?'':x).toLowerCase(); y=String(y==null?'':y).toLowerCase(); }
    if(x<y) return -sortDir; if(x>y) return sortDir; return 0;
  });
  return rows;
}
function render(){
  const rows=filtered(); const cols=visCols();
  $('#count').textContent=`${rows.length} of ${DATA.length} brokers/agents`;
  $('#thead').innerHTML='<tr>'+cols.map(c=>`<th data-k="${c.k}" class="${sortKey===c.k?(sortDir>0?'asc':'desc'):''}">${esc(c.l)}</th>`).join('')+'</tr>';
  if(view==='table'){
    $('#tableView').classList.remove('hide'); $('#gridView').classList.add('hide');
    $('#tbody').innerHTML=rows.map(r=>`<tr class="brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">`+cols.map(c=>{const cls=(c.t==='n')?' class="n"':'';return `<td${cls}>${cell(r,c)}</td>`;}).join('')+'</tr>').join('');
  } else {
    $('#tableView').classList.add('hide'); $('#gridView').classList.remove('hide');
    const skipGrid=new Set(['name','firm','agent_type','phone','email','website','linkedin','title']);
    const bodyCols=cols.filter(c=>!skipGrid.has(c.k));
    $('#gridView').innerHTML=rows.map(r=>`<div class="gc brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">
      <div class="nm">${esc(r.name)} ${r.agent_type==='residential'?'<span class="atype res">Res</span>':'<span class="atype com">Com</span>'}</div>
      <div class="fm">${esc(r.firm||'—')}${r.title?' · '+esc(r.title):''}</div>`
      +bodyCols.map(c=>`<div class="row"><span class="k">${esc(c.l)}</span><span>${cell(r,c)}</span></div>`).join('')
      +`<div class="ci">${tel(r.phone)} ${mailCell(r)} ${web(r.website)} ${r.linkedin?url(r.linkedin,'in↗'):''}</div>
    </div>`).join('');
  }
}
const money=n=>(n==null||n==='')?'—':'$'+Number(n).toLocaleString();
const fdate=s=>{ if(!s) return ''; try{ return new Date(s).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); }catch(e){ return ''; } };
const atb=t=>t==='residential'?'<span class="atype res">Residential</span>':'<span class="atype com">Commercial</span>';
async function openContact(name,id){
  const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="mut">loading '+esc(name)+'…</div>';
  const qs=id?'id='+encodeURIComponent(id):'name='+encodeURIComponent(name);
  let d; try{ d=await (await fetch('/api/broker?'+qs)).json(); }catch(e){ mb.innerHTML='<div class="mut">error</div>'; return; }
  if(d.error){ mb.innerHTML='<div class="mut">'+esc(d.error)+'</div>'; return; }
  const b=d.broker, w=b.website?(/^https?:/.test(b.website)?b.website:'https://'+b.website):null;
  const li=b.linkedin?(/^https?:/.test(b.linkedin)?b.linkedin:'https://'+b.linkedin):null;
  mb.innerHTML='<h3>'+esc(b.name)+atb(b.agent_type)+'</h3><div class="mfirm">'+esc(b.firm||'—')+(b.title?' · '+esc(b.title):'')+(b.license?' · DRE/MLS '+esc(b.license):'')+'</div>'
    +'<div class="ci">'
    +(b.phone?'<a href="tel:'+esc(b.phone)+'">☎ '+esc(b.phone)+'</a>':'<span class="mut">☎ —</span>')
    +(b.email?'<a href="mailto:'+esc(b.email)+'">✉ '+esc(b.email)+'</a>':'<span class="mut">✉ —</span>')
    +(w?'<a href="'+esc(w)+'" target="_blank" rel="noopener noreferrer">🌐 '+esc(w.replace(/^https?:\/\/(www\.)?/,'').replace(/\/$/,''))+'</a>':'<span class="mut">🌐 —</span>')
    +(li?'<a href="'+esc(li)+'" target="_blank" rel="noopener noreferrer">in↗</a>':'')+'</div>'
    +'<div class="sec"><h4>Current listings ('+d.current.length+')</h4>'+(d.current.length?d.current.map(l=>'<div class="litem"><span>'+esc(l.address)+', '+esc(l.city)+(l.zip?' '+esc(l.zip):'')+'</span><span class="m">'+esc(l.type)+' · '+money(l.price)+(l.units?' · '+l.units+'u':'')+(l.cap_rate?' · '+l.cap_rate+'% cap':'')+(l.listed_at?' · listed '+fdate(l.listed_at):'')+'</span></div>').join(''):'<div class="mut">none in our set</div>')+'</div>'
    +'<div class="sec"><h4>Expired / off-market ('+((d.expired&&d.expired.length)||0)+')</h4>'+((d.expired&&d.expired.length)?d.expired.map(x=>'<div class="litem"><span>'+esc(x.address)+', '+esc(x.city)+'</span><span class="m">'+money(x.list_price)+' → sold '+money(x.sold_price)+' · '+fdate(x.sold_date)+(x.mls?' · MLS '+esc(x.mls):'')+(x.url?' · <a href="'+esc(x.url)+'" target="_blank" rel="noopener noreferrer">↗</a>':'')+'</span></div>').join(''):'<div class="mut">'+esc(d.expiredNote||'none')+'</div>')+'</div>'
    +'<div class="sec"><h4>Sales — area sold ('+((d.comps&&d.comps.length)||0)+')</h4>'+((d.comps&&d.comps.length)?d.comps.map(c=>'<div class="litem"><span>'+esc(c.address)+', '+esc(c.city)+(c.beds?' · '+c.beds+'bd':'')+(c.baths?'/'+c.baths+'ba':'')+(c.sqft?' · '+Number(c.sqft).toLocaleString()+'sf':'')+'</span><span class="m">'+money(c.sold_price)+' · '+fdate(c.sold_date)+(c.mls?' · MLS '+esc(c.mls):'')+(c.url?' · <a href="'+esc(c.url)+'" target="_blank" rel="noopener noreferrer">↗</a>':'')+'</span></div>').join(''):'<div class="mut">no area sales</div>')+'</div>';
}
document.addEventListener('click',e=>{ const fm=e.target.closest('.findmail'); if(fm){ findEmail(fm.dataset.id); return; } const row=e.target.closest('.brow'); if(row && e.target.tagName!=='A' && e.target.tagName!=='BUTTON'){ openContact(row.dataset.name,row.dataset.id); } if(e.target.id==='ov') $('#ov').classList.remove('on'); });
$('#thead').addEventListener('click',e=>{const th=e.target.closest('th');if(!th)return;const k=th.dataset.k;
  if(sortKey===k)sortDir*=-1;else{sortKey=k;sortDir=(k==='listings'||k==='total_assets'||k==='created_at')?-1:1;}
  localStorage.setItem('brokSortKey',sortKey);localStorage.setItem('brokSortDir',sortDir);if(window.__syncSortSel)window.__syncSortSel();render();});
$('#q').addEventListener('input',()=>{q=$('#q').value.trim();render();});
function exportCSV(){ const rows=filtered(); const cols=visCols(); const cv=(r,c)=>{let v=r[c.k]; v=(v==null?'':String(v)); return '"'+v.replace(/"/g,'""')+'"';};
  const csv=cols.map(c=>'"'+c.l+'"').join(',')+'\n'+rows.map(r=>cols.map(c=>cv(r,c)).join(',')).join('\n');
  const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'})); a.download='cre-brokers-'+rows.length+'.csv'; a.click(); URL.revokeObjectURL(a.href); }
$('#csv').addEventListener('click',exportCSV);
$('#view').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;
  document.querySelectorAll('#view button').forEach(x=>x.classList.toggle('on',x===b));view=b.dataset.v;localStorage.setItem('brokView',view);$('#denswrap').style.display=(view==='grid')?'inline-flex':'none';render();});
// ── sort <select> + density slider (Steve rule: every grid gets sort + density) ──
(function(){
  const sel=$('#sortsel'),dz=$('#density');
  // Grouped sort menu — EVERY field is sortable, organized under its category optgroup.
  // Rebuildable so auto-discovered columns join the menu once data loads.
  window.__buildSortSel=()=>{
    const groups={};
    COLS.forEach(c=>{ (groups[c.g||'Other']=groups[c.g||'Other']||[]).push(c); });
    sel.innerHTML=Object.entries(groups).map(([g,cs])=>
      `<optgroup label="${esc(g)}">`+cs.map(c=>`<option value="${c.k}">${esc(c.l)}</option>`).join('')+`</optgroup>`).join('');
    if(Array.from(sel.options).some(o=>o.value===sortKey)) sel.value=sortKey;
  };
  window.__buildSortSel();
  const sk=localStorage.getItem('brokSortKey'),sd=localStorage.getItem('brokSortDir');
  if(sk&&COLS.some(c=>c.k===sk))sortKey=sk; if(sd==='1'||sd==='-1')sortDir=+sd;
  if(!Array.from(sel.options).some(o=>o.value===sortKey))sortKey='listings';
  sel.value=sortKey;
  sel.addEventListener('change',()=>{sortKey=sel.value;const c=COLS.find(x=>x.k===sortKey);
    sortDir=(c&&(c.t==='n'||c.t==='date'))?-1:1;
    localStorage.setItem('brokSortKey',sortKey);localStorage.setItem('brokSortDir',sortDir);render();});
  const widthFor=v=>Math.round(400-(Math.max(1,Math.min(10,v))-1)*(400-150)/9);
  const applyDensity=v=>document.documentElement.style.setProperty('--card-min',widthFor(v)+'px');
  const dv=parseInt(localStorage.getItem('brokDensity'),10),dval=(dv>=1&&dv<=10)?dv:5;
  dz.value=dval;applyDensity(dval);
  dz.addEventListener('input',()=>{const v=+dz.value;applyDensity(v);localStorage.setItem('brokDensity',v);});
  const pv=localStorage.getItem('brokView'); if(pv==='grid'||pv==='table'){view=pv;document.querySelectorAll('#view button').forEach(x=>x.classList.toggle('on',x.dataset.v===view));}
  $('#denswrap').style.display=(view==='grid')?'inline-flex':'none';
  window.__syncSortSel=()=>{if(Array.from(sel.options).some(o=>o.value===sortKey)&&sel.value!==sortKey)sel.value=sortKey;};
})();
// left-rail facets + column toggles
$('#fType').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.types,c.dataset.ty);c.classList.toggle('active');render();});
$('#fFirm').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.firms,c.dataset.fm);c.classList.toggle('active');render();});
$('#fFields').addEventListener('change',e=>{const cb=e.target.closest('input[data-ck]');if(!cb)return;VISCOL[cb.dataset.ck]=cb.checked;saveCols();render();});
$('#fAll').addEventListener('click',()=>{COLS.forEach(c=>VISCOL[c.k]=true);saveCols();buildFieldToggles();render();});
$('#fNone').addEventListener('click',()=>{COLS.forEach(c=>VISCOL[c.k]=false);saveCols();buildFieldToggles();render();});
// collapsible rail tabs — all collapsed on load
const railEl=document.querySelector('.rail');
if(railEl){ railEl.addEventListener('click',e=>{ const h=e.target.closest('h4'); if(h && h.parentElement.classList.contains('rsec')) h.parentElement.classList.toggle('collapsed'); });
  document.querySelectorAll('.rail .rsec').forEach(s=>s.classList.add('collapsed')); }
fetch('/api/brokers/all').then(r=>r.json()).then(d=>{DATA=d.brokers||[];autoCols();if(window.__buildSortSel)window.__buildSortSel();buildRail();render();})
  .catch(()=>{$('#count').textContent='failed to load /api/brokers/all';});
</script>
  <script src="/col-resize.js" defer></script>
</body>
</html>