[object Object]

← back to Commercialrealestate

CRCP: sortable columns on broker + firm tables (click any header to rank asc/desc, ▲▼ indicator)

52878765d511b698e057798b3bb715048abc8be5 · 2026-06-28 10:18:35 -0700 · Steve

Files touched

Diff

commit 52878765d511b698e057798b3bb715048abc8be5
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jun 28 10:18:35 2026 -0700

    CRCP: sortable columns on broker + firm tables (click any header to rank asc/desc, ▲▼ indicator)
---
 public/crcp.html | 50 ++++++++++++++++++++++++++++++++++----------------
 1 file changed, 34 insertions(+), 16 deletions(-)

diff --git a/public/crcp.html b/public/crcp.html
index 55108b8..bc5f821 100644
--- a/public/crcp.html
+++ b/public/crcp.html
@@ -31,6 +31,8 @@
   .panel h3 .c{color:var(--muted);font-weight:400;}
   table{width:100%;border-collapse:collapse;font-size:13px;} td,th{text-align:left;padding:6px 6px;border-top:1px solid var(--line);}
   th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.4px;border-top:0;}
+  th.srt{cursor:pointer;user-select:none;} th.srt:hover{color:var(--fg);}
+  th.srt.asc::after{content:' ▲';color:var(--gold);} th.srt.desc::after{content:' ▼';color:var(--gold);}
   td.n{text-align:right;font-variant-numeric:tabular-nums;color:var(--active);font-weight:600;}
   .firm{color:var(--muted);font-size:11px;}
   .atype{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:1px 6px;border-radius:9px;margin-left:6px;vertical-align:middle;}
@@ -76,7 +78,7 @@
         <div class="meta" id="enrMeta"></div>
       </div>
       <div class="panel"><h3>Top brokers <span class="c" id="brC"></span></h3>
-        <table id="brTbl"><thead><tr><th>Broker</th><th class="n">Lists</th><th>Phone</th><th>Email</th><th>Web</th></tr></thead><tbody></tbody></table>
+        <table id="brTbl"><thead><tr><th class="srt" data-col="name">Broker</th><th class="srt n" data-col="listings">Lists</th><th class="srt" data-col="phone">Phone</th><th class="srt" data-col="email">Email</th><th class="srt" data-col="website">Web</th></tr></thead><tbody></tbody></table>
       </div>
     </div>
     <div>
@@ -85,7 +87,7 @@
         <div class="meta">FHA/VA-approval-based proxy — NOT lender-verified Fannie/Freddie warrantability.</div>
       </div>
       <div class="panel"><h3>Top firms by market share <span class="c" id="fmC"></span></h3>
-        <table id="fmTbl"><thead><tr><th>Firm</th><th class="n">Listings</th><th class="n">Brokers</th></tr></thead><tbody></tbody></table>
+        <table id="fmTbl"><thead><tr><th class="srt" data-col="firm">Firm</th><th class="srt n" data-col="listings">Listings</th><th class="srt n" data-col="brokers">Brokers</th></tr></thead><tbody></tbody></table>
       </div>
     </div>
   </div>
@@ -144,8 +146,36 @@ function tile(k,key,v,sub,cls){ const d=(prev[key]!=null&&v>prev[key])?('+'+(v-p
 function bar(label,have,total,color){ const pct=total?Math.round(100*have/total):0;
   return `<div style="margin-bottom:10px"><div class="prog"><span>${label}</span><span>${fmt(have)} / ${fmt(total)} · ${pct}%</span></div><div class="bar"><i style="width:${pct}%;background:${color||'linear-gradient(90deg,var(--gold),var(--active))'}"></i></div></div>`; }
 
+// ---- sortable columns ----
+let brSort={col:'listings',dir:-1}, fmSort={col:'listings',dir:-1}, lastStats=null;
+function srt(arr,col,dir){ return arr.slice().sort((a,b)=>{ let x=a[col],y=b[col];
+  if(col==='phone'||col==='email'||col==='website'){ x=x?1:0; y=y?1:0; }
+  if(typeof x==='string'||typeof y==='string') return dir*String(x||'').localeCompare(String(y||''));
+  return dir*((+x||0)-(+y||0)); }); }
+function markHdr(tbl,sort){ document.querySelectorAll('#'+tbl+' th.srt').forEach(th=>{ th.classList.remove('asc','desc'); if(th.dataset.col===sort.col) th.classList.add(sort.dir>0?'asc':'desc'); }); }
+const webLink=u=>{ if(!u) return '<span class="miss">—</span>'; const h=/^https?:/.test(u)?u:'https://'+u; return `<a href="${h}" target="_blank" rel="noopener noreferrer" style="color:var(--gold)">site↗</a>`; };
+function renderBrokers(s){ if(!s) return; const rows=srt(s.topBrokers||[], brSort.col, brSort.dir);
+  $('#brC').textContent=rows.length+' shown · click a broker · click a header to sort';
+  $('#brTbl tbody').innerHTML=rows.map(b=>`<tr class="brow" data-id="${b.id||''}" data-name="${(b.name||'').replace(/"/g,'&quot;')}" style="cursor:pointer">
+    <td>${b.name}${atype(b.agent_type)}<div class="firm">${b.firm||'—'}</div></td>
+    <td class="n">${b.listings}</td>
+    <td>${b.phone?`<a href="tel:${b.phone}" class="has" style="color:var(--active)">${b.phone}</a>`:'<span class="miss">—</span>'}</td>
+    <td>${b.email?`<a href="mailto:${b.email}" class="has" style="color:var(--draft)">${b.email}</a>`:'<span class="miss">—</span>'}</td>
+    <td>${webLink(b.website)}</td></tr>`).join('');
+  document.querySelectorAll('.brow').forEach(r=>r.onclick=e=>{ if(e.target.tagName==='A') return; openContact(r.dataset.name, r.dataset.id); });
+  markHdr('brTbl',brSort); }
+function renderFirms(s){ if(!s) return; const rows=srt(s.topFirms||[], fmSort.col, fmSort.dir);
+  $('#fmC').textContent='click a firm · click a header to sort';
+  $('#fmTbl tbody').innerHTML=rows.map(f=>`<tr class="frow" data-firm="${(f.firm||'').replace(/"/g,'&quot;')}" style="cursor:pointer"><td>${f.firm}</td><td class="n">${f.listings}</td><td class="n" style="color:var(--draft)">${f.brokers}</td></tr>`).join('');
+  document.querySelectorAll('.frow').forEach(r=>r.onclick=()=>openFirm(r.dataset.firm));
+  markHdr('fmTbl',fmSort); }
+// wire header clicks once
+document.querySelectorAll('#brTbl th.srt').forEach(th=>th.onclick=()=>{ const c=th.dataset.col; if(brSort.col===c) brSort.dir*=-1; else {brSort.col=c; brSort.dir=-1;} renderBrokers(lastStats); });
+document.querySelectorAll('#fmTbl th.srt').forEach(th=>th.onclick=()=>{ const c=th.dataset.col; if(fmSort.col===c) fmSort.dir*=-1; else {fmSort.col=c; fmSort.dir=-1;} renderFirms(lastStats); });
+
 async function poll(){
   let s; try{ s=await (await fetch('/api/crcp/stats')).json(); }catch(e){ $('#livetxt').textContent='reconnecting…'; return; }
+  lastStats=s;
   $('#livetxt').textContent='live · '+new Date(s.ts).toLocaleTimeString();
   // tiles
   $('#tiles').innerHTML = [
@@ -165,23 +195,11 @@ async function poll(){
   const enr=(s.brokers_phone||0)+(s.brokers_email||0);
   const penr=(prev.brokers_phone||0)+(prev.brokers_email||0);
   $('#enrMeta').innerHTML = enr>penr ? `<span class="has">▲ +${enr-penr} contacts since last refresh — Job B populating…</span>` : (enr===0?'Job B (broker enrichment) queued — contacts will populate here live.':'enrichment idle / complete');
-  // top brokers
-  $('#brC').textContent=(s.topBrokers||[]).length+' shown · click a broker';
-  const web=u=>{ if(!u) return '<span class="miss">—</span>'; const h=/^https?:/.test(u)?u:'https://'+u; return `<a href="${h}" target="_blank" rel="noopener noreferrer" style="color:var(--gold)">site↗</a>`; };
-  $('#brTbl tbody').innerHTML=(s.topBrokers||[]).map(b=>`<tr class="brow" data-id="${b.id||''}" data-name="${(b.name||'').replace(/"/g,'&quot;')}" style="cursor:pointer">
-    <td>${b.name}${atype(b.agent_type)}<div class="firm">${b.firm||'—'}</div></td>
-    <td class="n">${b.listings}</td>
-    <td>${b.phone?`<a href="tel:${b.phone}" class="has" style="color:var(--active)">${b.phone}</a>`:'<span class="miss">—</span>'}</td>
-    <td>${b.email?`<a href="mailto:${b.email}" class="has" style="color:var(--draft)">${b.email}</a>`:'<span class="miss">—</span>'}</td>
-    <td>${web(b.website)}</td></tr>`).join('');
-  document.querySelectorAll('.brow').forEach(r=>r.onclick=e=>{ if(e.target.tagName==='A') return; openContact(r.dataset.name, r.dataset.id); });
+  renderBrokers(s);
   // warrantability
   $('#wa').innerHTML = bar('FHA-approved', s.fhaApproved||0, (s.fhaApproved||0)+(s.fhaExpired||0), 'var(--active)')
     + bar('FHA-expired (flag/review)', s.fhaExpired||0, (s.fhaApproved||0)+(s.fhaExpired||0), 'var(--staged)');
-  // firms (clickable → roster)
-  $('#fmC').textContent='click a firm';
-  $('#fmTbl tbody').innerHTML=(s.topFirms||[]).map(f=>`<tr class="frow" data-firm="${(f.firm||'').replace(/"/g,'&quot;')}" style="cursor:pointer"><td>${f.firm}</td><td class="n">${f.listings}</td><td class="n" style="color:var(--draft)">${f.brokers}</td></tr>`).join('');
-  document.querySelectorAll('.frow').forEach(r=>r.onclick=()=>openFirm(r.dataset.firm));
+  renderFirms(s);
   prev=s;
   // flash changed tiles
   document.querySelectorAll('.delta.show').forEach(d=>d.closest('.tile').classList.add('flash'));

← 56c1898 auto-save: 2026-06-28T10:14:06 (2 files) — scripts/serve.js  ·  back to Commercialrealestate  ·  closed-sales: full LA County coverage — 228 resolved regions cc6c1a9 →