← 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 →