[object Object]

← back to Commercialrealestate

loan-officers: always show phone+email (TK-10703)

a7b190400f33c933e7d05d4b58c5e63d749686e6 · 2026-08-18 18:26:13 -0700 · Steve Abrams

Files touched

Diff

commit a7b190400f33c933e7d05d4b58c5e63d749686e6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 18:26:13 2026 -0700

    loan-officers: always show phone+email (TK-10703)
---
 public/loan-officers.html | 77 +++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 75 insertions(+), 2 deletions(-)

diff --git a/public/loan-officers.html b/public/loan-officers.html
index 947bca3..579d468 100644
--- a/public/loan-officers.html
+++ b/public/loan-officers.html
@@ -64,15 +64,16 @@ window.GRID_CONFIG = {
   title: 'NMLS-licensed loan officer',
   api: '/api/lending/loan-officers', dataKey: 'officers',
   sortKey: 'name', sortDir: 1,
-  defaultCols: ['name', 'company', 'nmls_id', 'city', 'source'],
+  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: 'business_phone', l: 'Business phone', t: 'tel', g: 'Contact', def: 0 },
     { k: 'source', l: 'Source', t: 'src', g: 'Evidence' },
     { k: 'retrieved_at', l: 'Retrieved', t: 'date', g: 'Evidence', def: 0 },
   ],
@@ -82,6 +83,78 @@ window.GRID_CONFIG = {
   ],
 };
 </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>

← 352846c licensed-agents: always show agent phone+email (TK-10703)  ·  back to Commercialrealestate  ·  contractors: always show email alongside phone (TK-10703) 2681078 →