[object Object]

← back to Rentv

desk.html: add Grid/List/Table views + Sort select + click-to-sort table headers to the master-detail list (per-scope columns for commercial/brokers/firms/owners/sublease; client-side sort over loaded page; row-click still drives detail, master-detail preserved)

1a02bbf915aaf4d4338e6507146076ffc6230086 · 2026-08-13 08:30:13 -0700 · steve

Files touched

Diff

commit 1a02bbf915aaf4d4338e6507146076ffc6230086
Author: steve <steve@designerwallcoverings.com>
Date:   Thu Aug 13 08:30:13 2026 -0700

    desk.html: add Grid/List/Table views + Sort select + click-to-sort table headers to the master-detail list (per-scope columns for commercial/brokers/firms/owners/sublease; client-side sort over loaded page; row-click still drives detail, master-detail preserved)
---
 public/desk.html | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 76 insertions(+), 7 deletions(-)

diff --git a/public/desk.html b/public/desk.html
index e341c2d1..4322dae1 100644
--- a/public/desk.html
+++ b/public/desk.html
@@ -38,6 +38,23 @@
   .md .rows{overflow-y:auto;flex:1}
   .md .row{padding:12px 14px;border-bottom:1px solid #171d24;cursor:pointer}.md .row:hover{background:#141b22}.md .row.sel{background:#11161c;border-left:3px solid var(--red)}
   .md .row .nm{font-weight:700;font-size:14px}.md .row .f{font-size:12px;color:#9aa4ad;margin-top:2px}
+  /* sort + view controls in the list header (TK-10526) */
+  .md .ctls{display:flex;align-items:center;gap:8px;padding:0 12px 10px;flex-wrap:wrap}
+  .md .ctls select{background:#11161c;border:1px solid var(--line);border-radius:8px;color:#e7ecf1;font:700 11.5px 'Inter';padding:6px 8px;cursor:pointer}
+  .md .ctls select:focus{border-color:var(--red);outline:0}
+  .md .seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
+  .md .seg button{border:0;background:#11161c;color:#9aa4ad;font:800 10.5px 'Inter';letter-spacing:.03em;padding:6px 9px;cursor:pointer}
+  .md .seg button.on{background:var(--red);color:#fff}
+  /* compact LIST rows (denser) */
+  .md .rows.v-list .row{padding:7px 14px}
+  .md .rows.v-list .row .f{display:none}
+  /* dense TABLE view — all columns per scope, click-to-sort */
+  .md .rows.v-table{padding:0}
+  .md table.dt{width:100%;border-collapse:collapse;font-size:12px}
+  .md table.dt th{position:sticky;top:0;background:#0f141a;text-align:left;font:800 10px 'Inter';letter-spacing:.04em;text-transform:uppercase;color:#8b939b;padding:8px 10px;border-bottom:1px solid var(--line);cursor:pointer;white-space:nowrap}
+  .md table.dt th:hover{color:#fff}
+  .md table.dt td{padding:7px 10px;border-bottom:1px solid #171d24;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px}
+  .md table.dt tr{cursor:pointer}.md table.dt tr:hover td{background:#141b22}.md table.dt tr.sel td{background:#11161c}
   .md .detail{overflow-y:auto;padding:30px 36px}
   .md .detail .k{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--red);font-weight:800}
   .md .detail h2{font-size:30px;font-weight:800;margin:5px 0}.md .detail .meta{color:#9aa4ad;font-size:15px}
@@ -166,7 +183,32 @@ function actions(k,x){
 }
 const subRate=x=>x.asking_rate?('$'+Number(x.asking_rate).toFixed(2)+'/'+(x.rate_period||'yr')+' SF'):'Rate on request';
 const nm=x=>x.name||x.address||x.street||x.ain, sub=(k,x)=>k==='brokers'?(x.firm_name||'—'):k==='firms'?((x.agent_count||0).toLocaleString()+' agents'):k==='sublease'?[x.sqft?Number(x.sqft).toLocaleString()+' SF':null,subRate(x),x.submarket||x.city].filter(Boolean).join(' · '):k==='commercial'?[x.firm,x.listings?x.listings+' listings':null,x.agent_type].filter(Boolean).join(' · '):(x.type_label||'');
-let D={scope:'commercial',view:'directory',selDir:0,selMap:0};
+let D={scope:'commercial',view:'directory',selDir:0,selMap:0,
+  listView:(['grid','list','table'].includes(localStorage.getItem('rentv.desk.listView'))?localStorage.getItem('rentv.desk.listView'):'grid'),
+  sort:null // {k,dir} client-side over the loaded page; null = load order
+};
+// Every displayed field per scope → sortable + table columns (TK-10526). Sort is
+// client-side over the already-loaded desk page (≤500 rows), so master-detail stays intact.
+const DESK_COLS={
+  commercial:[{k:'name',l:'Broker'},{k:'firm',l:'Firm'},{k:'agent_type',l:'Type'},{k:'listings',l:'Listings',num:true},{k:'phone',l:'Phone'},{k:'email',l:'Email'},{k:'dre_license',l:'DRE #'}],
+  brokers:[{k:'name',l:'Broker'},{k:'firm_name',l:'Firm'},{k:'city',l:'City'},{k:'state_code',l:'State'},{k:'license_status',l:'Status'},{k:'license_type',l:'Type'},{k:'license_no',l:'DRE #'}],
+  firms:[{k:'name',l:'Firm'},{k:'agent_count',l:'Agents',num:true},{k:'city',l:'City'},{k:'hq_state',l:'State'},{k:'license_no',l:'DRE #'},{k:'source',l:'Source'}],
+  owners:[{k:'name',l:'Asset'},{k:'address',l:'Address'},{k:'city',l:'City'},{k:'assessed_total',l:'Assessed',num:true},{k:'sqft',l:'SqFt',num:true},{k:'year_built',l:'Built'},{k:'ain',l:'AIN'}],
+  sublease:[{k:'address',l:'Address'},{k:'submarket',l:'Submarket'},{k:'city',l:'City'},{k:'sqft',l:'SqFt',num:true},{k:'asking_rate',l:'Rate',num:true},{k:'term_months',l:'Term',num:true},{k:'sublandlord',l:'Sublandlord'},{k:'firm_name',l:'Broker firm'}],
+};
+function deskCols(){return DESK_COLS[D.scope]||DESK_COLS.brokers;}
+function sortDeskRows(rows){
+  if(!D.sort)return rows;
+  const col=deskCols().find(c=>c.k===D.sort.k); if(!col)return rows;
+  const dir=D.sort.dir==='asc'?1:-1;
+  return rows.slice().sort((a,b)=>{
+    let va=a[D.sort.k],vb=b[D.sort.k];
+    const ea=va==null||va===''||va===undefined,eb=vb==null||vb===''||vb===undefined;
+    if(ea&&eb)return 0; if(ea)return 1; if(eb)return -1;
+    if(col.num)return ((+va)-(+vb))*dir;
+    return String(va).localeCompare(String(vb))*dir;
+  });
+}
 // chrome
 $('burger').onclick=()=>{$('scrim').classList.add('open');$('drawer').classList.add('open');};
 const cd=()=>{$('scrim').classList.remove('open');$('drawer').classList.remove('open');};$('dclose').onclick=cd;$('scrim').onclick=cd;
@@ -254,13 +296,23 @@ function applyScopeViews(){
 }
 $('rail').addEventListener('click',e=>{const btn=e.target.closest('button');if(!btn||btn.disabled)return;
   if(btn.dataset.v){document.querySelectorAll('#rail [data-v]').forEach(x=>x.classList.remove('on'));btn.classList.add('on');D.view=btn.dataset.v;document.querySelectorAll('.view').forEach(v=>v.classList.remove('on'));$('v-'+D.view).classList.add('on');}
-  if(btn.dataset.s){document.querySelectorAll('#rail .scope').forEach(x=>x.classList.remove('on'));btn.classList.add('on');D.scope=btn.dataset.s;D.selDir=0;setFresh();renderAll();}
+  if(btn.dataset.s){document.querySelectorAll('#rail .scope').forEach(x=>x.classList.remove('on'));btn.classList.add('on');D.scope=btn.dataset.s;D.selDir=0;D.sort=null;setFresh();renderAll();}
 });
 function renderAll(){directory();analytics();applyScopeViews();}
 // ── Directory (master-detail) ──
 function directory(){
   const rows=scopeData();
-  $('v-directory').innerHTML=`<div class="md"><div class="list"><div class="search"><input id="dq" placeholder="Search ${D.scope}…"><div class="fresh" id="dcount" style="padding:7px 2px 0"></div></div><div class="rows" id="drows"></div></div><div class="detail" id="ddetail"></div></div>`;
+  const cols=deskCols();
+  const sortOpts='<option value="">Sort: default</option>'+cols.map(c=>`<option value="${c.k}:asc">${esc(c.l)} ${c.num?'↑':'A→Z'}</option><option value="${c.k}:desc">${esc(c.l)} ${c.num?'↓':'Z→A'}</option>`).join('');
+  $('v-directory').innerHTML=`<div class="md"><div class="list">`+
+    `<div class="search"><input id="dq" placeholder="Search ${D.scope}…"><div class="fresh" id="dcount" style="padding:7px 2px 0"></div></div>`+
+    `<div class="ctls"><select id="dsort" title="Sort">${sortOpts}</select>`+
+    `<span class="seg" id="dseg" role="group" aria-label="View"><button data-lv="grid">▦</button><button data-lv="list">☰</button><button data-lv="table">▤</button></span></div>`+
+    `<div class="rows" id="drows"></div></div><div class="detail" id="ddetail"></div></div>`;
+  $('dsort').value=D.sort?D.sort.k+':'+D.sort.dir:'';
+  [...$('dseg').children].forEach(x=>x.classList.toggle('on',x.dataset.lv===D.listView));
+  $('dsort').onchange=e=>{const v=e.target.value;if(!v)D.sort=null;else{const[k,d]=v.split(':');D.sort={k,dir:d};}draw();};
+  $('dseg').addEventListener('click',e=>{const b=e.target.closest('button[data-lv]');if(!b)return;D.listView=b.dataset.lv;localStorage.setItem('rentv.desk.listView',D.listView);[...$('dseg').children].forEach(x=>x.classList.toggle('on',x.dataset.lv===D.listView));draw();});
   function list(){let r=rows.slice();const v=($('dq').value||'').toLowerCase();if(v)r=r.filter(x=>JSON.stringify(x).toLowerCase().includes(v));return r;}
   function detail(x){if(!x){$('ddetail').innerHTML='';return;}
     const grid=D.scope==='sublease'?[['SqFt',x.sqft?Number(x.sqft).toLocaleString():'—'],['Asking',subRate(x)],['Term',x.term_months?x.term_months+' mo':'—'],['Available',x.available_date?dstr(x.available_date):'Now'],['Sublandlord',x.sublandlord||'—'],['Listing broker',x.firm_name||'—']]:D.scope==='owners'?[['Assessed',shortUsd(x.assessed_total)],['SqFt',(x.sqft||0).toLocaleString()],['Built',x.year_built||'—'],['AIN',x.ain]]:D.scope==='firms'?[['Agents',(x.agent_count||0).toLocaleString()],['HQ',loc(x)||'—'],['License',x.license_no||'—'],['Source',x.source||'—']]:D.scope==='commercial'?[['Firm',x.firm||'—'],['Phone',x.phone||'—'],['Email',x.email||'—'],['Listings',String(x.listings??'0')],['Type',x.agent_type||'—'],['Specialties',x.specialties||'—'],['License',x.dre_license||x.license||'—'],['Office',x.office_addr||'—']]:[['Phone',(x.broker_phone||x.firm_phone)||'—'],['Email',(x.broker_email||x.firm_email)||'—'],['License',x.license_no||'—'],['Type',x.license_type||'—'],['Firm',x.firm_name||'—'],['City',x.city||'—']];
@@ -270,13 +322,30 @@ function directory(){
   const emptyMsg=D.scope==='sublease'
     ? '<div class="fresh" style="padding:20px 16px;line-height:1.6">No sublease listings yet.<br>Inventory is entered by the RENTV desk from live broker deals — <a class="field-link" href="/desk-admin">add a listing →</a></div>'
     : '<div class="fresh">No matches in the registry.</div>';
-  async function draw(){const r=await liveRows(D.scope,$('dq').value);
+  function cell(x,c){let v=x[c.k];if(v==null||v==='')return '—';if(c.num)return (+v).toLocaleString();return esc(v);}
+  function tableHtml(r){
+    const cols=deskCols();
+    const arrow=k=>(D.sort&&D.sort.k===k)?(D.sort.dir==='desc'?' ▼':' ▲'):'';
+    const head=cols.map(c=>`<th data-k="${c.k}">${esc(c.l)}${arrow(c.k)}</th>`).join('');
+    const body=r.map((x,i)=>`<tr class="${i===D.selDir?'sel':''}" data-i="${i}">${cols.map(c=>`<td>${cell(x,c)}</td>`).join('')}</tr>`).join('');
+    return `<table class="dt"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
+  }
+  async function draw(){let r=await liveRows(D.scope,$('dq').value);
+    r=sortDeskRows(r);
     const tot=D.scope==='brokers'?D.totalB:D.scope==='firms'?D.totalF:D.scope==='sublease'?D.totalS:D.scope==='commercial'?D.totalC:D.owners.length;
     const term=($('dq').value||'').trim();
     $('dcount').textContent=r.length?(term?`${r.length} match${r.length===1?'':'es'}`:`Showing ${r.length.toLocaleString()} of ${Number(tot).toLocaleString()}`):'';
-    $('drows').innerHTML=r.map((x,i)=>`<div class="row${i===D.selDir?' sel':''}" data-i="${i}"><div class="nm">${esc(nm(x))}</div><div class="f">${esc(sub(D.scope,x))}</div></div>`).join('')||emptyMsg;
+    const box=$('drows');
+    box.classList.remove('v-grid','v-list','v-table'); box.classList.add('v-'+D.listView);
+    if(!r.length){ box.innerHTML=emptyMsg; }
+    else if(D.listView==='table'){ box.innerHTML=tableHtml(r); }
+    else { box.innerHTML=r.map((x,i)=>`<div class="row${i===D.selDir?' sel':''}" data-i="${i}"><div class="nm">${esc(nm(x))}</div><div class="f">${esc(sub(D.scope,x))}</div></div>`).join(''); }
     if(!r.length&&D.scope==='sublease'){$('ddetail').innerHTML='<div class="fresh" style="padding:30px 36px;line-height:1.7;max-width:520px">This desk surfaces <b style="color:#e7ecf1">real sublease space</b> — sqft, asking rate, term, the sublandlord, and the listing broker, each with click-outs to the listing, map, and broker. It fills as the RENTV team logs deals from their broker network.</div>';return;}
-    $('drows').querySelectorAll('.row').forEach(row=>row.onclick=()=>{D.selDir=+row.dataset.i;$('drows').querySelectorAll('.row').forEach(x=>x.classList.remove('sel'));row.classList.add('sel');detail(r[D.selDir]);});detail(r[D.selDir]||r[0]);}
+    const selectRow=i=>{D.selDir=i;box.querySelectorAll('.row,tr[data-i]').forEach(x=>x.classList.remove('sel'));const el=box.querySelector(`[data-i="${i}"]`);if(el)el.classList.add('sel');detail(r[i]);};
+    // click-to-sort table headers (client-side, re-render)
+    box.querySelectorAll('th[data-k]').forEach(th=>th.onclick=()=>{const k=th.dataset.k;D.sort=(D.sort&&D.sort.k===k)?{k,dir:D.sort.dir==='desc'?'asc':'desc'}:{k,dir:'asc'};$('dsort').value=D.sort.k+':'+D.sort.dir;draw();});
+    box.querySelectorAll('.row[data-i],tr[data-i]').forEach(row=>row.onclick=e=>{if(e.target.closest('a'))return;selectRow(+row.dataset.i);});
+    detail(r[D.selDir]||r[0]);}
   $('dq').oninput=debounce(()=>{D.selDir=0;draw();},300);draw();
 }
 // Programmatic scope switch — the drill target for a count (Steve's "counts → filtered list" rule).
@@ -284,7 +353,7 @@ function directory(){
 function gotoScope(s){
   if(!s) return;
   document.querySelectorAll('#rail .scope').forEach(x=>x.classList.toggle('on',x.dataset.s===s));
-  D.scope=s; D.selDir=0; D.view='directory'; setFresh();
+  D.scope=s; D.selDir=0; D.sort=null; D.view='directory'; setFresh();
   document.querySelectorAll('#rail [data-v]').forEach(x=>x.classList.toggle('on',x.dataset.v==='directory'));
   document.querySelectorAll('.view').forEach(v=>v.classList.toggle('on',v.id==='v-directory'));
   renderAll();

← 0169120d directory.html: add Sort select + Grid/List/Table views + cl  ·  back to Rentv  ·  deals.html: add Grid/List/Table views + expanded all-column 1494e910 →