← back to Nationalrealestate

public/broker-desk.js

116 lines

/* broker-desk.js — shared residential/commercial broker & firm desk for usreal (TK-10590).
 * A page sets `window.DESK_MODE = 'residential' | 'commercial' | ''` (asset_class) BEFORE loading
 * this script, plus an optional `window.DESK_CITY` default. One module, two surfaces — kills the
 * drift between residential-brokers.html and commercial-brokers.html (Cody gate: they were 97% dup).
 * Adds the standing grid requirements missing from the old clones: SORT + DENSITY + real PAGINATION,
 * and an honest count (never show the full total next to a truncated page). */
(function () {
  const MODE = window.DESK_MODE || '';                 // '' = all, else asset_class
  const AGENTS = MODE === 'residential';
  const NOUN = AGENTS ? 'agents' : 'brokers';
  const PAGE = 200;
  const $ = (s) => document.querySelector(s);
  const LS = { get(k){try{return localStorage.getItem(k)}catch{return null}}, set(k,v){try{localStorage.setItem(k,v)}catch{}} };
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));

  const S = {
    tab: 'firms',
    city: (window.DESK_CITY !== undefined ? window.DESK_CITY : 'LOS ANGELES'),
    q: '',
    sort: LS.get('desk.sort') || '',
    dens: +(LS.get('desk.dens') || 280),
    offset: 0, total: 0, shown: 0
  };

  const CITIES = [['LOS ANGELES','Los Angeles'],['BEVERLY HILLS','Beverly Hills'],['SANTA MONICA','Santa Monica'],
    ['LONG BEACH','Long Beach'],['BURBANK','Burbank'],['GLENDALE','Glendale'],['PASADENA','Pasadena'],['','All CA']];

  const root = document.getElementById('desk-root');
  root.innerHTML = `
    <div id="controls">
      <div id="tabs"><button data-t="firms" class="active">Firms</button><button data-t="brokers">${AGENTS?'Agents':'Brokers'}</button></div>
      <select id="city">${CITIES.map(([v,l])=>`<option value="${v}"${v===S.city?' selected':''}>${l}</option>`).join('')}</select>
      <input id="q" placeholder="Search name / firm / license…" style="width:200px">
      <select id="sort" title="Sort (server-side, across all rows)">
        <option value="">Sort: default</option>
        <option value="name">Name A→Z</option>
        <option value="firm">Firm A→Z</option>
        <option value="agents">Agents ↓</option>
        <option value="city">City A→Z</option>
      </select>
      <label id="densWrap" title="Card density">▦ <input type="range" id="dens" min="200" max="380" step="20" value="${S.dens}"></label>
      <span id="count"></span>
    </div>
    <div id="grid"></div>
    <div id="pager"><button id="pgPrev">‹ Prev</button><span id="pgInfo"></span><button id="pgNext">Next ›</button></div>
    <div id="loading">Loading…</div>`;

  // density → grid column min-width (persisted)
  function applyDens(){ $('#grid').style.gridTemplateColumns = `repeat(auto-fill,minmax(${S.dens}px,1fr))`; }
  applyDens();

  async function j(u){ const r = await fetch(u); return r.json(); }
  function params(extra){
    const p = new URLSearchParams({ state:'CA', limit:PAGE, offset:S.offset });
    if (MODE) p.set('asset_class', MODE);
    if (S.city) p.set('city', S.city);
    if (S.q) p.set('q', S.q);
    if (S.sort) p.set('sort', S.sort);   // server-side sort (TK-10590) — covers all rows, not just the page
    if (extra) for (const k in extra) p.set(k, extra[k]);
    return p;
  }
  function setCount(shown,total){
    S.shown = shown; S.total = total;
    $('#count').textContent = (shown < total)
      ? `showing ${shown.toLocaleString()} of ${total.toLocaleString()} ${S.tab==='firms'?'firms':NOUN} — page ${Math.floor(S.offset/PAGE)+1} of ${Math.max(Math.ceil(total/PAGE),1).toLocaleString()}`
      : `${total.toLocaleString()} ${S.tab==='firms'?'firms':NOUN}`;
    const pgs = Math.max(Math.ceil(total/PAGE),1), pg = Math.floor(S.offset/PAGE)+1;
    $('#pgInfo').textContent = `${pg} / ${pgs.toLocaleString()}`;
    $('#pgPrev').disabled = S.offset <= 0;
    $('#pgNext').disabled = pg >= pgs;
  }
  async function loadFirms(){
    const d = await j('/api/firms?'+params());
    const rows = d.rows||[];
    setCount(rows.length, d.total||0);
    $('#grid').innerHTML = rows.map(f=>`<div class="card firm" data-name="${esc(f.name)}">
      <div class="n">${esc(f.name)||'—'}</div>
      <div class="m"><span class="agents">${f.agent_count||0} agents</span><span>${esc(f.hq_city)}</span>${f.license_no?`<span>DRE ${esc(f.license_no)}</span>`:''}</div>
      <div class="roster"></div></div>`).join('') || '<div style="color:var(--muted);padding:20px">no firms match</div>';
    document.querySelectorAll('.card.firm').forEach(c=>c.onclick=()=>toggleRoster(c));
  }
  async function toggleRoster(c){
    const r = c.querySelector('.roster');
    if (r.style.display==='block'){ r.style.display='none'; return; }
    r.style.display='block';
    if (r.dataset.loaded) return;
    r.innerHTML='loading roster…';
    const d = await j('/api/brokers?'+params({ limit:50, offset:0, firm:c.dataset.name }));
    r.dataset.loaded='1';
    const rows = d.rows||[], total = d.total||rows.length;
    const hdr = total>rows.length ? `<div style="color:var(--muted);margin-bottom:4px">showing first ${rows.length} of ${total.toLocaleString()} ${NOUN}</div>` : '';
    r.innerHTML = hdr + (rows.map(b=>`<div>${esc(b.name)} <span style="color:var(--muted)">· ${esc(b.license_type)} · DRE ${esc(b.license_no)}</span></div>`).join('') || '<div style="color:var(--muted)">no roster rows</div>');
  }
  async function loadBrokers(){
    const d = await j('/api/brokers?'+params());
    const rows = d.rows||[];
    setCount(rows.length, d.total||0);
    $('#grid').innerHTML = rows.map(b=>`<div class="card">
      <div class="n">${esc(b.name)||'—'}</div>
      <div class="m"><span class="lt">${esc(b.license_type)}</span><span>${esc(b.firm_name)||'—'}</span><span>${esc(b.city)}</span>${b.license_no?`<span>DRE ${esc(b.license_no)}</span>`:''}</div>
      <div class="ct">${b.phone
          ? `<a href="tel:${esc(b.phone)}">📞 ${esc(b.phone)}</a>${b.phone_tier==='firm'?`<span class="tier" title="firm main line (broker direct line unknown)">firm</span>`:''}`
          : `<span class="pend" title="phone enrichment pending">☎ phone pending</span>`}${b.email?` <a href="mailto:${esc(b.email)}">✉ ${esc(b.email)}</a>`:''}${b.address?` <span class="addr">📍 ${esc(b.address)}</span>`:''}</div></div>`).join('') || '<div style="color:var(--muted);padding:20px">no '+NOUN+' match</div>';
  }
  async function load(){ $('#loading').style.display='block'; S.tab==='firms'?await loadFirms():await loadBrokers(); $('#loading').style.display='none'; }

  $('#tabs').querySelectorAll('button').forEach(b=>b.onclick=()=>{ $('#tabs').querySelectorAll('button').forEach(x=>x.classList.remove('active')); b.classList.add('active'); S.tab=b.dataset.t; S.offset=0; load(); });
  $('#city').onchange=()=>{ S.city=$('#city').value; S.offset=0; load(); };
  $('#q').oninput=()=>{ S.q=$('#q').value; S.offset=0; clearTimeout(window._t); window._t=setTimeout(load,250); };
  $('#sort').value=S.sort; $('#sort').onchange=()=>{ S.sort=$('#sort').value; LS.set('desk.sort',S.sort); S.offset=0; load(); };
  $('#dens').oninput=()=>{ S.dens=+$('#dens').value; LS.set('desk.dens',S.dens); applyDens(); };
  $('#pgPrev').onclick=()=>{ if(S.offset>0){ S.offset=Math.max(0,S.offset-PAGE); load(); } };
  $('#pgNext').onclick=()=>{ if((Math.floor(S.offset/PAGE)+1) < Math.ceil(S.total/PAGE)){ S.offset+=PAGE; load(); } };
  load();
})();