← back to Commercialrealestate

public/loan-officers.html

172 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='%230c0f15'/%3E%3Ctext x='8' y='12' font-size='9' text-anchor='middle' fill='%23C8A24B'%3E%F0%9F%91%A4%3C/text%3E%3C/svg%3E">
<title>Loan Officers — NMLS-licensed (compliance-gated)</title>
<link rel="stylesheet" href="/lending-grid.css">
<style>
  .compliance{margin:16px 20px 0;padding:14px 18px;background:rgba(210,153,34,.08);border:1px solid rgba(210,153,34,.4);border-radius:11px;color:var(--ink);font-size:13px;line-height:1.5;max-width:980px;}
  .compliance b{color:var(--warn);}
  .compliance a{color:var(--blue);} .compliance .lk{display:inline-block;margin-top:8px;margin-right:14px;}
  .empty{margin:20px;padding:40px 24px;text-align:center;color:var(--mut);border:1px dashed var(--line);border-radius:12px;}
</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>
<header>
  <h1><b>👤 Loan Officers</b></h1>
  <div class="nav"><a href="/lending.html">← Lending</a><a href="/lenders.html">🏦 Lenders</a><a href="/credit-unions.html">🤝 Credit Unions</a><a href="/mortgage-market.html">📊 Market</a></div>
  <span class="spacer"></span>
  <input class="search" id="q" type="text" placeholder="search all fields — name, company, NMLS #… (space = AND)">
  <div class="toggle" id="view"><button data-v="table" class="on">▦ List</button><button data-v="grid">▤ Grid</button></div>
  <select id="sortsel" title="Sort"></select>
  <label id="denswrap" title="Card density — grid view">⤢<input type="range" id="density" min="1" max="10" step="1"></label>
  <button class="csvbtn" id="csv" title="Export current rows to CSV">⬇ CSV</button>
  <span class="count" id="count">…</span>
</header>
<div class="compliance">
  <b>⚖️ Compliance-gated dataset.</b> Individual loan-officer records are added <b>only</b> from an
  officially-permitted source or authorized manual entry — <b>never scraped</b> from NMLS Consumer Access,
  LinkedIn, or any login-gated database. Personal mobile numbers, personal emails, and home addresses are
  never stored. Look up a licensed originator at the official source:
  <div>
    <a class="lk" href="https://www.nmlsconsumeraccess.org/" target="_blank" rel="noopener noreferrer">🔎 NMLS Consumer Access ↗</a>
    <a class="lk" href="https://dfpi.ca.gov/" target="_blank" rel="noopener noreferrer">🔎 CA DFPI licensee search ↗</a>
  </div>
</div>
<div class="shell">
<aside class="rail">
  <div id="railFacets"></div>
  <div class="rsec"><h4>Fields / columns — show / hide</h4>
    <div class="fieldtoggles" id="fFields"></div>
    <div style="margin-top:8px"><button class="minibtn" id="fAll">All</button><button class="minibtn" id="fNone">None</button><button class="minibtn" id="fReset">↺ Reset</button></div>
  </div>
</aside>
<div class="wrap">
  <div class="databar"><span class="pubtag">Public data</span><span id="demoNote">NMLS Consumer Access — manual/authorized entry only</span></div>
  <div id="emptyState" class="empty hide">No loan-officer records on file yet. Records appear here once added from a permitted source or authorized entry — each with a public NMLS license number and a source link. Use the official lookups above in the meantime.</div>
  <div id="tableView" class="tblwrap"><table class="g" data-no-sort><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
  <div id="gridView" class="grid hide"></div>
</div>
</div>
<div id="ov"><div id="modal"><button class="x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
<script>
window.GRID_CONFIG = {
  ns: 'crcpLO', noun: 'loan officers', nameKey: 'name', idKey: 'id',
  title: 'NMLS-licensed loan officer',
  api: '/api/lending/loan-officers', dataKey: 'officers',
  sortKey: 'name', sortDir: 1,
  defaultCols: ['name', 'company', 'nmls_id', 'business_phone', 'business_email', 'city', 'source'],
  cols: [
    { k: 'name', l: 'Name', t: 's', g: 'Identity' },
    { k: 'company', l: 'Company', t: 's', g: 'Identity' },
    { k: 'nmls_id', l: 'NMLS #', t: 's', g: 'Identity' },
    { k: 'title', l: 'Title', t: 's', g: 'Identity', def: 0 },
    { k: 'business_phone', l: 'Phone', t: 'tel', g: 'Contact' },
    { k: 'business_email', l: 'Email', t: 'mail', g: 'Contact' },
    { k: 'city', l: 'City', t: 's', g: 'Location' },
    { k: 'state', l: 'State', t: 's', g: 'Location', def: 0 },
    { k: 'source', l: 'Source', t: 'src', g: 'Evidence' },
    { k: 'retrieved_at', l: 'Retrieved', t: 'date', g: 'Evidence', def: 0 },
  ],
  facets: [
    { k: 'company', l: 'Company', limit: 16 },
    { k: 'city', l: 'City', limit: 16 },
  ],
};
</script>
<script>
/* Contact enrichment (TK-10703): the loan-officers dataset is compliance-gated and may store
   no contact of its own, so ALWAYS surface a business phone + email by joining each officer
   client-side against /api/brokers/all by name (and firm when it disambiguates) — the same
   brokerFor() approach used by just-listed.html. Blanks-only fill: never overwrites a real
   value the record already carries, never fabricates one where no match exists (graceful — the
   grid renders "—" and the Source link remains the path to full contact). Runs by wrapping
   fetch for the officers API only, so the shared /lending-grid.js consumes enriched rows. */
(function () {
  var API = '/api/lending/loan-officers';
  var norm = function (s) { return (s == null ? '' : String(s)).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); };
  var brokerIndex = null; // lazily built { byName: Map, byNameFirm: Map }
  function buildIndex(brokers) {
    var byName = new Map(), byNameFirm = new Map();
    (brokers || []).forEach(function (b) {
      if (!b || !b.name) return;
      var contact = { phone: b.phone || '', email: b.email || '' };
      if (!contact.phone && !contact.email) return;
      var n = norm(b.name);
      // byName: keep first, but flag ambiguity so we don't guess when a name is shared.
      if (byName.has(n)) { byName.get(n)._ambiguous = true; } else { byName.set(n, contact); }
      var nf = n + '|' + norm(b.firm);
      if (!byNameFirm.has(nf)) byNameFirm.set(nf, contact);
    });
    return { byName: byName, byNameFirm: byNameFirm };
  }
  function matchFor(o) {
    if (!brokerIndex || !o || !o.name) return null;
    var n = norm(o.name);
    var nf = n + '|' + norm(o.company);
    var m = brokerIndex.byNameFirm.get(nf);          // name + firm — highest confidence
    if (m) return m;
    var bn = brokerIndex.byName.get(n);              // name-only — only if unambiguous
    if (bn && !bn._ambiguous) return bn;
    return null;
  }
  function enrich(officers) {
    (officers || []).forEach(function (o) {
      if (!o) return;
      if ((o.business_phone && o.business_email)) return; // already has both
      var m = matchFor(o);
      if (!m) return;
      if (!o.business_phone && m.phone) o.business_phone = m.phone;
      if (!o.business_email && m.email) o.business_email = m.email;
    });
    return officers;
  }
  var of = window.fetch;
  window.fetch = function (u, opt) {
    var url = (typeof u === 'string') ? u : (u && u.url) || '';
    var isOfficers = url.indexOf(API) >= 0;
    var p = of.call(this, u, opt);
    if (!isOfficers) return p;
    return p.then(function (res) {
      if (!res || !res.ok) return res;
      return res.clone().json().then(function (d) {
        if (!d || !Array.isArray(d.officers) || !d.officers.length) return res; // nothing to enrich
        var withBoth = d.officers.filter(function (o) { return o && o.business_phone && o.business_email; }).length;
        if (withBoth === d.officers.length) return res; // all complete already
        var bp = brokerIndex ? Promise.resolve(brokerIndex)
          : of.call(window, '/api/brokers/all').then(function (r) { return r.json(); })
              .then(function (bd) { brokerIndex = buildIndex((bd && bd.brokers) || []); return brokerIndex; })
              .catch(function () { brokerIndex = { byName: new Map(), byNameFirm: new Map() }; return brokerIndex; });
        return bp.then(function () {
          enrich(d.officers);
          return new Response(JSON.stringify(d), { status: res.status, statusText: res.statusText, headers: { 'Content-Type': 'application/json' } });
        });
      }).catch(function () { return res; });
    });
  };
})();
</script>
<script src="/column-manager.js" defer></script>
<script src="/lending-grid.js" defer></script>
<script src="/col-resize.js" defer></script>
<script>
// Show the empty-state notice when there are zero records (compliance-gated dataset starts empty).
fetch('/api/lending/loan-officers', { credentials:'same-origin' }).then(r=>r.json()).then(d=>{
  if(!(d.officers||[]).length){ document.getElementById('emptyState').classList.remove('hide'); document.getElementById('tableView').classList.add('hide'); }
}).catch(()=>{});
</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>