← back to Commercialrealestate
public/rent-rolls.html
464 lines
<!doctype html>
<html lang="en">
<head>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='light'?'light':'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>
<script>/* credential-safe fetch (2026-08-18): resolve EVERY relative fetch (with or without a leading slash) against a credential-free base so creds embedded in the page URL (http://user:pass@host/...) don't poison document.baseURI and throw "Request cannot be constructed from a URL that includes credentials". */(function(){var of=window.fetch,O=location.origin;window.fetch=function(u,o){try{if(typeof u==='string'){if(/^[a-z][a-z0-9+.-]*:/i.test(u)){var x=new URL(u);if(x.username||x.password){x.username='';x.password='';u=x.href;}}else{u=new URL(u,O+location.pathname+location.search).href;}}else if(u&&typeof u.url==='string'){var y=new URL(u.url,O+location.pathname+location.search);if(y.username||y.password){y.username='';y.password='';u=new Request(y.href,u);}}}catch(e){}return of.call(this,u,o);};try{if(location.href.indexOf('@')>=0){history.replaceState(null,'',location.pathname+location.search+location.hash);}}catch(e){}})();</script>
<!-- Self-heal: nav-agent grid controls can persist a "removed"/"collapsed" state that hides the
whole grid with no obvious way back. Clear it before nav-agent runs so a grid can never stay hidden. -->
<script>(function(){try{for(var i=localStorage.length-1;i>=0;i--){var k=localStorage.key(i);if(k&&k.indexOf('navagent:')===0){var v=localStorage.getItem(k);if(v&&v.indexOf('"removed":true')>=0){localStorage.removeItem(k);}}}}catch(e){}})();</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%23c8a24b'%3E%24%3C/text%3E%3C/svg%3E">
<title>Rent Rolls → DSCR — LA County CRE</title>
<style>
:root{--gold:#e9c46a;--teal:#2dd4bf;--blue:#60a5fa;--violet:#8b7cf6;--pink:#f472b6;--ink:#eef1f7;--muted:#9aa3b4;
--glass:rgba(255,255,255,.055);--glassb:rgba(255,255,255,.12);--ok:#34d399;--warn:#fb923c;--bad:#f87171;}
*{box-sizing:border-box;margin:0;padding:0;}
html,body{min-height:100%;background:var(--bg,#070a10);color:var(--ink);font:15px/1.5 -apple-system,'SF Pro Display',Segoe UI,Roboto,sans-serif;-webkit-font-smoothing:antialiased;}
body::before{content:'';position:fixed;inset:-25%;z-index:-2;background:
radial-gradient(38vw 38vw at 16% 20%,rgba(45,212,191,.34),transparent 60%),
radial-gradient(34vw 34vw at 84% 16%,rgba(139,124,246,.32),transparent 60%),
radial-gradient(40vw 40vw at 74% 88%,rgba(233,196,106,.28),transparent 62%);filter:saturate(1.2);animation:drift 28s ease-in-out infinite alternate;}
body::after{content:'';position:fixed;inset:0;z-index:-1;backdrop-filter:blur(70px);background:rgba(7,10,16,.36);}
@keyframes drift{0%{transform:translate3d(0,0,0) scale(1);}100%{transform:translate3d(-2%,2%,0) scale(1.06);}}
@media(prefers-reduced-motion:reduce){body::before{animation:none;}.glass{animation:none;opacity:1;transform:none;}}
.wrap{max-width:1180px;margin:0 auto;padding:22px 22px 60px;}
header{display:flex;align-items:center;gap:16px;margin-bottom:20px;flex-wrap:wrap;}
h1{font-size:25px;font-weight:800;letter-spacing:-.5px;background:linear-gradient(92deg,#fff,var(--gold) 60%,var(--teal));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;}
.sub{font-size:12.5px;color:var(--muted);margin-top:2px;}
nav{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap;}
nav a{font-size:12.5px;color:var(--muted);text-decoration:none;padding:7px 13px;border-radius:13px;background:var(--glass);border:1px solid var(--glassb);backdrop-filter:blur(14px);}
nav a:hover{color:#fff;border-color:rgba(233,196,106,.5);}
.glass{background:var(--glass);border:1px solid var(--glassb);border-radius:20px;backdrop-filter:blur(22px) saturate(1.5);
box-shadow:0 12px 40px rgba(0,0,0,.4),inset 0 1px 0 rgba(255,255,255,.16);overflow:hidden;}
.roll{padding:20px 22px;margin-bottom:18px;}
/* Cards-view density (Steve rule: every product grid has sort + density). #rolls becomes an
N-column grid driven by --cols; default 1 = the original stacked look, so no regression. */
#rolls{display:grid;grid-template-columns:repeat(var(--cols,1),minmax(0,1fr));gap:18px;}
#rolls .roll{margin-bottom:0;}
/* the .roll cards embed a DSCR calculator (fixed 1fr 1fr input rows) — force a single
column on narrow viewports so density can't crush the calculator below usable width. */
@media(max-width:820px){#rolls{grid-template-columns:1fr;}}
.dens{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px;white-space:nowrap;}
.dens input[type=range]{accent-color:var(--acc,#4aa3ff);width:96px;}
.roll h2{font-size:18px;font-weight:700;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
.badge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:2px 8px;border-radius:9px;background:rgba(251,146,60,.18);color:var(--warn);border:1px solid rgba(251,146,60,.4);}
.addr{font-size:12.5px;color:var(--muted);margin-top:2px;}
.uwgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(135px,1fr));gap:12px;margin:16px 0;}
.uw{background:var(--card,#0a0d13);border:1px solid var(--glassb);border-radius:12px;padding:12px 13px;}
.uw .k{font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);}
.uw .v{font-size:21px;font-weight:700;margin-top:4px;font-variant-numeric:tabular-nums;}
.uw .v.gold{color:var(--gold);} .uw .v.teal{color:var(--teal);} .uw .v.blue{color:var(--blue);}
.cols{display:grid;grid-template-columns:1.1fr 1fr;gap:18px;margin-top:8px;}
@media(max-width:820px){.cols{grid-template-columns:1fr;}}
table{width:100%;border-collapse:collapse;font-size:13px;}
th,td{text-align:left;padding:6px 8px;border-top:1px solid var(--glassb);}
th{font-size:10.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);border-top:0;}
td.n{text-align:right;font-variant-numeric:tabular-nums;}
.vac{color:var(--warn);} .occ{color:var(--ok);}
.sec h3{font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin-bottom:10px;}
.calc label{display:block;font-size:11px;color:var(--muted);margin:9px 0 3px;}
.calc input{width:100%;background:var(--card,#0a0d13);border:1px solid var(--glassb);border-radius:9px;padding:8px 10px;color:var(--ink);font-size:14px;font-variant-numeric:tabular-nums;}
.calc .row{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
.dscrout{margin-top:14px;padding:14px;border-radius:12px;background:var(--card,#0a0d13);border:1px solid var(--glassb);}
.dscrbig{font-size:34px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1;}
.dscrbig.pass{color:var(--ok);} .dscrbig.lean{color:var(--warn);} .dscrbig.fail{color:var(--bad);}
.dline{display:flex;justify-content:space-between;font-size:13px;margin-top:8px;color:var(--muted);} .dline b{color:var(--ink);font-variant-numeric:tabular-nums;}
.verdict{font-size:12px;margin-top:8px;} .note{font-size:11px;color:var(--muted);margin-top:12px;line-height:1.6;}
.empty{padding:50px 24px;text-align:center;color:var(--muted);} .empty code{background:var(--card,#0a0d13);padding:2px 7px;border-radius:6px;color:var(--gold);}
/* ── table-view toolbar + controls (Steve list/table rule 2026-07-31) ── */
.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:16px;}
.search{background:var(--glass);border:1px solid var(--glassb);color:var(--ink);border-radius:11px;padding:8px 13px;font-size:13.5px;min-width:260px;flex:1 1 260px;backdrop-filter:blur(14px);}
.search::placeholder{color:var(--muted);}
.toggle{display:flex;border:1px solid var(--glassb);border-radius:11px;overflow:hidden;background:var(--glass);}
.toggle button{background:transparent;border:0;color:var(--muted);padding:8px 13px;font-size:13px;cursor:pointer;}
.toggle button.on{background:var(--gold);color:#1a1205;font-weight:700;}
#sortsel{background:var(--glass);color:var(--ink);border:1px solid var(--glassb);border-radius:11px;padding:8px 10px;font-size:13px;backdrop-filter:blur(14px);}
#sortsel optgroup{color:var(--muted);} #sortsel option{color:var(--ink);background:var(--card,#0a0d13);}
.count{color:var(--muted);font-size:12px;margin-left:auto;}
/* Columns ▾ dropdown */
.coldrop{position:relative;}
.coldrop>button{background:var(--glass);border:1px solid var(--glassb);color:var(--ink);border-radius:11px;padding:8px 12px;font-size:13px;cursor:pointer;backdrop-filter:blur(14px);}
.coldrop>button:hover{border-color:rgba(233,196,106,.5);}
.colmenu{display:none;position:absolute;right:0;top:calc(100% + 6px);z-index:50;min-width:240px;max-height:60vh;overflow:auto;
background:var(--card,#0c1018);border:1px solid var(--glassb);border-radius:13px;padding:10px;box-shadow:0 16px 44px rgba(0,0,0,.55);}
.coldrop.open .colmenu{display:block;}
.colmenu .fgrp{font-size:9.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--gold);margin:9px 0 3px;font-weight:700;opacity:.85;}
.colmenu .fgrp:first-child{margin-top:0;}
.ftog{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--ink);cursor:pointer;padding:3px 4px;border-radius:6px;}
.ftog:hover{background:rgba(233,196,106,.08);} .ftog input{accent-color:var(--gold);}
.minibtn{background:var(--glass);color:var(--muted);border:1px solid var(--glassb);border-radius:8px;padding:4px 9px;font-size:11px;cursor:pointer;margin-right:5px;} .minibtn:hover{border-color:var(--gold);color:var(--ink);}
.colmenu .mbrow{display:flex;gap:5px;margin-top:9px;border-top:1px solid var(--glassb);padding-top:9px;}
/* data table */
.tblwrap{overflow:auto;border:1px solid var(--glassb);border-radius:16px;background:var(--glass);backdrop-filter:blur(22px) saturate(1.5);}
table.g{border-collapse:separate;border-spacing:0;width:100%;font-size:12.5px;white-space:nowrap;}
table.g thead th{position:sticky;top:0;background:var(--headbg,#12161f);color:var(--muted);text-transform:uppercase;letter-spacing:.3px;font-size:10.5px;padding:9px 11px;border-bottom:1px solid var(--glassb);cursor:pointer;user-select:none;z-index:2;text-align:left;}
table.g thead th:hover{color:var(--ink);}
table.g thead th.asc::after{content:' ▲';color:var(--gold);} table.g thead th.desc::after{content:' ▼';color:var(--gold);}
table.g thead th.dragcol{cursor:grab;}
table.g thead th.dragging{opacity:.45;}
table.g thead th.dropL{box-shadow:inset 3px 0 0 var(--gold);}
table.g thead th.dropR{box-shadow:inset -3px 0 0 var(--gold);}
table.g tbody td{padding:8px 11px;border-bottom:1px solid rgba(255,255,255,.06);}
table.g tbody tr:hover td{background:rgba(233,196,106,.07);}
table.g td.n{text-align:right;font-variant-numeric:tabular-nums;}
table.g td.miss{color:#5A6472;}
.dscrpill{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:9px;font-variant-numeric:tabular-nums;}
.dscrpill.pass{background:rgba(52,211,153,.16);color:var(--ok);} .dscrpill.lean{background:rgba(251,146,60,.16);color:var(--warn);} .dscrpill.fail{background:rgba(248,113,113,.16);color:var(--bad);}
/* detail modal */
#ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:60;align-items:flex-start;justify-content:center;padding:40px 14px;overflow-y:auto;}
#ov.on{display:flex;}
#modal{width:min(760px,96vw);position:relative;padding:0;}
#modal .x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--muted);font-size:22px;cursor:pointer;z-index:2;}
.hide{display:none;}
/* compact List view (3rd view alongside Table + Cards) */
.lst{padding:8px 0 40px;display:flex;flex-direction:column;}
.lrow{display:flex;align-items:baseline;gap:16px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--line,rgba(255,255,255,.08));cursor:pointer;}
.lrow:hover{background:var(--hover,rgba(120,160,255,.06));}
.lnm{font-weight:700;min-width:200px;flex:0 0 auto;color:var(--ink,#e6edf3);}
.lmeta{display:flex;flex-wrap:wrap;gap:5px 16px;flex:1 1 320px;min-width:0;}
.lm{font-size:12.5px;white-space:nowrap;color:var(--ink,#e6edf3);}
.lk{color:var(--mut,#8b949e);font-size:10px;text-transform:uppercase;letter-spacing:.5px;margin-right:5px;}
</style>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
<link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so html[data-theme] wins -->
</head>
<body>
<div class="wrap">
<header>
<div><h1>📑 Rent Rolls → DSCR</h1><div class="sub" id="sub">document-grade underwriting from parsed rent rolls · local · $0</div></div>
<nav><a href="/">Properties</a><a href="/deals.html">Deals</a><a href="/map.html">Map</a><a href="/crcp.html">CRCP</a></nav>
</header>
<div class="toolbar" id="toolbar" style="display:none">
<input class="search" id="q" type="text" placeholder="search all fields — address, city, zip, source, units, NOI, rent… (space = AND)">
<div class="toggle" id="view"><button data-v="table" class="on">▦ Table</button><button data-v="list">☰ List</button><button data-v="cards">▤ Cards</button></div>
<select id="sortsel" title="Sort rent rolls"></select>
<label class="dens" title="Card density — columns in Cards view">▦ <input type="range" id="cols" min="1" max="4" step="1" value="1"></label>
<div class="coldrop" id="coldrop">
<button id="colbtn" title="Show / hide columns">Columns ▾</button>
<div class="colmenu" id="colmenu"></div>
</div>
<span class="count" id="count">…</span>
</div>
<div id="tableView" class="tblwrap hide"><table class="g" data-no-sort><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
<div id="listView" class="lst hide"></div>
<div id="rolls"></div>
</div>
<div id="ov"><div id="modal" class="glass"><button class="x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
<script>
const $=s=>document.querySelector(s);
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');
const M = n => (n==null||!isFinite(n)) ? '—' : '$'+Math.round(n).toLocaleString();
const PCT = n => (n==null||!isFinite(n)) ? '—' : n.toFixed(1)+'%';
const num = n => (n==null||n==='')?'—':Number(n).toLocaleString();
const dt = s => { if(!s) return '—'; try{ return new Date(s).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); }catch(e){ return '—'; } };
// amortizing payment + annual debt service constant
function pmt(L, ratePct, years){ const c=ratePct/100/12, n=years*12; return c===0 ? L/n : L*c/(1-Math.pow(1+c,-n)); }
function annualConstant(ratePct, years){ return pmt(1, ratePct, years)*12; }
/* ── existing per-rent-roll CARD render (faithfully preserved) ── */
function rollHtml(r, i){
const uw = r.underwriting||{};
const noi = uw.noiProxyAnnual||0, inplace = uw.inPlaceRentAnnual||0;
const units = (r.units||[]);
const unitRows = units.map(u=>`<tr><td>${u.unit||'—'}</td><td>${u.beds?u.beds+'bd':'—'}</td>
<td class="${u.status==='vacant'?'vac':'occ'}">${u.status||'—'}</td><td class="n">${u.marketRent?M(u.marketRent):'—'}</td></tr>`).join('');
return `<div class="glass roll" data-i="${i}">
<h2>${esc((r.property?.address)||r.source||'Rent roll')} ${r.isSample?'<span class="badge">demo</span>':''}</h2>
<div class="addr">${esc([r.property?.city,r.property?.zip].filter(Boolean).join(' '))} · ${r.unitCount||units.length} units · parsed from ${esc(r.source||'PDF')}</div>
<div class="uwgrid">
<div class="uw"><div class="k">Units</div><div class="v">${uw.unitCount||units.length}</div></div>
<div class="uw"><div class="k">Occupied</div><div class="v teal">${uw.occupied??'—'} <span style="font-size:12px;color:var(--muted)">/ ${uw.vacant??0} vac</span></div></div>
<div class="uw"><div class="k">Gross sched. rent</div><div class="v">${M(uw.grossScheduledRentAnnual)}<span style="font-size:11px;color:var(--muted)">/yr</span></div></div>
<div class="uw"><div class="k">In-place rent</div><div class="v gold">${M(inplace)}<span style="font-size:11px;color:var(--muted)">/yr</span></div></div>
<div class="uw"><div class="k">NOI (proxy)</div><div class="v blue">${M(noi)}<span style="font-size:11px;color:var(--muted)">/yr</span></div></div>
<div class="uw"><div class="k">Vacancy</div><div class="v">${PCT(uw.vacancyRate)}</div></div>
</div>
<div class="cols">
<div class="sec"><h3>Unit-by-unit</h3>
<table><thead><tr><th>Unit</th><th>Beds</th><th>Status</th><th class="n">Market rent</th></tr></thead><tbody>${unitRows}</tbody></table>
</div>
<div class="sec calc"><h3>DSCR calculator</h3>
<label>Net operating income (NOI, annual)</label><input type="number" data-f="noi" value="${noi}">
<div class="row"><div><label>Loan amount</label><input type="number" data-f="loan" value="${Math.round(noi/0.085/1000)*1000}"></div>
<div><label>Interest rate %</label><input type="number" step="0.05" data-f="rate" value="6.75"></div></div>
<div class="row"><div><label>Amortization (yrs)</label><input type="number" data-f="amort" value="30"></div>
<div><label>Target DSCR</label><input type="number" step="0.05" data-f="target" value="1.25"></div></div>
<div class="dscrout" data-out>—</div>
</div>
</div>
<div class="note">${uw.noiBasis?('NOI basis: '+esc(uw.noiBasis)+'. '):''}${esc(r.note||'')} DSCR = NOI ÷ annual debt service. Not an appraisal or a lending commitment.</div>
</div>`;
}
function recalc(roll){
const g = f => parseFloat(roll.querySelector(`[data-f="${f}"]`).value)||0;
const noi=g('noi'), loan=g('loan'), rate=g('rate'), amort=g('amort'), target=g('target')||1.25;
const ads = pmt(loan, rate, amort)*12; // annual debt service
const dscr = ads>0 ? noi/ads : 0;
const maxLoan = annualConstant(rate,amort)>0 ? (noi/target)/annualConstant(rate,amort) : 0;
const cls = dscr>=target ? 'pass' : dscr>=1.0 ? 'lean' : 'fail';
const verdict = dscr>=target ? `✓ Clears the ${target.toFixed(2)} target` : dscr>=1.0 ? `⚠ Covers debt but under the ${target.toFixed(2)} target` : `✗ NOI does not cover debt service`;
roll.querySelector('[data-out]').innerHTML =
`<div class="dscrbig ${cls}">${dscr?dscr.toFixed(2):'—'}<span style="font-size:13px;color:var(--muted);font-weight:400"> DSCR</span></div>
<div class="verdict ${cls==='pass'?'occ':cls==='fail'?'vac':''}" style="color:${cls==='pass'?'var(--ok)':cls==='fail'?'var(--bad)':'var(--warn)'}">${verdict}</div>
<div class="dline">Annual debt service <b>${M(ads)}</b></div>
<div class="dline">Monthly payment <b>${M(ads/12)}</b></div>
<div class="dline">Max loan @ ${target.toFixed(2)} DSCR <b>${M(maxLoan)}</b></div>`;
}
function wireCards(){
document.querySelectorAll('.roll').forEach(roll=>{
recalc(roll);
roll.querySelectorAll('input').forEach(inp=>inp.addEventListener('input',()=>recalc(roll)));
});
}
/* ══════════════ TABLE LAYER (7 controls) ══════════════ */
// Flatten each rent roll into one searchable/sortable row. The original object is
// kept on __raw so the detail modal can render the full card (units + DSCR calc).
// A quick DSCR estimate at a benchmark rate powers the DSCR column + sort.
const DSCR_RATE=6.75, DSCR_AMORT=30, DSCR_LTV_CONST=0.085;
function estDscr(noi){ if(!noi) return null; const loan=noi/DSCR_LTV_CONST; const ads=pmt(loan,DSCR_RATE,DSCR_AMORT)*12; return ads>0?noi/ads:null; }
function flatRow(r,i){
const uw=r.underwriting||{}, p=r.property||{};
const noi=uw.noiProxyAnnual||0;
return {
id:i, __raw:r,
address:p.address||r.source||'Rent roll',
city:p.city||'', zip:p.zip||'',
units:uw.unitCount||(r.units?r.units.length:0)||r.unitCount||0,
occupied:(uw.occupied==null?null:uw.occupied),
vacant:(uw.vacant==null?null:uw.vacant),
vacancyRate:(uw.vacancyRate==null?null:uw.vacancyRate),
grossRent:uw.grossScheduledRentAnnual||null,
inPlaceRent:uw.inPlaceRentAnnual||null,
noi:noi||null,
dscr:estDscr(noi),
source:r.source||'', isSample:!!r.isSample,
parsedAt:r.parsedAt||'', note:r.note||'', noiBasis:uw.noiBasis||''
};
}
// COLS: order below IS the column order. Every field is sortable + toggleable.
const COLS=[
{k:'address',l:'Property / Address',t:'s',g:'Identity'},
{k:'city',l:'City',t:'s',g:'Identity'},
{k:'zip',l:'ZIP',t:'s',g:'Identity',def:0},
{k:'units',l:'Units',t:'n',g:'Occupancy'},
{k:'occupied',l:'Occupied',t:'n',g:'Occupancy',def:0},
{k:'vacant',l:'Vacant',t:'n',g:'Occupancy',def:0},
{k:'vacancyRate',l:'Vacancy %',t:'pct',g:'Occupancy',def:0},
{k:'grossRent',l:'Gross Rent /yr',t:'money',g:'Underwriting',def:0},
{k:'inPlaceRent',l:'In-Place Rent /yr',t:'money',g:'Underwriting',def:0},
{k:'noi',l:'NOI /yr',t:'money',g:'Underwriting'},
{k:'dscr',l:'DSCR',t:'dscr',g:'Underwriting'},
{k:'source',l:'Source',t:'s',g:'Provenance',def:0},
{k:'parsedAt',l:'Parsed',t:'date',g:'Provenance',def:0},
{k:'note',l:'Note',t:'s',g:'Provenance',def:0},
{k:'noiBasis',l:'NOI Basis',t:'s',g:'Provenance',def:0},
];
let DATA=[], view='table', sortKey='dscr', sortDir=-1, q='', lastColSig='';
// START TIGHT: only ~6 essentials on first load; a saved pref (rentCols) always wins.
const DEFAULT_ON=new Set(['address','city','units','grossRent','noi','dscr']);
let VISCOL=(function(){let s={};try{s=JSON.parse(localStorage.getItem('rentCols')||'{}');}catch(e){}return s;})();
function colVis(k){ if(k in VISCOL) return !!VISCOL[k]; return DEFAULT_ON.has(k); }
// Draggable column ORDER — persisted per browser under rentColOrder.
let COLORDER=(function(){let a=[];try{a=JSON.parse(localStorage.getItem('rentColOrder')||'[]');}catch(e){}return Array.isArray(a)?a:[];})();
function syncColOrder(){
const keys=COLS.map(c=>c.k);
COLORDER=COLORDER.filter(k=>keys.includes(k));
keys.forEach(k=>{ if(!COLORDER.includes(k)) COLORDER.push(k); });
}
function orderedCols(){ syncColOrder(); return COLORDER.map(k=>COLS.find(c=>c.k===k)).filter(Boolean); }
function visCols(){ return orderedCols().filter(c=>colVis(c.k)); }
function saveCols(){ try{localStorage.setItem('rentCols',JSON.stringify(VISCOL));}catch(e){} }
function saveColOrder(){ try{localStorage.setItem('rentColOrder',JSON.stringify(COLORDER));}catch(e){} }
// Hand the table back to col-resize.js so it rebuilds <colgroup> + handles.
function reinitResize(){
const tbl=document.querySelector('#tableView table.g'); if(!tbl) return;
const cg=tbl.querySelector('colgroup[data-cr]'); if(cg) cg.remove();
delete tbl.dataset.crDone; tbl.style.tableLayout=''; tbl.style.width='';
if(window.ColResize && window.ColResize.refresh) window.ColResize.refresh();
}
function dscrCls(v){ return v==null?'':(v>=1.25?'pass':v>=1.0?'lean':'fail'); }
function cell(r,c){
const v=r[c.k];
if(c.t==='n') return num(v);
if(c.t==='money') return M(v);
if(c.t==='pct') return PCT(v);
if(c.t==='date') return dt(v);
if(c.t==='dscr'){ if(v==null) return '<span class="miss">—</span>'; return `<span class="dscrpill ${dscrCls(v)}">${v.toFixed(2)}</span>`; }
if(c.k==='address'){ return esc(v==null?'':v)+(r.isSample?' <span class="badge">demo</span>':''); }
return esc(v==null||v===''?'—':v);
}
function filtered(){
let rows=DATA.slice();
if(q){
// Search ALL data — every field the API returned per row (nested via JSON.stringify),
// excluding the __raw back-reference (its contents ARE the row). Space = AND.
const terms=q.toLowerCase().split(/\s+/).filter(Boolean);
rows=rows.filter(r=>{
const src=r.__raw||r;
const hay=Object.keys(src).map(k=>{const v=src[k];return v==null?'':(typeof v==='object'?JSON.stringify(v):String(v));}).join(' ').toLowerCase();
return terms.every(t=>hay.includes(t));
});
}
const c=COLS.find(x=>x.k===sortKey)||COLS[0];
rows=rows.slice().sort((a,b)=>{
let x=a[c.k], y=b[c.k];
const xn=(x!=null&&x!==''&&!isNaN(+x)), yn=(y!=null&&y!==''&&!isNaN(+y));
if(xn&&yn){ x=+x; y=+y; } else { x=String(x==null?'':x).toLowerCase(); y=String(y==null?'':y).toLowerCase(); }
if(x<y) return -sortDir; if(x>y) return sortDir; return 0;
});
return rows;
}
// density slider only applies to Cards view — hide it in Table view so it's never a dead control
function syncDensVis(){ const d=document.querySelector('.dens'); if(d) d.classList.toggle('hide', view!=='cards'); }
function render(){
syncDensVis();
const lv=$('#listView');
if(view==='table'){
$('#tableView').classList.remove('hide'); $('#rolls').classList.add('hide'); if(lv) lv.classList.add('hide');
const rows=filtered(); const cols=visCols();
$('#count').textContent=`${rows.length} of ${DATA.length} rent rolls`;
$('#thead').innerHTML='<tr>'+cols.map(c=>`<th data-k="${c.k}" draggable="true" title="Drag to reorder · click to sort" class="dragcol${sortKey===c.k?(sortDir>0?' asc':' desc'):''}">${esc(c.l)}</th>`).join('')+'</tr>';
$('#tbody').innerHTML=rows.map(r=>`<tr class="rrow" data-id="${r.id}" style="cursor:pointer">`+cols.map(c=>{const cls=(c.t==='n'||c.t==='money'||c.t==='pct')?' class="n"':'';return `<td${cls}>${cell(r,c)}</td>`;}).join('')+'</tr>').join('');
const csig=cols.map(c=>c.k).join(',');
if(csig!==lastColSig){ lastColSig=csig; reinitResize(); }
else if(window.ColResize && window.ColResize.reattach){ window.ColResize.reattach(document.querySelector('#tableView table.g')); } // resize handles survive sort/search re-render (TK-10089)
} else if(view==='list' && lv){
$('#tableView').classList.add('hide'); $('#rolls').classList.add('hide'); lv.classList.remove('hide');
const rows=filtered(); const cols=visCols();
$('#count').textContent=`${rows.length} of ${DATA.length} rent rolls`;
lv.innerHTML=rows.map(r=>`<div class="lrow rrow" data-id="${r.id}" style="cursor:pointer"><div class="lnm">${cell(r,cols[0])}</div><div class="lmeta">`+cols.slice(1).map(c=>`<span class="lm"><span class="lk">${esc(c.l)}</span>${cell(r,c)}</span>`).join('')+`</div></div>`).join('');
} else {
$('#tableView').classList.add('hide'); $('#rolls').classList.remove('hide'); if(lv) lv.classList.add('hide');
const rows=filtered();
$('#count').textContent=`${rows.length} of ${DATA.length} rent rolls`;
$('#rolls').innerHTML=rows.map((r,idx)=>rollHtml(r.__raw,idx)).join('');
wireCards();
}
}
// Row click → detail modal with ALL fields (renders the full original card inside).
function openDetail(id){
const r=DATA.find(x=>+x.id===+id); if(!r) return;
const ov=$('#ov'), mb=$('#mbody');
mb.innerHTML=rollHtml(r.__raw,0).replace('glass roll','roll').replace(/margin-bottom:18px/,'');
ov.classList.add('on');
const roll=mb.querySelector('.roll');
if(roll){ recalc(roll); roll.querySelectorAll('input').forEach(inp=>inp.addEventListener('input',()=>recalc(roll))); }
}
/* ── column show/hide dropdown ── */
function buildColMenu(){
const groups={};
COLS.forEach(c=>{ (groups[c.g||'Other']=groups[c.g||'Other']||[]).push(c); });
$('#colmenu').innerHTML=Object.entries(groups).map(([g,cs])=>
`<div class="fgrp">${esc(g)}</div>`+cs.map(c=>`<label class="ftog"><input type="checkbox" data-ck="${c.k}" ${colVis(c.k)?'checked':''}><span>${esc(c.l)}</span></label>`).join('')
).join('')+
`<div class="mbrow"><button class="minibtn" id="cAll">All</button><button class="minibtn" id="cNone">None</button><button class="minibtn" id="cReset" title="Reset columns to the tight default — order, widths & visibility">↺ Reset</button></div>`;
}
/* ── sort <select> ── */
function buildSortSel(){
const sel=$('#sortsel'); const groups={};
COLS.forEach(c=>{ (groups[c.g||'Other']=groups[c.g||'Other']||[]).push(c); });
sel.innerHTML=Object.entries(groups).map(([g,cs])=>
`<optgroup label="${esc(g)}">`+cs.map(c=>`<option value="${c.k}">${esc(c.l)}</option>`).join('')+`</optgroup>`).join('');
if(Array.from(sel.options).some(o=>o.value===sortKey)) sel.value=sortKey;
}
/* ══════════════ event wiring ══════════════ */
$('#q').addEventListener('input',()=>{q=$('#q').value.trim();render();});
$('#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;localStorage.setItem('rentView',view);render();});
// header sort (delegated) — verified to work after a search since filtered() re-applies q
$('#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;const c=COLS.find(x=>x.k===k);sortDir=(c&&(c.t==='n'||c.t==='money'||c.t==='pct'||c.t==='dscr'||c.t==='date'))?-1:1;}
localStorage.setItem('rentSortKey',sortKey);localStorage.setItem('rentSortDir',sortDir);
const sel=$('#sortsel'); if(Array.from(sel.options).some(o=>o.value===sortKey))sel.value=sortKey;
render();});
$('#sortsel').addEventListener('change',()=>{sortKey=$('#sortsel').value;const c=COLS.find(x=>x.k===sortKey);
sortDir=(c&&(c.t==='n'||c.t==='money'||c.t==='pct'||c.t==='dscr'||c.t==='date'))?-1:1;
localStorage.setItem('rentSortKey',sortKey);localStorage.setItem('rentSortDir',sortDir);render();});
// density slider (Steve rule: every product grid gets sort + density) — drives the cards-view --cols
const densEl=$('#cols');
if(densEl){
const applyDens=v=>document.documentElement.style.setProperty('--cols',v);
densEl.addEventListener('input',()=>{ applyDens(densEl.value); localStorage.setItem('rentDensity',densEl.value); });
const sd=localStorage.getItem('rentDensity'); if(sd){ densEl.value=sd; } applyDens(densEl.value);
}
// drag-to-reorder columns
let dragKey=null;
$('#thead').addEventListener('dragstart',e=>{ const th=e.target.closest('th'); if(!th) return;
if(e.target.closest('.cr-handle')){ e.preventDefault(); return; }
dragKey=th.dataset.k; e.dataTransfer.effectAllowed='move'; try{e.dataTransfer.setData('text/plain',dragKey);}catch(_){}
th.classList.add('dragging'); });
$('#thead').addEventListener('dragover',e=>{ if(!dragKey) return; const th=e.target.closest('th'); if(!th||th.dataset.k===dragKey) return;
e.preventDefault(); e.dataTransfer.dropEffect='move';
const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
th.classList.toggle('dropR',after); th.classList.toggle('dropL',!after); });
$('#thead').addEventListener('dragleave',e=>{ const th=e.target.closest('th'); if(th) th.classList.remove('dropL','dropR'); });
$('#thead').addEventListener('drop',e=>{ if(!dragKey) return; const th=e.target.closest('th'); if(!th) return; e.preventDefault();
const tgt=th.dataset.k; if(tgt!==dragKey){
const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
syncColOrder(); const from=COLORDER.indexOf(dragKey); if(from>=0){
COLORDER.splice(from,1); let to=COLORDER.indexOf(tgt); if(after) to+=1; COLORDER.splice(to,0,dragKey);
saveColOrder(); lastColSig=''; render();
}
}
dragKey=null; });
$('#thead').addEventListener('dragend',()=>{ document.querySelectorAll('#thead th.dragging,#thead th.dropL,#thead th.dropR').forEach(x=>x.classList.remove('dragging','dropL','dropR')); dragKey=null; });
// row click → detail (ignore clicks on links/buttons/inputs so nested controls still work)
document.addEventListener('click',e=>{
const row=e.target.closest('.rrow');
if(row && !e.target.closest('a,button,input')){ openDetail(row.dataset.id); }
if(e.target.id==='ov') $('#ov').classList.remove('on');
});
// Columns ▾ dropdown open/close + toggles
$('#colbtn').addEventListener('click',e=>{ e.stopPropagation(); $('#coldrop').classList.toggle('open'); });
document.addEventListener('click',e=>{ if(!e.target.closest('#coldrop')) $('#coldrop').classList.remove('open'); });
$('#colmenu').addEventListener('change',e=>{const cb=e.target.closest('input[data-ck]');if(!cb)return;VISCOL[cb.dataset.ck]=cb.checked;saveCols();render();});
$('#colmenu').addEventListener('click',e=>{
if(e.target.id==='cAll'){ COLS.forEach(c=>VISCOL[c.k]=true);saveCols();buildColMenu();render(); }
else if(e.target.id==='cNone'){ COLS.forEach(c=>VISCOL[c.k]=false);saveCols();buildColMenu();render(); }
else if(e.target.id==='cReset'){ VISCOL={}; saveCols(); COLORDER=[]; saveColOrder();
try{Object.keys(localStorage).forEach(k=>{ if(k.indexOf('cr:'+location.pathname+':')===0) localStorage.removeItem(k); });}catch(e2){}
lastColSig=''; buildColMenu(); render(); }
});
/* ══════════════ boot ══════════════ */
fetch('/api/rent-rolls').then(r=>r.json()).then(d=>{
const rolls=d.rentRolls||[];
const host=$('#rolls');
if(!rolls.length){ host.innerHTML=`<div class="glass empty">No rent rolls parsed yet.<br><br>
Drop a rent-roll PDF into <code>data/rent-rolls/</code> and run <code>node scripts/rent-roll-ingest.js</code>.<br>
It parses the unit/rent table locally ($0) and it shows up here with a live DSCR calculator.<br><br>
<span style="font-size:11px">Rent rolls are confidential — they're read locally, never uploaded or served as PDFs.</span></div>`; return; }
DATA=rolls.map(flatRow);
$('#sub').textContent=`${rolls.length} rent roll${rolls.length>1?'s':''} · document-grade NOI → DSCR · local · $0`;
// restore saved view + sort prefs
const pv=localStorage.getItem('rentView'); if(pv==='grid'||pv==='table'||pv==='cards'||pv==='list'){ view=(pv==='grid'?'cards':pv); }
const sk=localStorage.getItem('rentSortKey'), sd=localStorage.getItem('rentSortDir');
if(sk&&COLS.some(c=>c.k===sk))sortKey=sk; if(sd==='1'||sd==='-1')sortDir=+sd;
buildSortSel(); buildColMenu();
document.querySelectorAll('#view button').forEach(x=>x.classList.toggle('on',x.dataset.v===view));
$('#toolbar').style.display='flex';
render();
}).catch(e=>{ $('#rolls').innerHTML='<div class="glass empty">Failed to load rent rolls: '+esc(e.message)+'</div>'; });
</script>
<script src="/col-resize.js" defer></script>
<script src="/global-search.js" defer></script>
<script src="/sort-bar.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
<script src="/crcp-theme.js" defer></script><!-- CRCP day/night toggle (auto-injects into header) -->
</body>
</html>