← back to Commercialrealestate

public/broker-grid.html

520 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">
<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);}
  /* draggable column reorder (Steve rule 2026-07-31: move columns around) */
  table.g thead th.dragcol{cursor:grab;}
  table.g thead th.dragging{opacity:.45;}
  table.g thead th.dropL{box-shadow:inset 3px 0 0 var(--gold);}
  table.g thead th.dropR{box-shadow:inset -3px 0 0 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);}
  .ftog .flbl{flex:1;}
  .ftog .fdrag{cursor:grab;color:var(--mut);font-size:12px;opacity:.5;user-select:none;line-height:1;} .ftog:hover .fdrag{opacity:1;}
  .ftog.fdragging{opacity:.45;}
  .ftog.fdropT{box-shadow:inset 0 3px 0 var(--gold);} .ftog.fdropB{box-shadow:inset 0 -3px 0 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>
  <link rel="stylesheet" href="/three-view.css">
  <script src="/three-view.js"></script>
  <script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->

  <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><b>👥 Brokers</b> &amp; Agents</h1>
  <div class="nav"><a href="/crcp.html">← CRCP</a><a href="/">Explorer</a><a href="/mls.html">📋 Listings</a><a href="/gov-agents.html">🏛️ Gov-Licensed</a><a href="/linkedin.html">🔗 LinkedIn</a><a href="/brokers.html">🕸️ Mindmap</a></div>
  <span class="spacer"></span>
  <input class="search" id="q" type="text" placeholder="search all fields — name, firm, phone, email, license, source… (space = AND)">
  <div class="toggle tv-seg" id="view"><button data-v="grid">▦ Grid</button><button data-v="list">☰ List</button><button data-v="table" class="on">▤ Table</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" id="fieldsSec"><h4>Fields / columns — show / hide · reorder</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><button class="minibtn" id="fReset" title="Reset columns to the tight default — order, widths &amp; visibility">↺ Reset</button></div>
    <div class="mut" style="font-size:10.5px;margin-top:7px;line-height:1.35">Drag a row by its ⠿ handle to reorder columns. In List view you can also drag a column header to reorder · its right edge to resize · click to sort.</div>
  </div>
  <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>
</aside>
<div class="wrap">
  <div class="statsbar" id="statsbar"></div>
  <div id="tableView" class="tblwrap"><table class="g" data-no-sort><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
  <div id="gridView" class="grid hide"></div>
  <div id="listView" class="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,'&gt;').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();
}
// 'Added' (created_at) is an admin-card timestamp → date AND time, full ISO in title=
// (Steve HARD rule). created_at is the only t:'date' column, so this formatter is safe to
// upgrade wholesale; pure DATE fields (sold_date) use fdate() and stay date-only.
const dt=s=>{ if(!s) return '—'; try{ const d=new Date(s); if(isNaN(d)) return '—'; const t=d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); return '<span title="'+esc(d.toISOString())+'">'+esc(t)+'</span>'; }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='', lastColSig='';
const F={types:new Set(),firms:new Set()};
// START TIGHT (Steve rule 2026-07-31): only the essentials show on first load; the
// user toggles the rest on from the rail. A saved pref in VISCOL always wins.
const DEFAULT_ON=new Set(['name','firm','agent_type','title','state','listings']);
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]; return DEFAULT_ON.has(k); }
// Draggable column ORDER (Steve rule: move columns around). Keys in display order;
// auto-discovered/new keys are appended, stale keys pruned. Persisted per browser.
let COLORDER=(function(){let a=[];try{a=JSON.parse(localStorage.getItem('brokerColOrder')||'[]');}catch(e){}return Array.isArray(a)?a:[];})();
function syncColOrder(){
  const keys=COLS.map(c=>c.k);
  COLORDER=COLORDER.filter(k=>keys.includes(k));                    // prune removed
  keys.forEach(k=>{ if(!COLORDER.includes(k)) COLORDER.push(k); }); // append new
}
function orderedCols(){ syncColOrder(); return COLORDER.map(k=>COLS.find(c=>c.k===k)).filter(Boolean); }
function visCols(){ return orderedCols().filter(c=>colVis(c.k)); }
function saveCols(){ try{localStorage.setItem('brokerCols',JSON.stringify(VISCOL));}catch(e){} }
function saveColOrder(){ try{localStorage.setItem('brokerColOrder',JSON.stringify(COLORDER));}catch(e){} }
// When the visible column SET or ORDER changes, hand the table back to col-resize.js
// so it rebuilds its <colgroup> + drag handles for the current columns.
function reinitResize(){
  const tbl=document.querySelector('#tableView table.g'); if(!tbl) return;
  const cg=tbl.querySelector('colgroup[data-cr]'); if(cg) cg.remove();
  delete tbl.dataset.crDone; tbl.style.tableLayout=''; tbl.style.width='';
  if(window.ColResize && window.ColResize.refresh) window.ColResize.refresh();
}
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(){
  // Emit rows in current COLORDER, grouped by g for the section headers.
  // Each row is draggable via the ⠿ handle (FIX B); data-fk on the label drives
  // the panel drag listeners; data-ck on the checkbox is kept for the change listener.
  const ordered=orderedCols();
  // build group → ordered cols mapping (preserving COLORDER within each group)
  const groupsSeen=[], groupMap={};
  ordered.forEach(c=>{ const g=c.g||'Other'; if(!groupMap[g]){ groupMap[g]=[]; groupsSeen.push(g); } groupMap[g].push(c); });
  $('#fFields').innerHTML=groupsSeen.map(g=>
    `<div class="fgrp">${esc(g)}</div>`+groupMap[g].map(c=>`<label class="ftog" draggable="true" data-fk="${c.k}"><span class="fdrag" title="Drag to reorder column">⠿</span><input type="checkbox" data-ck="${c.k}" ${colVis(c.k)?'checked':''}><span class="flbl">${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){
    // Search ALL data (Steve rule 2026-07-31): every field the API returned on a row —
    // not just the defined/visible columns — incl. nested objects. Space = AND.
    const terms=q.toLowerCase().split(/\s+/).filter(Boolean);
    rows=rows.filter(r=>{
      const hay=Object.keys(r).filter(k=>k!=='__hay').map(k=>{const v=r[k];return v==null?'':(typeof v==='object'?JSON.stringify(v):String(v));}).join('  ').toLowerCase();
      return terms.every(t=>hay.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];
    // empty/null values always sink to the bottom, regardless of sort direction, so
    // choosing a sparse column (e.g. Company / Firm) never front-loads a wall of "—"
    const xE=(x==null||x===''), yE=(y==null||y==='');
    if(xE&&yE) return 0; if(xE) return 1; if(yE) return -1;
    const xn=!isNaN(+x), yn=!isNaN(+y);
    if(xn&&yn){ x=+x; y=+y; } else { x=String(x).toLowerCase(); y=String(y).toLowerCase(); }
    if(x<y) return -sortDir; if(x>y) return sortDir; return 0;
  });
  return rows;
}
let _bgInf=null,_bgRows=[],_bgCols=[];
// Render one window (slice) of brokers into the active view container.
function _bgPaint(win){ const cols=_bgCols;
  if(view==='table'){
    $('#tbody').innerHTML=win.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('');
    const csig=cols.map(c=>c.k).join(',');
    // Column SET/ORDER changed → full colgroup rebuild. Same columns (a sort / search / grow
    // re-render) → just re-wire the resize handles the innerHTML rebuild wiped (TK-10088).
    if(csig!==lastColSig){ lastColSig=csig; reinitResize(); }
    else if(window.ColResize && window.ColResize.reattach){ window.ColResize.reattach(document.querySelector('#tableView table.g')); }
  } else if(view==='list'){
    const metaCols=cols.filter(c=>c.k!=='name');
    $('#listView').innerHTML='<div class="tv-rows">'+win.map(r=>`<div class="tv-lrow brow" data-id="${r.id}" data-name="${esc(r.name)}">`
      +`<div class="tv-lmain"><span class="tv-la">${esc(r.name)}</span><span class="tv-lsub">${esc(r.firm||'—')}${r.title?' · '+esc(r.title):''}</span>${r.agent_type==='residential'?'<span class="atype res">Res</span>':'<span class="atype com">Com</span>'}</div>`
      +`<div class="tv-lmeta">`+metaCols.map(c=>`<span class="tv-lm"><span class="tv-lmk">${esc(c.l)}</span>${cell(r,c)}</span>`).join('')+`</div></div>`).join('')+'</div>';
  } else {
    const skipGrid=new Set(['name','firm','agent_type','phone','email','website','linkedin','title']);
    const bodyCols=cols.filter(c=>!skipGrid.has(c.k));
    $('#gridView').innerHTML=win.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('');
  }
}
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}" draggable="true" title="Drag to reorder · click to sort" class="dragcol${sortKey===c.k?(sortDir>0?' asc':' desc'):''}">${esc(c.l)}</th>`).join('')+'</tr>';
  const activeSel=view==='table'?'#tableView':view==='list'?'#listView':'#gridView';
  ['#tableView','#gridView','#listView'].forEach(s=>$(s).classList.toggle('hide',s!==activeSel));
  // Windowed infinite scroll (was rendering ALL ~2,500 brokers at once): reveal a batch,
  // load more on scroll. reset() repaints the active view from the top on filter/sort/view change.
  _bgRows=rows; _bgCols=cols;
  if(!_bgInf&&window.InfScroll) _bgInf=InfScroll.attach({batch:120,getRows:()=>_bgRows,getMount:()=>$(view==='table'?'#tableView':view==='list'?'#listView':'#gridView'),render:_bgPaint});
  if(_bgInf) _bgInf.reset(); else _bgPaint(rows);
}
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 ''; } };
// Date+TIME — only meaningful on our genuine capture timestamps (listed_at). Public-record
// sale/deed dates are date-only (no time-of-day exists in the source), so those keep fdate.
const fdt=s=>{ if(!s) return ''; try{ const d=new Date(s); const hasTime=/T\d\d:\d\d/.test(String(s))&&!/T0[0-7]:00:00/.test(String(s)); return d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})+(hasTime?', '+d.toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'}):''); }catch(e){ return ''; } };
const atb=t=>t==='residential'?'<span class="atype res">Residential</span>':'<span class="atype com">Commercial</span>';
// Fire-and-forget modal-open telemetry (DTD-C rider — demand signal for the parked paid-data track).
function track(ev,o){ try{ fetch('/api/track',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({ev},o)),keepalive:true}).catch(()=>{}); }catch(e){} }
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; }
  track('broker_modal',{kind:'broker',id:d.broker&&d.broker.id,name:d.broker&&d.broker.name,active:(d.current||[]).length,closed:(d.closed||[]).length,withdrawn:(d.expired||[]).length});
  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):'')+(function(){
      // Prefer the VERIFIED dre_license column (clean, cross-referenced) over regex-matching the
      // raw scraped license (Cody gate) — so the DRE# always hooks the correct full CA DRE record.
      const dl=b.dre_license?String(b.dre_license).trim():'', L=b.license?String(b.license).trim():'';
      const dre=(dl&&/^\d{7,8}$/.test(dl))?dl:(/^\d{8}$/.test(L)?L:'');
      if(dre) return ' · DRE# <a href="https://www2.dre.ca.gov/PublicASP/pplinfo.asp?License_id='+encodeURIComponent(dre)+'" target="_blank" rel="noopener noreferrer" title="Full CA DRE license record ↗">'+esc(dre)+' ↗</a>';
      return L?' · DRE/MLS '+esc(L):'';
    })()+'</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>'
    +(b.id!=null?'<div style="margin:8px 0"><a href="/broker.html?id='+encodeURIComponent(b.id)+'" style="color:var(--gold,#d29922);font-weight:600">View full profile →</a></div>':'')
    +'<div class="sec"><h4>Active / listed ('+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 '+fdt(l.listed_at):'')+'</span></div>').join(''):'<div class="mut">none in our set</div>')+'</div>'
    // DTD-C rider: render Closed/Withdrawn/area-sold ONLY when populated; collapse the gaps into one coverage line.
    +((d.closed&&d.closed.length)?'<div class="sec"><h4>Closed ('+d.closed.length+')</h4>'+d.closed.map(l=>'<div class="litem"><span>'+esc(l.address)+', '+esc(l.city)+'</span><span class="m">'+money(l.sold_price)+(l.sold_date?' · '+fdate(l.sold_date):'')+(l.type?' · '+esc(l.type):'')+'</span></div>').join('')+'</div>':'')
    +((d.expired&&d.expired.length)?'<div class="sec"><h4>Withdrawn / expired / off-market ('+d.expired.length+')</h4>'+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>':'')
    +((d.comps&&d.comps.length)?'<div class="sec"><h4>Sales — area sold ('+d.comps.length+')</h4>'+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>':'')
    +((!(d.closed&&d.closed.length)||!(d.expired&&d.expired.length))?'<div class="mut" style="margin-top:6px;opacity:.85">📊 Coverage: active listings only'+(d.current.length?'':' — none attributed here yet')+'. '+[!(d.closed&&d.closed.length)?'closed':'',!(d.expired&&d.expired.length)?'withdrawn/expired':''].filter(Boolean).join(' & ')+' history isn’t in free public records (needs a paid records source).</div>':'');
}
document.addEventListener('click',e=>{ const fm=e.target.closest('.findmail'); if(fm){ findEmail(fm.dataset.id); return; }
  const nm=e.target.closest('.nm,.tv-la'); const nrow=nm&&nm.closest('.brow'); if(nrow){ location.href='/agent.html?name='+encodeURIComponent(nrow.dataset.name); return; } // agent NAME -> our own profile page (Steve 2026-08-19: never crexi)
  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'); });
/* make agent names look clickable */
(function(){var s=document.createElement('style');s.textContent='.nm,.tv-la{cursor:pointer}.nm:hover,.tv-la:hover{text-decoration:underline;color:var(--blue)}';document.head.appendChild(s);})();
$('#thead').addEventListener('click',e=>{
  // A click on the resize handle (or the tail of a resize drag) must NOT sort —
  // otherwise grabbing/double-clicking the handle re-renders the thead mid-gesture,
  // detaching the cell col-resize is measuring (collapsed autofit) and firing a
  // spurious sort on every resize. col-resize owns the handle; the app owns sort.
  if(e.target.closest('.cr-handle'))return;
  if(document.body.classList.contains('cr-dragging'))return;
  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();});
// ── drag-to-reorder columns (Steve rule 2026-07-31: move columns around) ──
let dragKey=null;
$('#thead').addEventListener('dragstart',e=>{ const th=e.target.closest('th'); if(!th) return;
  // A grab on the resize handle must NOT start a column-move. On dragstart the target is the
  // draggable <th>, never the handle span, so closest('.cr-handle') can't catch it — instead
  // detect the resize gesture by the body.cr-dragging class col-resize adds on pointerdown
  // (which fires before dragstart). Without this, grabbing the edge starts a native reorder
  // drag that suppresses pointermove and the column silently won't resize (TK-10089).
  if(document.body.classList.contains('cr-dragging') || e.target.closest('.cr-handle')){ e.preventDefault(); return; }
  dragKey=th.dataset.k; e.dataTransfer.effectAllowed='move'; try{e.dataTransfer.setData('text/plain',dragKey);}catch(_){}
  th.classList.add('dragging'); });
$('#thead').addEventListener('dragover',e=>{ if(!dragKey) return; const th=e.target.closest('th'); if(!th||th.dataset.k===dragKey) return;
  e.preventDefault(); e.dataTransfer.dropEffect='move';
  const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
  th.classList.toggle('dropR',after); th.classList.toggle('dropL',!after); });
$('#thead').addEventListener('dragleave',e=>{ const th=e.target.closest('th'); if(th) th.classList.remove('dropL','dropR'); });
$('#thead').addEventListener('drop',e=>{ if(!dragKey) return; const th=e.target.closest('th'); if(!th) return; e.preventDefault();
  const tgt=th.dataset.k; if(tgt!==dragKey){
    const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
    syncColOrder(); const from=COLORDER.indexOf(dragKey); if(from>=0){
      COLORDER.splice(from,1); let to=COLORDER.indexOf(tgt); if(after) to+=1; COLORDER.splice(to,0,dragKey);
      saveColOrder(); lastColSig=''; render();
    }
  }
  dragKey=null; });
$('#thead').addEventListener('dragend',()=>{ document.querySelectorAll('#thead th.dragging,#thead th.dropL,#thead th.dropR').forEach(x=>x.classList.remove('dragging','dropL','dropR')); dragKey=null; });
$('#q').addEventListener('input',()=>{q=$('#q').value.trim();render();});
// deep-link ?q= (from the global nav search) — pre-fill + filter on load
(function(){ const _q=new URLSearchParams(location.search).get('q'); if(_q){ const b=$('#q'); if(b){ b.value=_q; q=_q.trim(); } } })();
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();});
// v2 3-view migration (TK-10526): the old 'table' button was labeled "List". Model is
// now grid/list/table — no key remap needed (stored value 'table' still valid).
// ── 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'||pv==='list'){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();});
// ── panel drag-to-reorder columns (FIX B: Steve rule — resort order via ⠿ handle) ──
let pDragKey=null;
$('#fFields').addEventListener('dragstart',e=>{ const row=e.target.closest('.ftog'); if(!row) return;
  if(!e.target.closest('.fdrag')){ e.preventDefault(); return; }   // only ⠿ handle starts a move
  pDragKey=row.dataset.fk; e.dataTransfer.effectAllowed='move'; try{e.dataTransfer.setData('text/plain',pDragKey);}catch(_){}
  row.classList.add('fdragging'); });
$('#fFields').addEventListener('dragover',e=>{ if(!pDragKey) return; const row=e.target.closest('.ftog'); if(!row||row.dataset.fk===pDragKey) return;
  e.preventDefault(); e.dataTransfer.dropEffect='move';
  const r=row.getBoundingClientRect(), after=(e.clientY-r.top)>r.height/2;
  $('#fFields').querySelectorAll('.ftog').forEach(x=>x.classList.remove('fdropT','fdropB'));
  row.classList.toggle('fdropB',after); row.classList.toggle('fdropT',!after); });
$('#fFields').addEventListener('dragleave',e=>{ const row=e.target.closest('.ftog'); if(row) row.classList.remove('fdropT','fdropB'); });
$('#fFields').addEventListener('drop',e=>{ if(!pDragKey) return; const row=e.target.closest('.ftog'); if(!row){ pDragKey=null; return; } e.preventDefault();
  const tgt=row.dataset.fk;
  if(tgt!==pDragKey){
    const r=row.getBoundingClientRect(), after=(e.clientY-r.top)>r.height/2;
    syncColOrder(); const from=COLORDER.indexOf(pDragKey);
    if(from>=0){ COLORDER.splice(from,1); let to=COLORDER.indexOf(tgt); if(after) to+=1; COLORDER.splice(to,0,pDragKey);
      saveColOrder(); lastColSig=''; buildFieldToggles(); render(); }
  }
  pDragKey=null; });
$('#fFields').addEventListener('dragend',()=>{ $('#fFields').querySelectorAll('.fdragging,.fdropT,.fdropB').forEach(x=>x.classList.remove('fdragging','fdropT','fdropB')); pDragKey=null; });
$('#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();});
// Reset the whole table layout back to the tight default: visibility, order, widths.
$('#fReset').addEventListener('click',()=>{ VISCOL={}; saveCols(); COLORDER=[]; saveColOrder();
  try{Object.keys(localStorage).forEach(k=>{ if(k.indexOf('cr:'+location.pathname+':')===0) localStorage.removeItem(k); });}catch(e){}
  lastColSig=''; buildFieldToggles(); render(); });
// collapsible rail tabs — all collapsed on load except #fieldsSec (FIX A: fields panel stays expanded)
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=>{ if(s.id!=='fieldsSec') s.classList.add('collapsed'); }); }
fetch('/api/brokers/all').then(r=>r.json()).then(d=>{DATA=d.brokers||[];autoCols();if(window.__buildSortSel)window.__buildSortSel();buildRail();
  // deep-link: ?type=residential|commercial pre-selects the Agent-type facet (from the crcp Residential-agents tile)
  const _tp=new URLSearchParams(location.search).get('type');
  if(_tp){ F.types.add(_tp); document.querySelectorAll('#fType .chip').forEach(c=>{ if(c.dataset.ty===_tp) c.classList.add('active'); }); }
  render();})
  .catch(()=>{$('#count').textContent='failed to load /api/brokers/all';});
</script>
  <script src="/col-resize.js" defer></script>
  <script src="/global-search.js" defer></script>
  <script src="/sort-bar.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>