[object Object]

← back to Commercialrealestate

Add CSV export to MLS + broker grids (exports current filtered/sorted rows)

c469c560a70d01498a321888e7d68d84e2f62e03 · 2026-06-30 17:01:59 -0700 · Steve Abrams

⬇ CSV button on both spreadsheet views — client-side export of the currently filtered+sorted rows with
all columns (proper quote-escaping). Verified headless: cre-mls-1407.csv + cre-brokers-2537.csv download, 0 errors.

Files touched

Diff

commit c469c560a70d01498a321888e7d68d84e2f62e03
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 30 17:01:59 2026 -0700

    Add CSV export to MLS + broker grids (exports current filtered/sorted rows)
    
    ⬇ CSV button on both spreadsheet views — client-side export of the currently filtered+sorted rows with
    all columns (proper quote-escaping). Verified headless: cre-mls-1407.csv + cre-brokers-2537.csv download, 0 errors.
---
 public/broker-grid.html | 6 ++++++
 public/mls.html         | 6 ++++++
 2 files changed, 12 insertions(+)

diff --git a/public/broker-grid.html b/public/broker-grid.html
index 4259943..384c74b 100644
--- a/public/broker-grid.html
+++ b/public/broker-grid.html
@@ -16,6 +16,7 @@
   .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:14px 18px 60px;}
   .tblwrap{overflow:auto;max-height:calc(100vh - 130px);border:1px solid var(--line);border-radius:10px;}
@@ -55,6 +56,7 @@
   <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>
+  <button class="csvbtn" id="csv" title="Export current rows to CSV">⬇ CSV</button>
   <span class="count" id="count">…</span>
 </header>
 <div class="wrap">
@@ -164,6 +166,10 @@ document.addEventListener('click',e=>{ const row=e.target.closest('.brow'); if(r
 $('#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;}render();});
 $('#q').addEventListener('input',()=>{q=$('#q').value.trim();render();});
+function exportCSV(){ const rows=filtered(); 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;render();});
 fetch('/api/brokers/all').then(r=>r.json()).then(d=>{DATA=d.brokers||[];render();})
diff --git a/public/mls.html b/public/mls.html
index dd52b28..8401e1a 100644
--- a/public/mls.html
+++ b/public/mls.html
@@ -16,6 +16,7 @@
   .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(--blue);color:#0b0e14;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:14px 18px 60px;}
   /* TABLE (spreadsheet) */
@@ -55,6 +56,7 @@
   <span class="spacer"></span>
   <input class="search" id="q" type="text" placeholder="search anything — address, city, firm, type, year…">
   <div class="toggle" id="view"><button data-v="table" class="on">▦ Table</button><button data-v="grid">▤ Grid</button></div>
+  <button class="csvbtn" id="csv" title="Export current rows to CSV">⬇ CSV</button>
   <span class="count" id="count">…</span>
 </header>
 <div class="wrap">
@@ -157,6 +159,10 @@ async function propHistory(address,city){
 document.addEventListener('click',e=>{ const row=e.target.closest('.prow'); if(row && e.target.tagName!=='A'){ propHistory(row.dataset.addr,row.dataset.city); }
   if(e.target.id==='ov'||e.target.classList.contains('ph-x')) $('#ov').classList.remove('on'); });
 $('#q').addEventListener('input',()=>{ q=$('#q').value.trim(); render(); });
+function exportCSV(){ const rows=filtered(); const cv=(r,c)=>{let v=cval(r,c); 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-mls-'+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; render(); });
 fetch('/data/ranked.json').then(r=>r.json()).then(d=>{ DATA=(d.ranked||d)||[]; render(); })

← 5a26b03 Make the grids actionable: MLS row → 100-yr property history  ·  back to Commercialrealestate  ·  Add .deploy.conf for Kamatera prod deploy (CRCP + FHA loans 8d4f2eb →