← back to Commercialrealestate
residential-brokers: add 3-view (grid/list/table) + all-field sort + collapsed rails (TK-10703)
3971e26b634b2e6961ac2a4167d077a5721ec340 · 2026-08-18 19:02:59 -0700 · Steve Abrams
Files touched
M public/residential-brokers.html
Diff
commit 3971e26b634b2e6961ac2a4167d077a5721ec340
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 18 19:02:59 2026 -0700
residential-brokers: add 3-view (grid/list/table) + all-field sort + collapsed rails (TK-10703)
---
public/residential-brokers.html | 120 +++++++++++++++++++++++++++++++++-------
1 file changed, 99 insertions(+), 21 deletions(-)
diff --git a/public/residential-brokers.html b/public/residential-brokers.html
index b21d2e1..5842908 100644
--- a/public/residential-brokers.html
+++ b/public/residential-brokers.html
@@ -38,7 +38,23 @@
.contact .tier{font-size:9.5px;color:var(--muted);opacity:.8}
.contact .none{color:var(--muted)}
.empty{color:var(--muted);padding:40px;text-align:center}
+ /* ── 3-view segmented [Grid|List|Table] + all-field sort (mirrors just-listed.html) ── */
+ #viewseg{display:inline-flex}
+ #viewseg button{background:#0c1017;color:var(--muted);border:1px solid var(--line);padding:6px 11px;font-size:12px;cursor:pointer}
+ #viewseg button:first-child{border-radius:8px 0 0 8px}
+ #viewseg button:last-child{border-radius:0 8px 8px 0}
+ #viewseg button:not(:last-child){border-right:0}
+ #viewseg button.active{color:#0a0d13;background:var(--gold);border-color:var(--gold);font-weight:600}
+ /* ── table view ── */
+ #tableView{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--panel);max-height:calc(100vh - 260px)}
+ #tableView table{border-collapse:collapse;width:100%;font-size:12.5px}
+ #tableView thead th{position:sticky;top:0;background:#11161d;z-index:2;text-align:left;padding:9px 11px;border-bottom:1px solid var(--line);white-space:nowrap;color:var(--muted);font-weight:700;font-size:10.5px;text-transform:uppercase;letter-spacing:.4px}
+ #tableView tbody td{padding:7px 11px;border-bottom:1px solid var(--line);vertical-align:top;line-height:1.35}
+ #tableView tbody tr:hover{background:rgba(74,144,217,.06)}
+ #tableView td a{color:var(--accent);text-decoration:none}
+ #tableView td a:hover{text-decoration:underline}
</style>
+ <link rel="stylesheet" href="/three-view.css">
<link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
</head>
<body>
@@ -56,54 +72,72 @@
<option value="FL">Florida</option><option value="TX">Texas</option>
<option value="NY">New York</option><option value="AZ">Arizona</option>
</select>
- <select id="sort">
- <option value="name">Sort: Name A→Z</option>
- <option value="firm_name">Firm A→Z</option>
- <option value="city">City A→Z</option>
- <option value="license_state">License state</option>
- <option value="license_no">License #</option>
- </select>
+ <span data-tvseg id="viewseg"></span>
+ <select id="tvSort"></select>
<label>Density <input id="dens" type="range" min="2" max="7" value="4"></label>
<span class="count" id="count">loading…</span>
</div>
<div class="toggles" id="toggles"></div>
-<main><div class="grid" id="grid"></div><div class="empty" id="empty" hidden></div></main>
+<main>
+ <div class="grid" id="grid"></div>
+ <div id="tvList"></div>
+ <div id="tableView"><table><thead></thead><tbody></tbody></table></div>
+ <div class="empty" id="empty" hidden></div>
+</main>
+<script src="/three-view.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script>
+// FIELDS drive the card body rows, the left-rail-style toggles, the table columns,
+// and the all-field sort menu. `get` = sortable/plain value; `render` = optional HTML.
+// `lock:true` = contact columns that can never be hidden (Steve hard rule).
const FIELDS = [
+ {k:'name', label:'Name', get:r=>r.name},
{k:'firm', label:'Firm', get:r=>r.firm_name},
{k:'city', label:'City', get:r=>[r.city,r.state_code].filter(Boolean).join(', ')},
{k:'license', label:'License', get:r=>r.license_no ? (r.license_no+(r.license_state?' · '+r.license_state:'')) : ''},
+ {k:'type', label:'Type', get:r=>r.license_type},
{k:'status', label:'Status', get:r=>r.license_status},
{k:'class', label:'Class', get:r=>r.asset_class},
+ {k:'phone', label:'Phone', lock:true, get:r=>brokerPhone(r),
+ render:r=>{const p=brokerPhone(r);return p?`<a href="tel:${esc(p.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">${esc(p)}</a>`:'<span class="none">—</span>';}},
+ {k:'email', label:'Email', lock:true, get:r=>brokerEmail(r),
+ render:r=>{const e=brokerEmail(r);return e?`<a href="mailto:${esc(e)}" onclick="event.stopPropagation()">${esc(e)}</a>`:'<span class="none">—</span>';}},
];
+const brokerPhone=r=>r.phone||r.broker_phone||r.firm_phone||'';
+const brokerEmail=r=>r.email||r.broker_email||r.firm_email||'';
const LS='crcp-resbrokers';
const state = Object.assign({state:'CA',sort:'name',q:'',cols:4,off:{}}, JSON.parse(localStorage.getItem(LS)||'{}'));
-let ROWS=[];
+let ROWS=[], TV=null;
const $=id=>document.getElementById(id);
function save(){ localStorage.setItem(LS, JSON.stringify(state)); }
+// contact columns can never be toggled off — Steve hard rule
+FIELDS.forEach(f=>{ if(f.lock) state.off[f.k]=false; });
function renderToggles(){
$('toggles').innerHTML='';
+ // 'name' is always the card title; locked contact fields (phone/email) can't be hidden
for(const f of FIELDS){
+ if(f.k==='name'||f.lock) continue;
const b=document.createElement('button');
b.textContent=f.label; b.className = state.off[f.k] ? '' : 'on';
b.onclick=()=>{ state.off[f.k]=!state.off[f.k]; save(); b.className=state.off[f.k]?'':'on'; draw(); };
$('toggles').appendChild(b);
}
}
-function draw(){
+const fvis=k=>{const f=FIELDS.find(x=>x.k===k);return f?(f.lock||!state.off[k]):false;};
+// filtered+sorted rows (ThreeView owns the all-field sort via applySort; this keeps
+// the page's own default order for grid/table). getRows() = these rows.
+function sortedRows(){ return [...ROWS]; }
+
+// ── GRID cards (the page's EXISTING renderer — unchanged look) ──
+function renderGrid(rows){
const grid=$('grid');
- document.documentElement.style.setProperty('--cols', state.cols);
- let rows=[...ROWS];
- const key=state.sort;
- rows.sort((a,b)=>String(a[key]||'').localeCompare(String(b[key]||''),undefined,{numeric:true}));
- if(!rows.length){ grid.innerHTML=''; $('empty').hidden=false; $('empty').textContent='No residential brokers match.'; return; }
- $('empty').hidden=true;
+ if(!rows.length){ grid.innerHTML=''; return; }
grid.innerHTML = rows.map(r=>{
- const lines = FIELDS.filter(f=>!state.off[f.k] && f.k!=='firm' && f.k!=='class').map(f=>{
+ // body rows: everything visible except name (title), firm (own line), class (pill), and locked contacts
+ const lines = FIELDS.filter(f=>!state.off[f.k] && !f.lock && f.k!=='name' && f.k!=='firm' && f.k!=='class').map(f=>{
const v=f.get(r); return v ? `<div class="row">${f.label}: <b>${esc(v)}</b></div>` : '';
}).join('');
const firm = !state.off.firm && r.firm_name ? `<div class="fm">${esc(r.firm_name)}</div>` : '';
@@ -111,7 +145,28 @@ function draw(){
return `<div class="card"><div class="nm">${esc(r.name||'—')}</div>${firm}${lines}${cls}${contact(r)}</div>`;
}).join('');
}
-function esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+
+// ── TABLE view (simple table of the same rows/fields; phone+email always present) ──
+function tableCols(){ return FIELDS.filter(f=>f.lock || (!state.off[f.k] && f.k!=='class')); }
+function renderTable(rows){
+ const cols=tableCols(), t=$('tableView').querySelector('table');
+ t.querySelector('thead').innerHTML='<tr>'+cols.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
+ t.querySelector('tbody').innerHTML=rows.slice(0,600).map(r=>
+ '<tr>'+cols.map(c=>{
+ const cell=c.render?c.render(r):(c.get(r)||'—');
+ return `<td>${c.render?cell:esc(cell)}</td>`;
+ }).join('')+'</tr>').join('');
+}
+
+function draw(){
+ document.documentElement.style.setProperty('--cols', state.cols);
+ const rows=sortedRows();
+ const has=rows.length>0;
+ $('empty').hidden=has;
+ if(!has){ $('empty').textContent='No residential brokers match.'; }
+ if(TV){ TV.render(); } else { renderGrid(rows); renderTable(rows); }
+}
+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
// ── broker contact (Steve HARD RULE: ALWAYS show broker phone + email, always visible) ──
// The /api/residential-brokers row already resolves phone/email (broker→firm) with a phone_tier,
@@ -146,13 +201,36 @@ async function load(){
}catch(e){ $('count').textContent='error'; $('empty').hidden=false; $('empty').textContent='Could not reach the registry.'; }
}
+// ── ThreeView: Grid / List / Table + all-field sort (guarded — three-view.js is deferred) ──
+function mountThreeView(){
+ const fields=FIELDS.map(f=>({k:f.k,l:f.label,html:!!f.render,
+ calc:r=>f.render?f.render(r):(f.get(r)||'')}));
+ TV=ThreeView.mount({
+ seg:'#viewseg', sortSel:'#tvSort',
+ listMount:'#tvList', gridMount:'#grid', tableMount:'#tableView',
+ fields,
+ getRows:sortedRows,
+ title:r=>({name:r.name||'—',sub:[r.firm_name,[r.city,r.state_code].filter(Boolean).join(', ')].filter(Boolean).join(' · '),href:null}),
+ badgeHtml:r=>r.asset_class?`<span class="pill">${esc(r.asset_class)}</span>`:'',
+ fvis,
+ renderGrid, renderTable,
+ onSort:()=>{ /* helper owns the sort; just re-render */ },
+ rowClick:()=>{},
+ storageKey:'resb', defaultView:'grid', views:['grid','list','table'],
+ });
+}
+let _tvTries=0;
+function startViews(){
+ if(typeof ThreeView==='undefined'){ if(++_tvTries>100){renderGrid(sortedRows());renderTable(sortedRows());return;} return setTimeout(startViews,40); }
+ mountThreeView(); draw();
+}
+
// wire controls
-$('q').value=state.q; $('state').value=state.state; $('sort').value=state.sort; $('dens').value=state.cols;
+$('q').value=state.q; $('state').value=state.state; $('dens').value=state.cols;
let t; $('q').oninput=e=>{ state.q=e.target.value.trim(); save(); clearTimeout(t); t=setTimeout(load,300); };
$('state').onchange=e=>{ state.state=e.target.value; save(); load(); };
-$('sort').onchange=e=>{ state.sort=e.target.value; save(); draw(); };
$('dens').oninput=e=>{ state.cols=+e.target.value; save(); draw(); };
-renderToggles(); load();
+renderToggles(); startViews(); load();
</script>
<script src="/crcp-theme.js" defer></script><!-- CRCP day/night toggle (auto-injects into header) -->
</body>
← 132b8b7 licensed-agents + licensed: require name+firm match for brok
·
back to Commercialrealestate
·
licensed-agents: add 3-view + all-field sort (TK-10703) b439ff0 →