← 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
A public/contractors.htmlM public/corner-nav.jsM scripts/serve.js
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,'&').replace(/</g,'<').replace(/"/g,'"');
+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 →