[object Object]

← back to Commercialrealestate

Add "Contractors for this deal" surface (TK-10488)

7deca8ff2073293c9a70c521ea5dd3a13ed95519 · 2026-08-12 10:34:00 -0700 · Steve Abrams

Surfaces CSLB-licensed GCs (A/B) + specialty trades (C-##) around a closed
CRE asset — framed for Frank the loan officer as vendors/borrowers who do the
TI build-out / value-add reno and are construction/bridge-financing prospects.

- public/contractors.html: browse-registry + match-a-deal modes; sort + density
  (localStorage); real drill-links (city/county/class) + URL deep-links.
- serve.js: same-origin proxy to the shared usre contractor API via
  CONTRACTORS_API_BASE (default :9913). /api/contractors, /api/contractors/match,
  /contractors. Unreachable upstream degrades to a 200 empty-state, never a hard fail.
- corner-nav.js: add "Contractors" to Brokers & Agents.

DATE EVERYTHING: each card renders license issue + expire dates (expired in red),
a "CSLB data as of {source_as_of} — verify at cslb.ca.gov" freshness/compliance
line, and updated_at (admin date+time); added issue/expire date sorts.
Good-standing status uses CLEAR (not Active) per the API contract.
Behind CRCP's Basic-auth gate; prod deploy left gated to Steve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 7deca8ff2073293c9a70c521ea5dd3a13ed95519
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 12 10:34:00 2026 -0700

    Add "Contractors for this deal" surface (TK-10488)
    
    Surfaces CSLB-licensed GCs (A/B) + specialty trades (C-##) around a closed
    CRE asset — framed for Frank the loan officer as vendors/borrowers who do the
    TI build-out / value-add reno and are construction/bridge-financing prospects.
    
    - public/contractors.html: browse-registry + match-a-deal modes; sort + density
      (localStorage); real drill-links (city/county/class) + URL deep-links.
    - serve.js: same-origin proxy to the shared usre contractor API via
      CONTRACTORS_API_BASE (default :9913). /api/contractors, /api/contractors/match,
      /contractors. Unreachable upstream degrades to a 200 empty-state, never a hard fail.
    - corner-nav.js: add "Contractors" to Brokers & Agents.
    
    DATE EVERYTHING: each card renders license issue + expire dates (expired in red),
    a "CSLB data as of {source_as_of} — verify at cslb.ca.gov" freshness/compliance
    line, and updated_at (admin date+time); added issue/expire date sorts.
    Good-standing status uses CLEAR (not Active) per the API contract.
    Behind CRCP's Basic-auth gate; prod deploy left gated to Steve.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/contractors.html | 340 ++++++++++++++++++++++++++++++++++++++++++++++++
 public/corner-nav.js    |   3 +-
 scripts/serve.js        |  59 +++++++++
 3 files changed, 401 insertions(+), 1 deletion(-)

diff --git a/public/contractors.html b/public/contractors.html
new file mode 100644
index 0000000..7bdf20f
--- /dev/null
+++ b/public/contractors.html
@@ -0,0 +1,340 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Contractors for this deal — CSLB licensed · CRCP</title>
+<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%F0%9F%94%A8%3C/text%3E%3C/svg%3E">
+<style>
+:root{--bg:#0e1116;--card:#161b22;--line:#2a313c;--ink:#e6edf3;--mut:#8b949e;--acc:#3fb950;--blue:#58a6ff;--gold:#ffa600;--violet:#bc8cff;--red:#f85149;--cols:4}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,Helvetica,Arial,sans-serif;font-size:13px}
+header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;flex-wrap:wrap;gap:10px;align-items:center}
+h1{font-size:17px;margin:0}h1 b{color:#fff}
+.badge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#0e1116;background:var(--gold);padding:3px 8px;border-radius:6px}
+.nav a{color:var(--blue);text-decoration:none;font-size:12px;margin-left:12px}.nav a:hover{color:var(--gold)}
+.shell{display:flex;align-items:flex-start}
+.rail{width:236px;flex:0 0 236px;border-right:1px solid var(--line);padding:12px;height:calc(100vh - 52px);overflow:auto;position:sticky;top:0}
+.rsec{border-bottom:1px solid var(--line);padding:9px 0}
+.rsec h4{margin:0 0 7px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut)}
+.chips{display:flex;flex-wrap:wrap;gap:5px}
+.chip{border:1px solid var(--line);border-radius:20px;padding:3px 9px;font-size:11px;cursor:pointer;background:var(--card);user-select:none}
+.chip.active{background:var(--blue);color:#0e1116;border-color:var(--blue);font-weight:600}
+.chip .ct{color:var(--mut);margin-left:4px;font-size:10px}.chip.active .ct{color:#0e1116}
+.rail input[type=text]{width:100%;background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
+.main{flex:1;padding:12px 16px;min-width:0}
+/* deal framing strip (Frank's angle) */
+.persona{background:linear-gradient(120deg,rgba(255,166,0,.10),rgba(88,166,255,.06));border:1px solid var(--line);border-radius:12px;padding:12px 15px;margin-bottom:13px;display:flex;flex-wrap:wrap;gap:11px 14px;align-items:center}
+.persona h2{font-size:15px;margin:0;color:#fff;font-weight:600}
+.persona .role{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--gold);border:1px solid rgba(255,166,0,.35);border-radius:20px;padding:2px 9px;white-space:nowrap}
+.persona .tip{flex-basis:100%;font-size:11.5px;color:var(--mut);margin:-2px 0 0;line-height:1.5}
+/* mode toggle: browse registry vs match-this-deal */
+.modebar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}
+.modebtn{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:8px 14px;font-size:12.5px;color:var(--mut);cursor:pointer}
+.modebtn.on{border-color:var(--gold);color:#fff;background:rgba(255,166,0,.10);font-weight:600}
+.toolbar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
+.search{flex:1;min-width:200px;background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:8px 11px;font-size:13px}
+select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
+input.dealf{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
+.count{color:var(--mut);font-size:12px}
+.prov{font-size:11px;color:var(--mut);margin:0 0 12px;line-height:1.5;border-left:2px solid var(--gold);padding-left:9px}
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px}
+.ccard{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:13px 14px;display:flex;flex-direction:column;gap:7px}
+.ccard .top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}
+.ccard .name{font-weight:600;font-size:13.5px;line-height:1.25}
+.pill{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;padding:2px 6px;border-radius:6px;white-space:nowrap;flex-shrink:0}
+.pill.active{background:rgba(63,185,80,.16);color:var(--acc)}
+.pill.inactive{background:rgba(248,81,73,.16);color:var(--red)}
+.pill.other{background:rgba(139,148,158,.16);color:var(--mut)}
+.dl{display:flex;flex-wrap:wrap;gap:5px 14px;font-size:12px;color:var(--ink)}
+.dl .i{color:var(--mut);display:inline-flex;align-items:center;gap:4px}
+.dl .i b{color:var(--ink);font-weight:600}
+.dl a.ddrill{color:inherit;text-decoration:none;border-bottom:1px dotted rgba(88,166,255,.35)}
+.dl a.ddrill:hover{color:var(--blue);border-bottom-color:var(--blue)}
+.classrow{display:flex;flex-wrap:wrap;gap:5px}
+.classtag{font-size:10px;background:rgba(188,140,255,.14);color:var(--violet);border:1px solid rgba(188,140,255,.3);border-radius:6px;padding:1px 6px;cursor:pointer;text-decoration:none}
+.classtag:hover{border-color:var(--violet)}
+.classtag.prim{background:rgba(255,166,0,.16);color:var(--gold);border-color:rgba(255,166,0,.4)}
+.datesrow{gap:5px 12px;font-size:11.5px;margin-top:-1px}
+.datesrow .i b{font-weight:600}
+.datesrow .expwarn{color:var(--red)}.datesrow .expwarn b{color:var(--red)}
+.fresh{font-size:10px;color:var(--mut);line-height:1.5;border-top:1px solid #20262f;padding-top:6px;margin-top:1px}
+.fresh b{color:var(--ink)}
+.fresh a{color:var(--blue);text-decoration:none}.fresh a:hover{text-decoration:underline}
+.provline{font-size:10px;color:var(--mut);border-top:1px solid #20262f;padding-top:6px;margin-top:1px;display:flex;align-items:center;gap:5px}
+.ccard a.f{color:var(--blue);text-decoration:none;font-size:12px}.ccard a.f:hover{text-decoration:underline}
+/* match-mode: class buckets */
+.bucket{margin-bottom:20px}
+.bucket h3{font-size:13px;margin:0 0 3px;color:#fff;display:flex;align-items:center;gap:8px}
+.bucket h3 .cls{font-size:10px;font-weight:800;letter-spacing:.5px;color:var(--gold);background:rgba(255,166,0,.14);border:1px solid rgba(255,166,0,.35);border-radius:6px;padding:2px 7px}
+.bucket .btitle{font-size:11.5px;color:var(--mut);margin:0 0 9px}
+.empty{color:var(--mut);padding:34px 12px;text-align:center;border:1px dashed var(--line);border-radius:12px;line-height:1.6}
+.empty b{color:var(--gold)}
+.finance{background:rgba(88,166,255,.08);border:1px solid rgba(88,166,255,.25);border-radius:8px;padding:2px 7px;font-size:10.5px;color:var(--blue);cursor:pointer;text-decoration:none}
+.finance:hover{border-color:var(--blue)}
+@media(max-width:760px){.rail{display:none}}
+</style></head><body>
+<header>
+  <h1><b>Contractors for this deal</b> · CSLB licensed</h1>
+  <span class="badge">🔨 vendor & borrower graph</span>
+  <div class="nav"><a href="/crcp.html">🎛️ Control Panel</a><a href="/deals-flow.html">📊 Deal Flow</a><a href="/loan-officers.html">🏦 Loan Officers</a><a href="/">Explorer</a></div>
+</header>
+<div class="shell">
+<aside class="rail">
+  <div class="rsec"><h4>License status</h4><div class="chips" id="fStatus"></div></div>
+  <div class="rsec"><h4>County</h4><div class="chips" id="fCounty"></div></div>
+  <div class="rsec"><h4>City</h4><div class="chips" id="fCity"></div></div>
+  <div class="rsec"><h4>CSLB class</h4><div class="chips" id="fClass"></div></div>
+  <div class="rsec" style="border-bottom:0"><h4>Registry note</h4>
+    <div style="font-size:11px;color:var(--mut);line-height:1.5">CSLB = California Contractors State License Board. <b>A</b> = General Engineering, <b>B</b> = General Building, <b>C-##</b> = specialty trades. License status is the CSLB record; verify at <a href="https://www.cslb.ca.gov/" target="_blank" rel="noopener noreferrer" style="color:var(--blue)">cslb.ca.gov ↗</a> before contracting.</div>
+  </div>
+</aside>
+<div class="main">
+  <div class="persona">
+    <h2>Who builds — and who might need to borrow</h2>
+    <span class="role">Frank · Loan Officer</span>
+    <p class="tip">Every closed CRE asset spins up work: TI build-outs for new tenants, value-add renovation, deferred-maintenance catch-up. The licensed <b>general contractors</b> and <b>trades</b> in that market are the vendors around the asset — and prime candidates for <b>construction / bridge financing</b>. Filter by the deal's county, city, or asset type to build a call list.</p>
+  </div>
+  <div class="modebar">
+    <button class="modebtn on" id="mBrowse" type="button">🔍 Browse the registry</button>
+    <button class="modebtn" id="mMatch" type="button">🎯 Match a deal</button>
+  </div>
+
+  <!-- Deal-match inputs (shown in match mode) -->
+  <div class="toolbar" id="matchInputs" style="display:none;border:1px solid var(--line);border-radius:10px;padding:11px 12px;background:var(--card)">
+    <span style="font-size:10px;font-weight:700;letter-spacing:.5px;color:var(--gold);text-transform:uppercase;white-space:nowrap">🎯 Deal criteria</span>
+    <input class="dealf" id="dCounty" placeholder="County (e.g. Los Angeles)" style="min-width:170px">
+    <input class="dealf" id="dCity" placeholder="City (e.g. Van Nuys)" style="min-width:150px">
+    <select id="dType">
+      <option value="">Asset type…</option>
+      <option value="office">Office / TI build-out</option>
+      <option value="retail">Retail / restaurant TI</option>
+      <option value="multifamily">Multifamily value-add</option>
+      <option value="industrial">Industrial / warehouse</option>
+      <option value="hospitality">Hotel / hospitality</option>
+      <option value="medical">Medical / dental</option>
+      <option value="ground-up">Ground-up construction</option>
+    </select>
+    <button class="modebtn" id="dGo" type="button" style="border-color:var(--gold);color:#fff">Find contractors →</button>
+  </div>
+
+  <div class="prov" id="prov">CSLB licensed-contractor registry, surfaced from the shared usre contractor API. Public license-board data. This is a lead / market-coverage aid — always re-verify a license on the CSLB site before doing business.</div>
+
+  <!-- Browse-mode toolbar (search + sort + density) -->
+  <div class="toolbar" id="browseTools">
+    <input class="search" id="q" placeholder="search business name, city, license #…">
+    <select id="sort"></select>
+    <label style="font-size:11px;color:var(--mut)">density <input type="range" id="dens" min="2" max="7" step="1"></label>
+    <span class="count" id="count"></span>
+  </div>
+
+  <div id="body"><div class="empty" style="opacity:.6">Loading contractors…</div></div>
+</div></div>
+
+<script>
+const $=s=>document.querySelector(s);
+const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
+const api=(u)=>fetch(u,{headers:{'Content-Type':'application/json'}}).then(r=>r.json());
+let MODE='browse';
+let DATA=[], q='', sortKey='business_name', sortDir=1;
+let dens=(function(){const v=parseInt(localStorage.getItem('cxDens'),10);return(v>=2&&v<=7)?v:4;})();
+const SORTS=[['business_name','Name A→Z'],['city','City A→Z'],['county','County A→Z'],['primary_class','Class'],['license_status','Status'],['license_no','License #'],['issue_date','Issued (oldest)'],['expire_date','Expires (soonest)']];
+const DATE_KEYS=new Set(['issue_date','expire_date']);
+$('#sort').innerHTML=SORTS.map(([k,l],i)=>`<option value="${k}"${i===0?' selected':''}>Sort: ${l}</option>`).join('');
+$('#dens').value=dens;
+
+// ── rail filter state (in-memory Sets) — each is a real, URL-addressable filter ──
+const F={status:new Set(),county:new Set(),city:new Set(),class:new Set()};
+const tog=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
+
+// upstream-down banner + graceful empty-state: the whole point of the wiring is that a not-yet-running
+// contractor API renders a clean, honest empty-state — never a broken page.
+function emptyState(reason,extra){
+  return `<div class="empty">🔨 <b>No contractors to show yet.</b><br>
+    ${reason||'The contractor registry API is not reachable from CRCP right now.'}<br>
+    <span style="font-size:11px;opacity:.8">${extra||'This panel is wired to the shared usre contractor API (CONTRACTORS_API_BASE). Once it is running + populated, licensed contractors for the selected market appear here automatically — no change needed.'}</span></div>`;
+}
+
+// ── card renderers ──
+function statusPill(s){const v=(s||'').toLowerCase();
+  // CSLB good-standing status is 'CLEAR' (the shared usre contractor API uses status=CLEAR, not 'Active').
+  if(v.includes('clear')||v.includes('active'))return `<span class="pill active">${esc(s)}</span>`;
+  if(v)return `<span class="pill inactive">${esc(s)}</span>`;
+  return `<span class="pill other">unknown</span>`;}
+// ── DATE EVERYTHING (Steve's hard rule + CSLB compliance): render license issue + expire dates on
+// every card, plus a "CSLB data as of {source_as_of}" freshness line that tells Frank to re-verify.
+function fmtDate(v){if(!v)return '';const d=new Date(v);if(isNaN(d))return esc(String(v));
+  return d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'});}
+function fmtDateTime(v){if(!v)return '';const d=new Date(v);if(isNaN(d))return esc(String(v));
+  return d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}
+function dateRow(c){
+  const iso=v=>{if(!v)return '';const d=new Date(v);return isNaN(d)?String(v):d.toISOString();};
+  const issue=c.issue_date?`<span class="i" title="License issued ${esc(iso(c.issue_date))}">🗓️ issued <b>${fmtDate(c.issue_date)}</b></span>`:'';
+  const exp=(()=>{if(!c.expire_date)return '';const d=new Date(c.expire_date);const past=(!isNaN(d)&&d.getTime()<Date.now());
+    return `<span class="i${past?' expwarn':''}" title="License expires ${esc(iso(c.expire_date))}">⏳ ${past?'expired':'expires'} <b>${fmtDate(c.expire_date)}</b></span>`;})();
+  return (issue||exp)?`<div class="dl datesrow">${issue}${exp}</div>`:'';
+}
+// Freshness / compliance line — the CSLB snapshot date this row came from, with the standing "verify" nudge.
+function freshLine(c){
+  const asof=c.source_as_of||c.cslb_last_update;
+  const upd=c.updated_at?` · row updated ${fmtDateTime(c.updated_at)}`:'';
+  return `<div class="fresh" title="Contractor license data is a periodic CSLB snapshot — always re-verify current status before contracting.">🕓 CSLB data as of <b>${asof?fmtDate(asof):'—'}</b> — <a href="https://www.cslb.ca.gov/" target="_blank" rel="noopener noreferrer">verify current status at cslb.ca.gov ↗</a>${upd}</div>`;
+}
+function classTags(c){
+  const prim=c.primary_class||''; const all=Array.isArray(c.classifications)?c.classifications:(c.classifications?String(c.classifications).split(/[,\s]+/):[]);
+  const titles=c.classification_titles||{};
+  const seen=new Set();
+  const tags=[];
+  const push=(cl)=>{cl=String(cl||'').trim();if(!cl||seen.has(cl))return;seen.add(cl);
+    const t=titles&&titles[cl]?` — ${titles[cl]}`:'';
+    tags.push(`<a class="classtag${cl===prim?' prim':''}" href="?class=${encodeURIComponent(cl)}" title="See all Class ${esc(cl)}${esc(t)}" data-drillf="class" data-drillv="${esc(cl)}">${esc(cl)}${cl===prim?' ★':''}</a>`);};
+  if(prim)push(prim); all.forEach(push);
+  return tags.length?`<div class="classrow">${tags.join('')}</div>`:'';
+}
+function card(c){
+  const county=c.county||''; const city=c.city||'';
+  const phone=c.phone?`<span class="i">☎ <a class="ddrill" href="tel:${esc(c.phone)}"><b>${esc(c.phone)}</b></a></span>`:'';
+  const cityD=city?`<span class="i" data-drillf="city" data-drillv="${esc(city)}">📍 <a class="ddrill" href="?city=${encodeURIComponent(city)}" title="All contractors in ${esc(city)}">${esc(city)}</a></span>`:'';
+  const countyD=county?`<span class="i" data-drillf="county" data-drillv="${esc(county)}">🗺️ <a class="ddrill" href="?county=${encodeURIComponent(county)}" title="All contractors in ${esc(county)} County">${esc(county)}</a></span>`:'';
+  const lic=c.license_no?`<span class="i">🪪 <a class="ddrill" href="https://www.cslb.ca.gov/OnlineServices/CheckLicenseII/LicenseDetail.aspx?LicNum=${encodeURIComponent(String(c.license_no).replace(/\D/g,''))}" target="_blank" rel="noopener noreferrer" title="Verify on CSLB ↗"><b>#${esc(c.license_no)}</b></a></span>`:'';
+  // Frank's angle: a construction-financing prospect chip that drills to Loan Officers / FHA products.
+  const fin=`<a class="finance" href="/loan-officers.html" title="Construction / bridge financing prospect — route to lending">💵 financing prospect</a>`;
+  return `<div class="ccard">
+    <div class="top"><div class="name">${esc(c.business_name||'—')}</div>${statusPill(c.license_status)}</div>
+    ${classTags(c)}
+    <div class="dl">${lic}${phone}${cityD}${countyD}</div>
+    ${dateRow(c)}
+    <div style="margin-top:2px">${fin}</div>
+    ${freshLine(c)}
+    <div class="provline">📋 CSLB registry · usre contractor API</div>
+  </div>`;
+}
+
+// ── BROWSE MODE — server-side filter via the proxy, client-side sort/search/density ──
+function passLocal(c){
+  if(F.status.size&&!F.status.has(c.license_status||'—'))return false;
+  if(F.county.size&&!F.county.has(c.county||'—'))return false;
+  if(F.city.size&&!F.city.has(c.city||'—'))return false;
+  if(F.class.size){const all=new Set([c.primary_class,...(Array.isArray(c.classifications)?c.classifications:[])].filter(Boolean).map(String));
+    let hit=false;F.class.forEach(v=>{if(all.has(v))hit=true;});if(!hit)return false;}
+  if(q){const t=q.toLowerCase();return [c.business_name,c.city,c.county,c.license_no,c.primary_class].some(v=>v!=null&&String(v).toLowerCase().includes(t));}
+  return true;
+}
+function filteredSorted(){
+  let rows=DATA.filter(passLocal);
+  rows.sort((a,b)=>{let x=a[sortKey],y=b[sortKey];const xm=(x==null||x===''),ym=(y==null||y==='');
+    if(xm&&ym)return 0;if(xm)return 1;if(ym)return -1;
+    if(DATE_KEYS.has(sortKey)){const dx=new Date(x).getTime(),dy=new Date(y).getTime();
+      x=isNaN(dx)?Infinity:dx;y=isNaN(dy)?Infinity:dy;}
+    else if(!isNaN(+x)&&!isNaN(+y)&&sortKey==='license_no'){x=+x;y=+y;}
+    else{x=String(x).toLowerCase();y=String(y).toLowerCase();}
+    return x<y?-1*sortDir:x>y?sortDir:0;});
+  return rows;
+}
+function chip(counts,attr,lim){return Object.entries(counts).sort((a,b)=>b[1]-a[1]).slice(0,lim||99)
+  .map(([k,c])=>`<span class="chip${F[attr].has(k)?' active':''}" data-${attr}="${esc(k)}">${esc(k)}<span class="ct">${c}</span></span>`).join('');}
+function buildRail(){
+  const sc={},cc={},ci={},cl={};
+  DATA.forEach(c=>{const s=c.license_status||'—';sc[s]=(sc[s]||0)+1;
+    const co=c.county||'—';cc[co]=(cc[co]||0)+1; const cy=c.city||'—';ci[cy]=(ci[cy]||0)+1;
+    [c.primary_class,...(Array.isArray(c.classifications)?c.classifications:[])].filter(Boolean).forEach(x=>{cl[x]=(cl[x]||0)+1;});});
+  $('#fStatus').innerHTML=chip(sc,'status',8)||'<span style="font-size:11px;color:var(--mut)">—</span>';
+  $('#fCounty').innerHTML=chip(cc,'county',12)||'<span style="font-size:11px;color:var(--mut)">—</span>';
+  $('#fCity').innerHTML=chip(ci,'city',14)||'<span style="font-size:11px;color:var(--mut)">—</span>';
+  $('#fClass').innerHTML=chip(cl,'class',20)||'<span style="font-size:11px;color:var(--mut)">—</span>';
+}
+function renderBrowse(down,reason){
+  document.documentElement.style.setProperty('--cols',dens);
+  if(down){$('#body').innerHTML=emptyState(reason);$('#count').textContent='upstream not reachable';return;}
+  const rows=filteredSorted();
+  $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()} shown`;
+  $('#body').innerHTML=rows.length?`<div class="grid">${rows.slice(0,600).map(card).join('')}</div>`
+    :emptyState('No licensed contractors match these filters.','Loosen the county / city / class filters, or clear the search.');
+}
+// Pull the registry through our own proxy (server injects CONTRACTORS_API_BASE). Server-side county/city
+// let the upstream do the heavy filter; we sort/search/paginate client-side.
+async function loadBrowse(){
+  // CSLB good-standing status value is 'CLEAR' (not 'Active') per the shared usre contractor API contract.
+  const u=new URLSearchParams();u.set('status','CLEAR');u.set('limit','1000');
+  if(F.county.size===1)u.set('county',[...F.county][0]);
+  if(F.city.size===1)u.set('city',[...F.city][0]);
+  if(F.class.size===1)u.set('class',[...F.class][0]);
+  let d;try{d=await api('/api/contractors?'+u.toString());}catch(e){renderBrowse(true,'CRCP could not reach the contractor API (network error).');return;}
+  if(d&&d.upstream_down){DATA=[];buildRail();renderBrowse(true,`The contractor API (<code>${esc(d.upstream||'')}</code>) is not up yet${d.reason?` — ${esc(d.reason)}`:''}.`);return;}
+  DATA=Array.isArray(d.results)?d.results:[];
+  buildRail();renderBrowse(false);
+}
+
+// ── MATCH MODE — deal criteria → class-bucketed matches for Frank's call list ──
+const CTYPE_LABEL={office:'Office / tenant-improvement build-out',retail:'Retail / restaurant tenant improvement',multifamily:'Multifamily value-add renovation',industrial:'Industrial / warehouse',hospitality:'Hotel / hospitality',medical:'Medical / dental office','ground-up':'Ground-up construction'};
+async function loadMatch(){
+  const county=$('#dCounty').value.trim(), city=$('#dCity').value.trim(), ctype=$('#dType').value;
+  const u=new URLSearchParams();u.set('mode','deal');
+  if(county)u.set('county',county);if(city)u.set('city',city);if(ctype)u.set('ctype',ctype);
+  $('#body').innerHTML='<div class="empty" style="opacity:.6">Matching contractors to the deal…</div>';
+  let d;try{d=await api('/api/contractors/match?'+u.toString());}catch(e){$('#body').innerHTML=emptyState('CRCP could not reach the contractor API (network error).');return;}
+  if(d&&d.upstream_down){$('#body').innerHTML=emptyState(`The contractor API (<code>${esc(d.upstream||'')}</code>) is not up yet${d.reason?` — ${esc(d.reason)}`:''}.`,'When it is running, the class-bucketed match list for this deal renders here — General Engineering (A), General Building (B), and the specialty trades (C-##) that fit the asset type.');return;}
+  const matches=(d&&d.matches&&typeof d.matches==='object')?d.matches:{};
+  const keys=Object.keys(matches);
+  document.documentElement.style.setProperty('--cols',dens);
+  const crit=[county&&`${esc(county)} County`,city&&esc(city),ctype&&CTYPE_LABEL[ctype]].filter(Boolean).join(' · ')||'all markets';
+  if(!keys.length){$('#body').innerHTML=emptyState(`No contractor matches for <b>${crit}</b>.`,'Try a broader county / drop the asset type, or the registry may not yet cover this market.');return;}
+  const CLASS_TITLE={A:'General Engineering — site, structural, heavy civil',B:'General Building — the prime for most TI + reno'};
+  let html=`<div style="font-size:12px;color:var(--mut);margin-bottom:14px">Contractors matched to <b style="color:#fff">${crit}</b> — bucketed by CSLB class. Start with <b style="color:var(--gold)">B (General Building)</b> for most build-outs; the C-## trades round out the job. Each is a construction-financing prospect.</div>`;
+  keys.forEach(cls=>{
+    const list=Array.isArray(matches[cls])?matches[cls]:[];
+    const title=CLASS_TITLE[cls]||(list[0]&&list[0].classification_titles&&list[0].classification_titles[cls])||'Specialty trade';
+    html+=`<div class="bucket"><h3><span class="cls">${esc(cls)}</span> ${esc(title)} <span style="font-size:11px;color:var(--mut);font-weight:400">(${list.length})</span></h3>
+      <div class="grid">${list.length?list.map(card).join(''):'<div class="empty" style="grid-column:1/-1">none in this class for this market</div>'}</div></div>`;
+  });
+  $('#body').innerHTML=html;
+}
+
+// ── mode switching ──
+function setMode(m){
+  MODE=m;
+  $('#mBrowse').classList.toggle('on',m==='browse');
+  $('#mMatch').classList.toggle('on',m==='match');
+  $('#matchInputs').style.display=m==='match'?'flex':'none';
+  $('#browseTools').style.display=m==='browse'?'flex':'none';
+  $('.rail')&&(document.querySelector('.rail').style.display=(m==='browse')?'':'');
+  if(m==='browse'){renderBrowse(DATA.length?false:undefined);if(!DATA.length)loadBrowse();}
+  else{$('#body').innerHTML='<div class="empty">🎯 Enter the deal’s county / city / asset type above, then <b>Find contractors →</b>.</div>';}
+}
+$('#mBrowse').onclick=()=>setMode('browse');
+$('#mMatch').onclick=()=>setMode('match');
+$('#dGo').onclick=loadMatch;
+['dCounty','dCity'].forEach(id=>$('#'+id).addEventListener('keydown',e=>{if(e.key==='Enter')loadMatch();}));
+
+// ── browse toolbar wiring ──
+$('#q').addEventListener('input',()=>{q=$('#q').value.trim();renderBrowse(false);});
+$('#sort').addEventListener('change',()=>{sortKey=$('#sort').value;sortDir=1;renderBrowse(false);});
+$('#dens').addEventListener('input',()=>{dens=+$('#dens').value;localStorage.setItem('cxDens',dens);renderBrowse(false);});
+
+// rail chips: multi-select. County/city/class with exactly one selection re-query the upstream
+// (server-side filter); otherwise filter the already-loaded set client-side.
+function railClick(attr,el){el.addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;
+  tog(F[attr],c.dataset[attr]);c.classList.toggle('active');
+  // a single county/city/class selection is worth a fresh, upstream-scoped pull; else local filter.
+  if((attr==='county'||attr==='city'||attr==='class')&&F[attr].size<=1){loadBrowse();}
+  else renderBrowse(false);});}
+railClick('status',$('#fStatus'));railClick('county',$('#fCounty'));railClick('city',$('#fCity'));railClick('class',$('#fClass'));
+
+// card drill-links (city/county/class) — filter in place; also real shareable hrefs.
+$('#body').addEventListener('click',e=>{
+  if(e.metaKey||e.ctrlKey||e.shiftKey||e.button===1)return;
+  const a=e.target.closest('a.ddrill[href^="tel"],a.ddrill[target="_blank"],a.f,a.finance,a.classtag[target]');
+  if(a&&a.getAttribute('target')==='_blank')return; // external verify links navigate
+  const d=e.target.closest('[data-drillf]');
+  if(d){const f=d.dataset.drillf,v=d.dataset.drillv;if(F[f]!==undefined){e.preventDefault();
+    if(MODE!=='browse'){setMode('browse');}
+    if(!F[f].has(v)){tog(F[f],v);}
+    if((f==='county'||f==='city'||f==='class')&&F[f].size<=1)loadBrowse();else renderBrowse(false);}}
+});
+
+// URL deep-links: /contractors?county=&city=&class=&q= apply on load (shareable card drills).
+(function initFromUrl(){const u=new URLSearchParams(location.search);let any=false;
+  ['county','city','class','status'].forEach(k=>{u.getAll(k).forEach(v=>{F[k].add(v);any=true;});});
+  if(u.get('q')){q=u.get('q');$('#q').value=q;any=true;}
+  if(u.get('mode')==='match'){setMode('match');return;}
+  setMode('browse');
+})();
+</script>
+<script src="/corner-nav.js" defer></script>
+</body></html>
diff --git a/public/corner-nav.js b/public/corner-nav.js
index 3e81a6c..403e281 100644
--- a/public/corner-nav.js
+++ b/public/corner-nav.js
@@ -20,7 +20,8 @@
       ['deals.html', '◆ Deal Intelligence'], ['deals-flow.html', '🔀 Deal Flow'] ] },
     { title: 'Brokers & Agents', items: [
       ['crcp.html', '🎛️ Control Panel'], ['broker-grid.html', '▦ Broker Grid'],
-      ['brokers.html', '🕸️ Broker Map'], ['loan-officers.html', '🏦 Loan Officers'] ] },
+      ['brokers.html', '🕸️ Broker Map'], ['loan-officers.html', '🏦 Loan Officers'],
+      ['contractors.html', '🔨 Contractors'] ] },
     { title: 'Licensing & Social', items: [
       ['licensed.html', '📇 Licensed Directory'], ['licensed-agents.html', '🧾 Licensed Agents'],
       ['linkedin.html', '💼 LinkedIn'] ] },
diff --git a/scripts/serve.js b/scripts/serve.js
index d217ff3..3b5b6b6 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -1657,6 +1657,65 @@ app.get('/api/fha-loans', (req, res) => {
   } catch (e) { res.status(502).json({ error: String(e.message).split('\n')[0] }); }
 });
 
+// ── Contractors-for-this-deal (TK-10488) ────────────────────────────────────────────
+// Frank the loan officer's angle: the licensed general contractors + relevant trades around a
+// closed CRE asset (or a county/city/asset-type) are the vendors/borrowers who do the TI build-out
+// and value-add reno — and who may need construction / bridge financing. This surfaces the CSLB
+// contractor registry (built in parallel as the shared usre contractor API) into CRCP's deal view.
+//
+// We proxy the shared API server-side so (1) the browser hits our own origin (no CORS / no leaked
+// base URL), (2) the upstream base is a single env knob, and (3) an unreachable/not-yet-running
+// upstream degrades to a clean, honest empty-state instead of a hard failure. The contract:
+//   GET {base}/api/contractors?county=&city=&class=&q=&status=Active&limit=
+//        -> {count, results:[{license_no,business_name,city,county,phone,license_status,
+//                             classifications,primary_class,classification_titles}]}
+//   GET {base}/api/contractors/match?mode=deal&county=&city=&ctype=
+//        -> {criteria, matches:{"A":[...],"B":[...],"C-10":[...]}}
+const CONTRACTORS_API_BASE = (process.env.CONTRACTORS_API_BASE || 'http://localhost:9913').replace(/\/+$/, '');
+// Small helper: fetch upstream with a timeout, always resolve to a shaped object so the caller
+// (and the front-end) never sees a thrown/undefined — an empty result set is the honest default.
+async function fetchContractors(pathQs, timeoutMs = 8000) {
+  const url = CONTRACTORS_API_BASE + pathQs;
+  const ac = new AbortController();
+  const timer = setTimeout(() => ac.abort(), timeoutMs);
+  try {
+    const r = await fetch(url, { signal: ac.signal, headers: { Accept: 'application/json' } });
+    if (!r.ok) return { ok: false, status: r.status, upstream: CONTRACTORS_API_BASE, error: 'upstream ' + r.status };
+    const j = await r.json();
+    return { ok: true, upstream: CONTRACTORS_API_BASE, data: j };
+  } catch (e) {
+    // Network refused / DNS / abort-on-timeout — the upstream simply isn't up yet. Not an error page.
+    return { ok: false, upstream: CONTRACTORS_API_BASE, unreachable: true, error: String(e && e.name === 'AbortError' ? 'timeout' : (e && e.message || e)).split('\n')[0] };
+  }
+}
+// Whitelist + clip the pass-through query params so we never forward arbitrary junk upstream.
+function contractorQs(q, keys) {
+  const u = new URLSearchParams();
+  for (const k of keys) { const v = q[k]; if (v != null && String(v).trim() !== '') u.set(k, String(v).slice(0, 120)); }
+  return u.toString() ? '?' + u.toString() : '';
+}
+// Listing/search proxy. On upstream-down: 200 with {count:0, results:[], upstream_down:true} so the
+// page renders its empty-state (the wiring is what matters) rather than throwing.
+app.get('/api/contractors', async (req, res) => {
+  const qs = contractorQs(req.query, ['county', 'city', 'class', 'q', 'status', 'limit']);
+  const r = await fetchContractors('/api/contractors' + qs);
+  if (!r.ok) return res.json({ count: 0, results: [], upstream_down: true, upstream: r.upstream, reason: r.error });
+  const d = r.data || {};
+  res.json({ count: d.count != null ? d.count : (Array.isArray(d.results) ? d.results.length : 0),
+             results: Array.isArray(d.results) ? d.results : [], upstream: r.upstream });
+});
+// Deal-match proxy — the buckets keyed by CSLB class (A / B / C-10 …) for a given deal's
+// county/city/asset-type. On upstream-down: 200 with empty matches so the panel shows its empty-state.
+app.get('/api/contractors/match', async (req, res) => {
+  const qs = contractorQs(req.query, ['mode', 'county', 'city', 'ctype']);
+  const r = await fetchContractors('/api/contractors/match' + (qs || '?mode=deal'));
+  if (!r.ok) return res.json({ criteria: null, matches: {}, upstream_down: true, upstream: r.upstream, reason: r.error });
+  const d = r.data || {};
+  res.json({ criteria: d.criteria || null, matches: (d.matches && typeof d.matches === 'object') ? d.matches : {}, upstream: r.upstream });
+});
+// Clean URL for the panel page (matches the /fha-loans, /property pattern).
+app.get(['/contractors'], (req, res) => res.sendFile(path.join(ROOT, 'public', 'contractors.html')));
+
 // Broker-history pilot — transaction history + listing-agent enrichment for the top ranked properties.
 // Source: LA County Assessor (recording_date = last deed), public MLS search results (listing agent).
 // Broker = CURRENT listing agent only; historical sale brokers are not in any free public record

← c98c3fe auto-data-snapshot: 2026-08-12T09:25:42 (2 data files) — dat  ·  back to Commercialrealestate  ·  crcp: send scoped Basic auth to usre contractor API (TK-1048 9bc8bba →