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