← back to Commercialrealestate

public/rent-rolls.html

140 lines

<!doctype html>
<html lang="en">
<head>
<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:#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;}
  .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:#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:#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:#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:#0a0d13;padding:2px 7px;border-radius:6px;color:var(--gold);}
</style>
</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 id="rolls"></div>
</div>
<script>
const M = n => (n==null||!isFinite(n)) ? '—' : '$'+Math.round(n).toLocaleString();
const PCT = n => (n==null||!isFinite(n)) ? '—' : n.toFixed(1)+'%';
// 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; }

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>${(r.property?.address)||r.source||'Rent roll'} ${r.isSample?'<span class="badge">demo</span>':''}</h2>
    <div class="addr">${[r.property?.city,r.property?.zip].filter(Boolean).join(' ')} · ${r.unitCount||units.length} units · parsed from ${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: '+uw.noiBasis+'. '):''}${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>`;
}

fetch('/api/rent-rolls').then(r=>r.json()).then(d=>{
  const rolls=d.rentRolls||[];
  const host=document.getElementById('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; }
  document.getElementById('sub').textContent=`${rolls.length} rent roll${rolls.length>1?'s':''} · document-grade NOI → DSCR · local · $0`;
  host.innerHTML=rolls.map(rollHtml).join('');
  document.querySelectorAll('.roll').forEach(roll=>{
    recalc(roll);
    roll.querySelectorAll('input').forEach(inp=>inp.addEventListener('input',()=>recalc(roll)));
  });
}).catch(e=>{ document.getElementById('rolls').innerHTML='<div class="glass empty">Failed to load rent rolls: '+e.message+'</div>'; });
</script>
  <script src="/col-resize.js" defer></script>
</body>
</html>