← back to Commercialrealestate

public/crcp.html

944 lines

<!doctype html>
<html lang="en">
<head>
<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>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='dark'?'dark':'light');}catch(e){document.documentElement.setAttribute('data-theme','light');}})();</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='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%23c8a24b'%3E%24%3C/text%3E%3C/svg%3E">
<title>CRCP — Commercial Real Estate Control Panel</title>
<style>
  /* Emulates CNCP (localhost:3333): dark panels, gold accent, status colors. */
  /* Dark fallbacks (shared crcp-theme.css overrides these per html[data-theme] after load) */
  :root{ --bg:#0d1117; --panel:#1c232e; --panel2:#171d26; --fg:#f2f6fb; --muted:#8A93A2; --gold:#C8A24B;
         --active:#70AD47; --staged:#E0A33E; --draft:#5B9BD5; --archived:#5A6472; --del:#E74C3C; --line:#33404f; }
  *{box-sizing:border-box;} body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;}
  header{display:flex;align-items:center;gap:14px;padding:14px 22px;border-bottom:1px solid var(--line);background:var(--bg);position:sticky;top:0;z-index:10;}
  header h1{margin:0;font-size:18px;letter-spacing:.5px;} header h1 b{color:var(--gold);}
  .live{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);}
  .pulse{width:9px;height:9px;border-radius:50%;background:var(--active);box-shadow:0 0 0 0 rgba(112,173,71,.6);animation:p 1.6s infinite;}
  @keyframes p{0%{box-shadow:0 0 0 0 rgba(112,173,71,.6)}70%{box-shadow:0 0 0 10px rgba(112,173,71,0)}100%{box-shadow:0 0 0 0 rgba(112,173,71,0)}}
  header .nav{margin-left:auto;display:flex;gap:14px;} header .nav a{color:var(--draft);text-decoration:none;font-size:13px;} header .nav a:hover{color:var(--gold);}
  .wrap{padding:18px 22px 40px;max-width:1320px;margin:0 auto;}
  .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(var(--tilemin,150px),1fr));gap:14px;margin-bottom:18px;}
  /* collapse-all-until-activated */
  .panel h3{cursor:pointer;user-select:none;}
  .panel h3 .caret{color:var(--gold);margin-right:6px;display:inline-block;transition:transform .15s;}
  .panel.collapsed h3 .caret{transform:rotate(-90deg);}
  .panel.collapsed > *:not(h3){display:none;}
  .denslider{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);}
  .denslider input{width:90px;accent-color:var(--gold);}
  .expandall{background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:5px 10px;font-size:12px;cursor:pointer;}
  .tile{background:var(--panel);border:1px solid var(--line);border-radius:13px;padding:15px 16px;position:relative;overflow:hidden;}
  .tile .k{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);}
  .tile .v{font-size:30px;font-weight:700;font-variant-numeric:tabular-nums;margin-top:3px;}
  .tile .sub{font-size:11px;color:var(--muted);margin-top:2px;}
  .tile .delta{position:absolute;top:13px;right:14px;font-size:12px;font-weight:700;color:var(--active);opacity:0;transition:opacity .3s;}
  .tile .delta.show{opacity:1;}
  .tile.gold .v{color:var(--gold);} .tile.active .v{color:var(--active);} .tile.draft .v{color:var(--draft);} .tile.staged .v{color:var(--staged);}
  .tile.clickable{cursor:pointer;transition:border-color .12s,transform .12s;} .tile.clickable:hover{border-color:var(--gold);transform:translateY(-1px);} .tile.clickable::after{content:'↗';position:absolute;bottom:8px;right:11px;font-size:11px;color:var(--muted);opacity:0;transition:opacity .12s;} .tile.clickable:hover::after{opacity:1;}
  .cols{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
  @media(max-width:880px){.cols{grid-template-columns:1fr;}}
  /* tablet + mobile: header + nav wrap instead of overflowing the viewport
     (was 760px — missed iPad portrait at 768px; raised to 1024 to cover the whole tablet band) */
  @media(max-width:1024px){
    header{flex-wrap:wrap;}
    header .nav{flex-wrap:wrap;gap:10px 12px;width:100%;}
    .wrap{padding:14px;}
    #modal{width:96vw;padding:16px;}
    html,body{overflow-x:hidden;}
  }
  .panel{background:var(--panel);border:1px solid var(--line);border-radius:13px;padding:16px;margin-bottom:16px;}
  .panel h3{margin:0 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--gold);display:flex;justify-content:space-between;cursor:pointer;user-select:none;}
  .panel h3 .c{color:var(--muted);font-weight:400;}
  table{width:100%;border-collapse:collapse;font-size:13px;} td,th{text-align:left;padding:6px 6px;border-top:1px solid var(--line);}
  th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.4px;border-top:0;}
  th.srt{cursor:pointer;user-select:none;} th.srt:hover{color:var(--fg);}
  th.srt.asc::after{content:' ▲';color:var(--gold);} th.srt.desc::after{content:' ▼';color:var(--gold);}
  td.n{text-align:right;font-variant-numeric:tabular-nums;color:var(--active);font-weight:600;}
  .firm{color:var(--muted);font-size:11px;}
  .atype{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:1px 6px;border-radius:9px;margin-left:6px;vertical-align:middle;}
  .atype.res{background:rgba(91,155,213,.16);color:var(--draft);border:1px solid rgba(91,155,213,.4);}
  .atype.com{background:rgba(200,162,75,.14);color:var(--gold);border:1px solid rgba(200,162,75,.4);}
  .atype.arc{background:rgba(127,174,106,.18);color:#8fd06f;border:1px solid rgba(127,174,106,.55);}
  .has{color:var(--active);} .miss{color:var(--archived);}
  .bar{height:8px;background:var(--bg);border-radius:5px;overflow:hidden;margin-top:4px;}
  .bar i{display:block;height:100%;background:linear-gradient(90deg,var(--gold),var(--active));}
  .prog{display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-top:10px;}
  .meta{font-size:11px;color:var(--muted);margin-top:6px;}
  .flash{animation:fl .8s;} @keyframes fl{0%{background:rgba(200,162,75,.18)}100%{background:transparent}}
  /* contact modal */
  #ov{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;z-index:50;}
  #ov.on{display:flex;align-items:center;justify-content:center;}
  #modal{width:min(760px,94vw);max-height:90vh;overflow-y:auto;background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:20px 22px;}
  #modal h2{margin:0 0 2px;font-size:20px;} #modal .mfirm{color:var(--gold);font-size:13px;}
  #modal .x{float:right;background:none;border:0;color:var(--muted);font-size:20px;cursor:pointer;}
  .cinfo{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0;}
  .cinfo a,.cinfo span{font-size:13px;background:var(--panel2);border:1px solid var(--line);border-radius:18px;padding:5px 11px;color:var(--fg);text-decoration:none;}
  .sec{margin-top:14px;} .sec h4{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:0 0 7px;border-top:1px solid var(--line);padding-top:10px;}
  .litem{display:flex;justify-content:space-between;font-size:13px;padding:4px 0;} .litem .a{color:var(--fg);} .litem .m{color:var(--muted);}
  /* warrantability drill-down (TK-10092): clickable bars + modal list */
  .wclk{cursor:pointer;border-radius:8px;padding:2px 8px;margin:0 -8px;transition:background .12s;} .wclk:hover{background:var(--panel2);} .wclk:hover .prog span:first-child{color:var(--gold);}
  .wtabs{display:flex;gap:6px;flex-wrap:wrap;margin:10px 0 4px;}
  .wtab{background:var(--panel2);border:1px solid var(--line);border-radius:16px;padding:5px 11px;font-size:12px;color:var(--muted);cursor:pointer;} .wtab.on{border-color:var(--gold);color:var(--fg);} .wtab .c{color:var(--gold);font-weight:700;}
  .wsearch{width:100%;box-sizing:border-box;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:13px;margin:8px 0 2px;}
  a.wrow{text-decoration:none;border-radius:6px;padding:5px 6px;margin:0 -6px;} a.wrow:hover{background:var(--panel2);} a.wrow:hover .a{color:var(--gold);}
  .clink{cursor:pointer;} .clink:hover{color:var(--gold);}
  #xpTbl tbody tr.xprow:hover{background:rgba(200,162,75,.08);}
  /* unwarrantable-loans CTA */
  .loanbtn{display:inline-flex;align-items:center;gap:7px;margin-top:12px;text-decoration:none;}
  .loanbtn .loanct{background:#1a1206;color:var(--gold);border-radius:10px;padding:1px 8px;font-size:12px;font-weight:700;}
  /* Solicitation Studio (RENTV-style outreach content) */
  .solbar{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start;margin-bottom:12px;}
  .soltabs,.soltypes{display:flex;gap:6px;flex-wrap:wrap;}
  .soltab,.soltype{background:var(--panel2);border:1px solid var(--line);border-radius:16px;padding:6px 13px;font-size:13px;color:var(--muted);cursor:pointer;}
  .soltab.on{border-color:var(--gold);color:var(--fg);background:rgba(200,162,75,.10);}
  .soltype:hover{color:var(--fg);border-color:var(--gold);} .soltype.on{border-color:var(--gold);color:var(--fg);background:rgba(200,162,75,.10);}
  .solaud{display:flex;gap:6px;flex-wrap:wrap;}
  .solaud .achip{background:var(--panel2);border:1px solid var(--line);border-radius:16px;padding:6px 11px;font-size:12px;color:var(--muted);cursor:pointer;}
  .solaud .achip.on{border-color:var(--draft);color:var(--fg);background:rgba(91,155,213,.12);}
  .solaud .achip .n{color:var(--draft);font-weight:700;margin-left:4px;}
  .solout{background:var(--panel2);border:1px solid var(--line);border-radius:11px;padding:16px;min-height:80px;}
  .solout h4{margin:0 0 4px;font-size:15px;color:var(--fg);} .solout .subj{color:var(--gold);font-size:12px;margin-bottom:8px;}
  .solout pre{white-space:pre-wrap;font:13px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--fg);margin:0;}
  .solactions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px;}
  .solactions button{background:var(--panel);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:7px 12px;font-size:12px;cursor:pointer;}
  .solactions button.prime{background:var(--gold);color:#1a1206;border:0;font-weight:700;}
  .solgate{font-size:11px;color:var(--staged);margin-top:8px;}
  .btn{background:var(--gold);color:#1a1206;border:0;border-radius:9px;padding:9px 14px;font-size:13px;font-weight:700;cursor:pointer;margin-right:8px;}
  .btn.alt{background:transparent;color:var(--draft);border:1px solid var(--draft);}
  .gate{font-size:11px;color:var(--staged);margin-top:6px;}
  .findmail{background:transparent;border:1px solid var(--draft);color:var(--draft);border-radius:9px;padding:2px 8px;font-size:11px;cursor:pointer;white-space:nowrap;}
  .findmail:hover{background:rgba(91,155,213,.12);}
  .findmail:disabled{opacity:.5;cursor:default;}
  .hunting{color:var(--staged);font-size:12px;}
  /* DSCR Qualifier */
  .dscr-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;} @media(max-width:880px){.dscr-grid{grid-template-columns:1fr;}}
  .dscr .dfield{margin-bottom:10px;} .dscr .dfield>label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:4px;}
  .dscr input[type=number],.dscr input[type=text]{width:100%;box-sizing:border-box;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:8px 10px;color:var(--fg);font-size:14px;font-variant-numeric:tabular-nums;}
  .dscr input:focus{outline:0;border-color:var(--gold);}
  .dscr .row2{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
  .dscr .unitrow{display:grid;grid-template-columns:1fr 130px 32px;gap:8px;align-items:center;margin-bottom:6px;}
  .dscr .unitrow button{background:transparent;border:1px solid var(--line);color:var(--muted);border-radius:7px;cursor:pointer;font-size:15px;line-height:1;padding:7px 0;}
  .dscr .unitrow button:hover{border-color:var(--del);color:var(--del);}
  .dscr .addunit{background:transparent;border:1px dashed var(--line);color:var(--draft);border-radius:8px;padding:6px 11px;font-size:12px;cursor:pointer;margin-top:2px;}
  .dscr .addunit:hover{border-color:var(--draft);}
  .dscr .rentsum{font-size:12px;color:var(--muted);margin-top:7px;} .dscr .rentsum b{color:var(--active);font-variant-numeric:tabular-nums;}
  .dscr .dtog{display:inline-flex;border:1px solid var(--line);border-radius:9px;overflow:hidden;}
  .dscr .dtog button{background:var(--panel2);border:0;color:var(--muted);padding:6px 12px;font-size:12px;cursor:pointer;} .dscr .dtog button.on{background:rgba(200,162,75,.14);color:var(--gold);}
  .dscr .dresult{background:var(--bg);border:1px solid var(--line);border-radius:12px;padding:18px 16px;text-align:center;}
  .dscr .dresult .ratio{font-size:48px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1;margin-top:4px;}
  .dscr .dbadge{display:inline-block;margin-top:12px;font-size:13px;font-weight:800;letter-spacing:.4px;text-transform:uppercase;padding:6px 14px;border-radius:20px;}
  .dscr .dbadge.pass{background:rgba(112,173,71,.15);color:var(--active);border:1px solid rgba(112,173,71,.5);}
  .dscr .dbadge.marg{background:rgba(224,163,62,.15);color:var(--staged);border:1px solid rgba(224,163,62,.5);}
  .dscr .dbadge.fail{background:rgba(231,76,60,.14);color:var(--del);border:1px solid rgba(231,76,60,.5);}
  .dscr .brk{font-size:12.5px;color:var(--muted);margin-top:14px;line-height:1.8;text-align:left;}
  .dscr .brk .r{display:flex;justify-content:space-between;gap:10px;} .dscr .brk b{color:var(--fg);font-variant-numeric:tabular-nums;}
  .dscr .brk .r.hi b{color:var(--gold);}
  .dscr pre{white-space:pre-wrap;background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:12px;font:12.5px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--fg);margin:6px 0 0;}
  .dscr .dactions{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;} .dscr .dactions button{background:var(--panel2);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:7px 12px;font-size:12px;cursor:pointer;} .dscr .dactions .prime{background:var(--gold);color:#1a1206;border-color:var(--gold);font-weight:700;}
</style>
<link rel="stylesheet" href="/crcp-theme.css">
<style>
  /* Bridge: map page-local aliases to shared tokens so JS inline styles using --fg/--panel still work */
  html[data-theme="dark"]  { --panel:var(--card); --panel2:var(--bg2); --fg:var(--ink); }
  html[data-theme="light"] { --panel:var(--card); --panel2:var(--bg2); --fg:var(--ink); }
</style>
<style>
  /* ── TK-10330: industry-grade layout redesign (override layer) ──────────────
     Loads LAST, so these rules win by cascade order. Additive + fully reversible;
     preserves every JS-bound ID/DOM hook. Builds on the shared day/night tokens.
     Design consensus: graphic-designer + ui-ux-designer panels — move from
     "consumer card app" toward a CoStar/Crexi/Bloomberg-tier deal terminal. */

  /* — Header: compact single-line toolbar, differentiated zones — */
  header{padding:0 22px;min-height:46px;gap:12px;border-bottom:2px solid var(--line);background:var(--headbg,var(--bg));backdrop-filter:saturate(1.1) blur(6px);}
  header h1{font-size:15px;font-weight:700;letter-spacing:.2px;white-space:nowrap;}
  header h1 b{color:var(--gold);}
  header h1::after{content:'';display:inline-block;width:1px;height:15px;background:var(--line);margin:0 4px 0 12px;vertical-align:-2px;}
  header .nav a{color:var(--muted);font-size:12.5px;font-weight:500;}
  header .nav a:hover{color:var(--ink,var(--fg));}
  header .nav a.active{color:var(--gold);font-weight:600;}
  .live{font-size:11.5px;}
  .expandall{border-radius:5px;padding:5px 10px;font-size:11.5px;}

  /* — Panels: terminal radius + gold category rail + muted section headers — */
  .panel{border-radius:5px;border-left:2px solid var(--gold);margin-bottom:12px;padding:14px 16px;}
  .panel h3{font-size:11px;font-weight:600;letter-spacing:.5px;color:var(--muted);
    border-bottom:1px solid var(--line);padding-bottom:9px;margin-bottom:11px;align-items:center;}
  .panel h3 .caret{color:var(--gold);}
  .panel h3 .c{font-size:10.5px;}

  /* — KPI tiles: color-coded top rail, tighter terminal density — */
  .tiles{gap:11px;margin-bottom:16px;}
  .tile{border-radius:6px;padding:12px 14px;border-top:2px solid var(--line);}
  .tile.gold{border-top-color:var(--gold);}
  .tile.active{border-top-color:var(--active);}
  .tile.draft{border-top-color:var(--draft);}
  .tile.staged{border-top-color:var(--staged);}
  .tile .k{font-size:10px;letter-spacing:.7px;font-weight:600;}
  .tile .v{font-size:26px;line-height:1.08;margin-top:2px;}
  .tile .sub{font-size:10px;margin-top:1px;}
  /* persistent (not hover-only) drill affordance so it's discoverable */
  .tile.clickable::after{opacity:.3;}
  .tile.clickable:hover::after{opacity:1;}

  /* — Tables: header rail, zebra, hover, discoverable sort affordance — */
  td,th{padding:7px 8px;}
  th{font-size:10px;letter-spacing:.5px;}
  #segTbl thead th,#brTbl thead th,#fmTbl thead th,#xpTbl thead th{
    background:var(--bg2,transparent);border-bottom:1px solid var(--line);}
  /* sort affordance is already owned by the page's native th.srt.asc/.desc glyphs
     + col-resize.js's .cr-sort span — do NOT add an idle ::after here or it double-renders */
  #segTbl tbody tr:nth-child(even) td,#brTbl tbody tr:nth-child(even) td,
  #fmTbl tbody tr:nth-child(even) td{background:var(--stripe,transparent);}
  #segTbl tbody tr:hover td,#brTbl tbody tr:hover td,
  #fmTbl tbody tr:hover td,#xpTbl tbody tr:hover td{background:var(--hover,rgba(121,184,255,.10));}

  /* — Loading / connection state: skeleton shimmer + honest pulse — */
  @keyframes crcpShimmer{0%{opacity:.45}50%{opacity:.9}100%{opacity:.45}}
  .tile.skel .v{color:transparent;background:var(--line);border-radius:4px;height:24px;width:62%;margin-top:5px;animation:crcpShimmer 1.3s ease-in-out infinite;}
  .tile.skel .sub{color:transparent;background:var(--line);border-radius:3px;height:8px;width:75%;margin-top:6px;opacity:.5;}
  .pulse.reconnecting{background:var(--gold);animation:pWarn 1.6s infinite;}
  @keyframes pWarn{0%{box-shadow:0 0 0 0 rgba(200,162,75,.55)}70%{box-shadow:0 0 0 9px rgba(200,162,75,0)}100%{box-shadow:0 0 0 0 rgba(200,162,75,0)}}
  #crcpConnBanner{display:none;margin:0 0 14px;background:rgba(231,76,60,.10);border:1px solid var(--del);
    border-radius:6px;padding:9px 13px;font-size:12.5px;color:var(--del);}
  #crcpConnBanner.show{display:block;}
</style>
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<header>
  <h1><b>CRCP</b> · Commercial Real Estate Control Panel</h1>
  <span class="live"><span class="pulse"></span><span id="livetxt">live</span></span>
  <nav class="nav">
    <a href="/">Properties</a><a href="/mls.html">📋 Listings</a><a href="/broker-grid.html">👥 Brokers grid</a><a href="/firms.html">🏢 Firms</a><a href="/map.html">Map</a><a href="/deals.html">Deals</a><a href="/rent-rolls.html">Rent Rolls</a><a href="/brokers.html">Mind-map</a><a href="/graphics.html">Graphics</a><a href="/condos.html">Condos</a><a href="/fha-loans.html">FHA Loans</a><a href="/lending.html">💰 Lending</a>
    <span class="denslider" title="chip / tile density">⊞ <input type="range" id="dens" min="120" max="300" value="150" aria-label="Tile density"></span>
    <button class="expandall" id="expandall">Expand all ▾</button>
  </nav>
</header>
<div class="wrap">
  <div class="tiles" id="tiles"></div>
  <div class="panel"><h3>📞 Call Segments — prospects by property category <span class="c">click a segment for the call list</span></h3>
    <table id="segTbl"><thead><tr><th class="srt" data-col="label">Segment</th><th class="srt" data-col="product">Category / note</th><th class="srt n" data-col="props">Properties</th><th class="srt n" data-col="priceMed">Price range (med)</th><th class="srt n" data-col="agents">Agents to call</th></tr></thead><tbody></tbody></table>
  </div>
  <div class="cols">
    <div>
      <div class="panel"><h3>Broker enrichment <span class="c" id="enrLbl"></span></h3>
        <div id="enrBars"></div>
        <div class="meta" id="enrMeta"></div>
      </div>
      <div class="panel"><h3>Top brokers <span class="c" id="brC"></span></h3>
        <table id="brTbl"><thead><tr><th class="srt" data-col="name">Broker</th><th class="srt n" data-col="listings">Lists</th><th class="srt" data-col="phone">Phone</th><th class="srt" data-col="email">Email</th><th class="srt" data-col="website">Web</th></tr></thead><tbody></tbody></table>
      </div>
    </div>
    <div>
      <div class="panel"><h3>Warrantability (FHA proxy) <span class="c" id="waC"></span></h3>
        <div id="wa"></div>
        <a class="btn loanbtn" href="/condos.html?status=unwarrantable" title="Every non-FHA-approved condo — the deals conventional lenders decline">💰 Unwarrantable Loans — non-QM opportunities <span id="unwarrC" class="loanct"></span></a>
        <div class="meta">FHA/VA-approval-based proxy — NOT lender-verified Fannie/Freddie warrantability. “Unwarrantable” = every condo not FHA-approved (non-QM / portfolio lending target).</div>
      </div>
      <div class="panel"><h3>Top firms by market share <span class="c" id="fmC"></span></h3>
        <table id="fmTbl"><thead><tr><th class="srt" data-col="firm">Firm</th><th class="srt n" data-col="listings">Listings</th><th class="srt n" data-col="brokers">Brokers</th></tr></thead><tbody></tbody></table>
      </div>
    </div>
  </div>
  <div class="panel"><h3>⚠️ FHA approval expiring within <span id="xpWin">12</span> months <span class="c" id="xpC"></span></h3>
    <div class="wtabs" data-xp-win>
      <button type="button" class="wtab" data-months="6" data-days="183">6 months</button>
      <button type="button" class="wtab on" data-months="12" data-days="365">12 months</button>
      <button type="button" class="wtab" data-months="24" data-days="730">24 months</button>
    </div>
    <table id="xpTbl"><thead><tr>
      <th class="srt" data-col="project">Project</th>
      <th class="srt" data-col="city">City</th>
      <th class="srt" data-col="zip">ZIP</th>
      <th class="srt" data-col="expires">Expires</th>
      <th class="srt n" data-col="days">Days</th>
    </tr></thead><tbody></tbody></table>
    <div class="meta">Approved projects whose FHA certification lapses within a year — a buyer using FHA financing should confirm re-certification. FHA-approval PROXY signal, not lender-verified Fannie/Freddie warrantability.</div>
  </div>

  <div class="panel dscr" id="dscrPanel"><h3>🧮 DSCR Qualifier — does the property qualify itself? <span class="c">no tax returns · rents ÷ PITI</span></h3>
    <div class="meta">A <b>DSCR</b> (debt-service-coverage-ratio) loan qualifies on the <b>property's own cash flow</b>, not the borrower's tax returns — the alternative to conventional commercial financing on small multifamily. Enter the rents and the payment: if the current or projected fair-market rents cover the PITI at the lender's ratio, it's approvable.</div>
    <div class="dscr-grid" style="margin-top:14px">
      <div>
        <div class="dfield"><label>Property (used in Frank's note)</label><input type="text" id="dAddr" placeholder="e.g. the 95th St 6-unit"></div>
        <div class="dfield"><label>Rent basis</label>
          <div class="dtog" id="dRentBasis"><button type="button" class="on" data-v="current">Current rents</button><button type="button" data-v="projected">Projected fair-market</button></div>
        </div>
        <div class="dfield"><label>Rent roll — monthly rent per unit</label>
          <div id="dUnits"></div>
          <button type="button" class="addunit" id="dAddUnit">+ add unit</button>
          <div class="rentsum">Gross monthly rents: <b id="dRentSum">—</b> &nbsp;·&nbsp; annual <b id="dRentAnn">—</b></div>
        </div>
        <div class="dfield"><label>Vacancy % (0 = fully occupied)</label><input type="number" id="dVac" value="0" min="0" max="30" step="1"></div>
        <div class="dfield"><label>PITI — the monthly payment</label>
          <div class="dtog" id="dPitiMode"><button type="button" class="on" data-v="build">Build it</button><button type="button" data-v="direct">Enter directly</button></div>
        </div>
        <div id="dBuild">
          <div class="dfield"><label>Payment type</label>
            <div class="dtog" id="dPayType"><button type="button" class="on" data-v="pi">P&amp;I (amortizing)</button><button type="button" data-v="io">Interest-only</button></div>
          </div>
          <div class="row2">
            <div class="dfield"><label>Loan amount</label><input type="number" id="dLoan" value="960000" min="0" step="1000"></div>
            <div class="dfield"><label>Rate %</label><input type="number" id="dRate" value="7.25" min="0" step="0.05"></div>
          </div>
          <div class="row2">
            <div class="dfield"><label>Amortization (yrs)</label><input type="number" id="dAmort" value="30" min="1" step="1"></div>
            <div class="dfield"><label>Taxes / mo</label><input type="number" id="dTax" value="1250" min="0" step="10"></div>
          </div>
          <div class="row2">
            <div class="dfield"><label>Insurance / mo</label><input type="number" id="dIns" value="450" min="0" step="10"></div>
            <div class="dfield"><label>HOA / other / mo</label><input type="number" id="dHoa" value="0" min="0" step="10"></div>
          </div>
        </div>
        <div id="dDirect" style="display:none">
          <div class="dfield"><label>PITI / mo (all-in payment)</label><input type="number" id="dPiti" value="8200" min="0" step="10"></div>
        </div>
        <div class="dfield"><label>Approval DSCR (lender's line)</label><input type="number" id="dThresh" value="1.20" min="0.5" max="3" step="0.05"></div>
      </div>
      <div>
        <div class="dresult">
          <div class="k" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)">DSCR = rents ÷ PITI</div>
          <div class="ratio" id="dRatio">—</div>
          <div id="dBadge" class="dbadge">—</div>
          <div class="brk" id="dBrk"></div>
        </div>
        <div style="margin-top:14px">
          <div class="k" style="font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)">Frank's note to the referring agent</div>
          <pre id="dNote"></pre>
          <div class="dactions"><button type="button" class="prime" id="dCopy">📋 Copy note</button><button type="button" id="dQueue">🎬 Queue to producer</button></div>
          <div class="gate">Draft only — sending / producing is gated (queued for Steve, never auto-sent).</div>
        </div>
      </div>
    </div>
  </div>

  <div class="panel" id="solPanel"><h3>📣 Solicitation Studio — cold &amp; warm outreach content <span class="c" id="solC">ads · video · music · social</span></h3>
    <div class="solbar">
      <div class="soltabs" id="solTone" title="Cold = never contacted · Warm = prior relationship">
        <button type="button" class="soltab on" data-tone="cold">❄️ Cold</button>
        <button type="button" class="soltab" data-tone="warm">🔥 Warm</button>
      </div>
      <div class="solaud" id="solAud"></div>
    </div>
    <div class="soltypes" id="solTypes" style="margin-bottom:6px">
      <button type="button" class="soltype on" data-type="ad">📝 Ad copy</button>
      <button type="button" class="soltype" data-type="video">🎬 Video script</button>
      <button type="button" class="soltype" data-type="music">🎵 Music / jingle</button>
      <button type="button" class="soltype" data-type="social">📣 Social post</button>
    </div>
    <div class="meta">Pick a tone + audience + content type — the Studio drafts a ready-to-produce solicitation grounded in that segment's live pipeline. Production &amp; publishing stay gated (queued for Steve).</div>
    <div id="solOut" class="solout"><div class="meta" style="margin:0">Choose an audience above to draft the outreach.</div></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 src="/finance.js"></script>
<script>
const $=s=>document.querySelector(s);
const money=n=>n?'$'+Number(n).toLocaleString():'—';
const fd10=s=>(s||'').toString().slice(0,10);   // public-record dates are date-only
// date+TIME — only for genuine capture timestamps (listed_at); public sale/deed dates stay date-only
const fdt=s=>{ if(!s)return''; try{ const d=new Date(s),S=String(s); const t=/T\d\d:\d\d/.test(S)&&!/T0[0-7]:00:00/.test(S); return d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})+(t?', '+d.toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'}):''); }catch(e){ return fd10(s); } };
// Shared property-bucket renderer for broker + firm modals (DTD-C rider 2026-07-31): show ONLY
// the buckets that have real data — never a hollow "Closed (0)" note-section — and collapse the
// gaps into ONE honest coverage line, so the modal reads "active listings only" cleanly instead
// of looking broken. Active is always shown (it's the covered dimension).
function propBuckets(d){
  const active=d.current||[], closed=d.closed||[], expired=d.expired||[];
  let html=`<div class="sec"><h4>Active / listed (${active.length})</h4>${active.length?active.map(l=>`<div class="litem"><span class="a">${l.address}, ${l.city}${l.zip?' '+l.zip:''}</span><span class="m">${l.type} · ${money(l.price)}${l.units?' · '+l.units+'u':''}${l.cap_rate?' · '+l.cap_rate+'% cap':''}${l.listed_at?' · listed '+fdt(l.listed_at):''}</span></div>`).join(''):'<div class="meta">none in our set</div>'}</div>`;
  if(closed.length) html+=`<div class="sec"><h4>Closed (${closed.length})</h4>${closed.map(l=>`<div class="litem"><span class="a">${l.address}, ${l.city}</span><span class="m">${money(l.sold_price)}${l.sold_date?' · '+fd10(l.sold_date):''}${l.type?' · '+l.type:''}</span></div>`).join('')}</div>`;
  if(expired.length) html+=`<div class="sec"><h4>Withdrawn / expired / off-market (${expired.length})</h4>${expired.map(x=>`<div class="litem"><span class="a">${x.address}, ${x.city}</span><span class="m">${money(x.list_price)} → sold ${money(x.sold_price)} · ${fd10(x.sold_date)}${x.mls?' · MLS '+x.mls:''}${x.url?` · <a href="${x.url}" target="_blank" rel="noopener noreferrer">↗</a>`:''}</span></div>`).join('')}</div>`;
  const gaps=[]; if(!closed.length) gaps.push('closed'); if(!expired.length) gaps.push('withdrawn/expired');
  if(gaps.length) html+=`<div class="meta" style="margin-top:6px;opacity:.85">📊 Coverage: active listings only${active.length?'':' — none attributed here yet'}. ${gaps.join(' & ')} history isn’t in free public records (needs a paid records source).</div>`;
  return html;
}
// Fire-and-forget modal-open telemetry (demand signal for the parked paid-data track).
function track(ev,o){ try{ fetch('/api/track',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({ev},o)),keepalive:true}).catch(()=>{}); }catch(e){} }
const atype=t=>t==='residential'?'<span class="atype res" title="Residential listing agent (Redfin)">Residential</span>':'<span class="atype com" title="Commercial broker (Crexi)">Commercial</span>';
async function openContact(name, id, seg){
  window.__seg = seg || null;
  const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="meta">loading '+name+'…</div>';
  const qs = id ? 'id='+encodeURIComponent(id) : 'name='+encodeURIComponent(name);
  let d; try{ d=await (await fetch('/api/broker?'+qs)).json(); }catch(e){ mb.innerHTML='error'; return; }
  if(d.error){ mb.innerHTML='<div class="meta">'+d.error+'</div>'; return; }
  const b=d.broker, w=b.website?(/^https?:/.test(b.website)?b.website:'https://'+b.website):null;
  track('broker_modal',{kind:'broker',id:b&&b.id,name:b&&b.name,active:(d.current||[]).length,closed:(d.closed||[]).length,withdrawn:(d.expired||[]).length});
  mb.innerHTML=`<h2>${b.name}${atype(b.agent_type)}</h2><div class="mfirm">${b.firm||'—'}${b.title?' · '+b.title:''}${b.license?' · DRE/MLS '+b.license:''}</div>
    <div class="cinfo">
      ${b.phone?`<a href="tel:${b.phone}">☎ ${b.phone}</a>`:'<span class="m">☎ —</span>'}
      ${b.email?`<a href="mailto:${b.email}">✉ ${b.email}</a>`:(w?`<span class="m" id="memail">✉ — <button class="findmail" onclick="findEmailModal(${b.id})" title="Visit the site and hunt down an email address">find email on site</button></span>`:'<span class="m">✉ —</span>')}
      ${w?`<a href="${w}" target="_blank" rel="noopener noreferrer">🌐 ${w.replace(/^https?:\/\/(www\.)?/,'').replace(/\/$/,'')}</a>`:'<span class="m">🌐 —</span>'}
      <span>📦 ${b.agent_type==='residential'?(d.current.length+' condo listing'+(d.current.length===1?'':'s')+' (Redfin)'):((b.total_assets||'?')+' total listings (Crexi)')}</span>
    </div>
    ${propBuckets(d)}
    ${(d.comps&&d.comps.length)?`<div class="sec"><h4>Sales — area sold (${d.comps.length})</h4>${d.comps.map(c=>`<div class="litem"><span class="a">${c.address}, ${c.city}${c.beds?' · '+c.beds+'bd':''}${c.baths?'/'+c.baths+'ba':''}${c.sqft?' · '+Number(c.sqft).toLocaleString()+'sf':''}</span><span class="m">${money(c.sold_price)} · ${(c.sold_date||'').toString().slice(0,10)}${c.mls?' · MLS '+c.mls:''}${c.url?` · <a href="${c.url}" target="_blank" rel="noopener noreferrer">↗</a>`:''}</span></div>`).join('')}<div class="meta">${d.compsNote||''}</div></div>`:''}`;
}
// Modal variant of the email hunt — same endpoint, updates the ✉ chip in place.
async function findEmailModal(id){
  const el=document.getElementById('memail'); if(el) el.innerHTML='✉ <span class="hunting">⏳ hunting on the site…</span>';
  try{
    const r=await (await fetch('/api/broker/find-email',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:+id})})).json();
    if(r.email){ hunt[id]={email:r.email,basis:r.basis}; if(el) el.innerHTML=`✉ <a href="mailto:${r.email}">${r.email}</a> <span style="color:var(--muted);font-size:11px">(${r.basis||'found'}${r.site?' · '+r.site:''})</span>`; }
    else { hunt[id]={miss:r.reason||r.error||'not found'}; if(el) el.innerHTML=`✉ not found <span style="color:var(--muted);font-size:11px" title="${((r.reason||r.error||'')+'').replace(/"/g,'&quot;')}">(${r.reason||r.error||'no email on the site'})</span>`; }
    renderBrokers(lastStats);
  }catch(e){ if(el) el.innerHTML='✉ error: '+e.message; }
}
async function openFirm(name){
  const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="meta">loading '+name+'…</div>';
  let d; try{ d=await (await fetch('/api/firm?name='+encodeURIComponent(name))).json(); }catch(e){ mb.innerHTML='error'; return; }
  if(d.error){ mb.innerHTML='<div class="meta">'+d.error+'</div>'; return; }
  const a=d.agg||{};
  track('firm_modal',{kind:'firm',name:d.firm&&d.firm.name,active:(d.current||[]).length,closed:(d.closed||[]).length,withdrawn:(d.expired||[]).length});
  mb.innerHTML=`<h2>${d.firm.name}</h2><div class="mfirm">brokerage firm</div>
    <div class="cinfo"><span>👥 ${a.brokers||0} brokers/agents</span><span>🏠 ${a.residential||0} residential</span><span>☎ ${a.phone||0}</span><span>✉ ${a.email||0}</span></div>
    ${propBuckets(d)}
    <div class="sec"><h4>Roster (${d.roster.length}) — click anyone for full contact</h4>
      ${d.roster.map(b=>`<div class="litem brow2" data-id="${b.id}" data-name="${(b.name||'').replace(/"/g,'&quot;')}" style="cursor:pointer">
        <span class="a">${b.name} ${atype(b.agent_type)}</span>
        <span class="m">${b.listings} lists · ${b.phone?'☎':'·'} ${b.email?'✉':'·'} ${b.website?'🌐':'·'}</span></div>`).join('')}</div>`;
  document.querySelectorAll('.brow2').forEach(r=>r.onclick=()=>openContact(r.dataset.name, r.dataset.id));
}
// ---- Call Segments (by property category) ----
// Compliant lead-source playbook for self-employed / bank-statement buyers (NOT a scraped list —
// referral partners + compliant marketing channels). Shown on the SFR / non-QM segments.
const LEAD_SOURCES=[
  ['🤝 CPAs & bookkeepers','Highest-intent referral partners — they serve self-employed clients who need bank-statement/P&L loans. Build relationships; they send pre-qualified borrowers.'],
  ['🏠 Real-estate agents (in this DB)','The residential agents here list to self-employed buyers — when a buyer can’t show W-2 income, they refer to you. Use the segments to reach SFR-listing agents.'],
  ['📣 Meta ads — Special Ad Category','Mortgage ads are a HUD/Meta Special Ad Category: NO age/gender/ZIP/protected-class targeting. Allowed: broad interest ("small business owner") + Special Ad Audience. Equal Housing + NMLS ID required.'],
  ['💼 LinkedIn — organic + content','Educational posts, business-owner groups, inbound. Value-first; no scraping/mass-DM. Sales Navigator for TOS-compliant outreach only.'],
  ['🌐 Local business communities','Chamber, BNI, "small business owners of [city]" groups. Relationship + referral, not list-harvesting.'],
  ['⚖ Compliance','ECOA/fair-lending (no protected-class targeting), TCPA (no cold calls/texts w/o consent), CAN-SPAM, NMLS disclosure. Inbound/referral only — never scrape/buy a consumer list.']
];
function leadSourcesHtml(){ return `<div class="sec"><h4>Lead sources — finding self-employed buyers (compliant)</h4>${LEAD_SOURCES.map(([t,d])=>`<div style="margin-bottom:8px"><b style="font-size:13px">${t}</b><div class="meta" style="margin-top:2px">${d}</div></div>`).join('')}</div>`; }
// Segments are a fixed summary/lookup set (a handful of rows), NOT a browsable catalog —
// so no column machinery, just page-owned click-to-sort on the multi-row result table
// (Steve list-build rule: any multi-row result table must be click-sortable). Segment
// rows stay click-to-drill; empty/null values always sink to the bottom.
let SEGMENTS=[], segSort={col:null,dir:-1};
function renderSegments(){
  const kM=n=>n==null?null:(n>=1e6?'$'+(n/1e6).toFixed(n>=1e7?0:1)+'M':'$'+Math.round(n/1e3)+'k');
  const priceCell=g=>{ if(g.priceMed==null) return '<span style="color:var(--muted)">—</span>';
    const range=(g.priceMin!=null&&g.priceMax!=null&&g.priceMax>g.priceMin)?`<div class="meta" style="margin:0">${kM(g.priceMin)}–${kM(g.priceMax)}</div>`:'';
    return `<b style="color:var(--gold)">${kM(g.priceMed)}</b>${range}`; };
  let rows=SEGMENTS.slice();
  if(segSort.col){ const c=segSort.col, dir=segSort.dir;
    rows.sort((a,b)=>{ let x=a[c], y=b[c];
      const xE=(x==null||x===''), yE=(y==null||y===''); if(xE&&yE) return 0; if(xE) return 1; if(yE) return -1;
      const xn=!isNaN(+x)&&x!=='', yn=!isNaN(+y)&&y!=='';
      if(xn&&yn) return dir*((+x)-(+y));
      return dir*String(x).toLowerCase().localeCompare(String(y).toLowerCase()); }); }
  $('#segTbl tbody').innerHTML=rows.map(g=>`<tr class="srow" data-key="${g.key}" style="cursor:${g.props?'pointer':'default'}${g.props?'':';opacity:.5'}">
    <td><b>${g.label}</b></td><td style="color:var(--muted)">${g.product}</td>
    <td class="n">${(g.props||0).toLocaleString()}</td><td class="n">${priceCell(g)}</td><td class="n" style="color:var(--draft)">${g.agents!=null?g.agents.toLocaleString():'—'}</td></tr>`).join('');
  document.querySelectorAll('.srow').forEach(r=>r.onclick=()=>openSegment(r.dataset.key));
  markHdr('segTbl',segSort);
}
async function loadSegments(){
  let d; try{ d=await (await fetch('/api/segments')).json(); }catch(e){ return; }
  SEGMENTS=d.segments||[]; renderSegments();
}
// wire segment header sort once (page-owned, same pattern as brTbl/fmTbl)
document.querySelectorAll('#segTbl th.srt').forEach(th=>th.onclick=()=>{ const c=th.dataset.col;
  if(segSort.col===c) segSort.dir*=-1; else { segSort.col=c; segSort.dir=(th.classList.contains('n'))?-1:1; }
  renderSegments(); });
async function openSegment(key){
  const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="meta">loading…</div>';
  let d; try{ d=await (await fetch('/api/segment?key='+encodeURIComponent(key))).json(); }catch(e){ mb.innerHTML='error'; return; }
  if(d.error){ mb.innerHTML='<div class="meta">'+d.error+'</div>'; return; }
  mb.innerHTML=`<h2>📞 ${d.label}</h2><div class="mfirm">${d.product}</div>
    <div class="meta" style="margin:8px 0;border-left:2px solid var(--gold);padding-left:8px">${d.hook}</div>
    <div class="sec"><h4>Agents to call (${d.callList.length}) — click one for full contact</h4>
      ${d.callList.length?d.callList.map(c=>`<div class="litem brow3" data-id="${c.id}" data-name="${(c.name||'').replace(/"/g,'&quot;')}" style="cursor:pointer">
        <span class="a">${c.name} ${atype(c.agent_type)}${c.avg_price?`<span class="meta" style="margin:0;display:block">🏢 ${money(c.avg_price)} avg${c.top_price&&c.top_price>c.avg_price?` · up to ${money(c.top_price)}`:''}</span>`:''}</span>
        <span class="m">${c.n} props · ${c.phone?`<a href="tel:${c.phone}" class="has" style="color:var(--active)">${c.phone}</a>`:'no phone'} ${c.email?'✉':''}</span></div>`).join(''):'<div class="meta">'+(d.note||'no matches')+'</div>'}</div>
    ${(key==='sfr-bankstmt'||key==='nonqm')?leadSourcesHtml():''}`;
  document.querySelectorAll('.brow3').forEach(r=>r.onclick=e=>{ if(e.target.tagName==='A') return; openContact(r.dataset.name, r.dataset.id, key); });
}
// Warrantability (FHA proxy) drill-down — count -> filtered condo list -> each row hrefs into the
// full Condos page (?status=&q=). Data from /api/fha-condos (data/fha-approved-condos.json). TK-10092.
async function openWarrant(status){
  status = status || 'fha_approved';
  const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="meta">loading…</div>';
  const q = window._waQ || '';
  let d; try{ d=await (await fetch('/api/fha-condos?status='+encodeURIComponent(status)+'&q='+encodeURIComponent(q))).json(); }
  catch(e){ mb.innerHTML='<div class="meta">error loading warrantability list</div>'; return; }
  if(d.error){ mb.innerHTML='<div class="meta">'+d.error+'</div>'; return; }
  const titles={ fha_approved:'✅ FHA-approved condos', fha_expired:'⚑ FHA-expired condos (flag / review)', all:'All condos · FHA proxy' };
  const rows=d.condos||[];
  const nApp=(lastStats&&lastStats.fhaApproved)||0, nExp=(lastStats&&lastStats.fhaExpired)||0;
  const chip=(v,l,n)=>`<button class="wtab${v===status?' on':''}" data-st="${v}">${l} <span class="c">${fmt(n)}</span></button>`;
  mb.innerHTML=`<h2>${titles[status]||status}</h2>
    <div class="mfirm">${esc(d.label||'')}</div>
    <div class="wtabs">${chip('fha_approved','FHA-approved',nApp)}${chip('fha_expired','FHA-expired',nExp)}${chip('all','All',nApp+nExp)}</div>
    <input id="waSearch" class="wsearch" type="text" placeholder="filter these condos — project, city, zip, address… (server-side)" value="${esc(q)}">
    <div class="sec"><h4>${fmt(d.count)} condo${d.count===1?'':'s'}${d.count>rows.length?` · showing first ${fmt(rows.length)}`:''} — click one to open it in the full Condos page</h4>
      ${rows.length?rows.map(c=>{
        const proj=esc(c.project_name||c.condo_id||'—');
        const loc=[c.city,c.zip].filter(Boolean).join(' ');
        const conc=c.fha_concentration?` · ${esc(c.fha_concentration)} FHA`:'';
        const meth=c.approval_method?` · ${esc(c.approval_method)}`:'';
        const dates=c.expiration_date?` · exp ${esc(c.expiration_date)}`:(c.status_date?` · ${esc(c.status_date)}`:'');
        const href=`/condos.html?status=${encodeURIComponent(status==='all'?'all':status)}&q=${encodeURIComponent(c.project_name||c.city||'')}`;
        return `<a class="litem wrow" href="${href}" title="Open ${proj} in the Condos page">
          <span class="a">${proj}</span>
          <span class="m">${esc(loc)}${conc}${meth}${dates}</span></a>`;
      }).join(''):'<div class="meta">none in this bucket</div>'}
    </div>
    <div class="meta" style="margin-top:10px"><a href="/condos.html?status=${encodeURIComponent(status)}" style="color:var(--gold)">Open the full Condos page for this filter ↗</a></div>`;
  mb.querySelectorAll('.wtab').forEach(b=>b.onclick=()=>{ const bx=mb.querySelector('#waSearch'); window._waQ = bx?bx.value:''; openWarrant(b.dataset.st); });
  const box=mb.querySelector('#waSearch');
  if(box){ let t; box.oninput=()=>{ clearTimeout(t); t=setTimeout(()=>{ window._waQ=box.value; openWarrant(status); }, 300); };
    box.focus(); box.setSelectionRange(box.value.length, box.value.length); }
}
const fmt=n=>n==null?'—':Number(n).toLocaleString();
let prev={};
function tile(k,key,v,sub,cls,href){ const d=(prev[key]!=null&&v>prev[key])?('+'+(v-prev[key])):'';
  return `<div class="tile ${cls||''}${href?' clickable':''}" data-key="${key}" data-href="${href||''}"><div class="k">${k}</div><div class="v">${fmt(v)}</div><div class="sub">${sub||''}</div><div class="delta ${d?'show':''}">${d}</div></div>`; }
function bar(label,have,total,color){ const pct=total?Math.round(100*have/total):0;
  return `<div style="margin-bottom:10px"><div class="prog"><span>${label}</span><span>${fmt(have)} / ${fmt(total)} · ${pct}%</span></div><div class="bar"><i style="width:${pct}%;background:${color||'linear-gradient(90deg,var(--gold),var(--active))'}"></i></div></div>`; }

// ---- sortable columns ----
let brSort={col:'listings',dir:-1}, fmSort={col:'listings',dir:-1}, lastStats=null;
function srt(arr,col,dir){ return arr.slice().sort((a,b)=>{ let x=a[col],y=b[col];
  if(col==='phone'||col==='email'||col==='website'){ x=x?1:0; y=y?1:0; }
  if(typeof x==='string'||typeof y==='string') return dir*String(x||'').localeCompare(String(y||''));
  return dir*((+x||0)-(+y||0)); }); }
function markHdr(tbl,sort){ document.querySelectorAll('#'+tbl+' th.srt').forEach(th=>{ th.classList.remove('asc','desc'); if(th.dataset.col===sort.col) th.classList.add(sort.dir>0?'asc':'desc'); }); }
// Web column shows the SITE NAME (domain), not a bare "site↗" — Steve 2026-07-13.
const siteName=u=>{ try{ return new URL(/^https?:/.test(u)?u:'https://'+u).hostname.replace(/^www\./,''); }catch(e){ return u; } };
const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const webLink=u=>{ if(!u) return '<span class="miss">—</span>'; const h=/^https?:/.test(u)?u:'https://'+u; return `<a href="${esc(h)}" target="_blank" rel="noopener noreferrer" style="color:var(--gold)" title="${esc(h)}">${esc(siteName(u))}↗</a>`; };
// On-demand email hunt (the "✉ find email" button). hunt[] survives the 3s poll re-renders;
// a found email also lands in broker.email server-side, so the next poll carries it natively.
const hunt={};
async function findEmail(id,name,website){
  hunt[id]={state:'hunting'}; renderBrokers(lastStats);
  try{
    const r=await (await fetch('/api/broker/find-email',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:+id,name,website})})).json();
    hunt[id]=r.email?{email:r.email,basis:r.basis}:{miss:r.reason||r.error||'not found'};
  }catch(e){ hunt[id]={miss:e.message}; }
  renderBrokers(lastStats);
}
function emailCell(b){
  if(b.email) return `<a href="mailto:${b.email}" class="has" style="color:var(--draft)">${b.email}</a>`;
  const h=hunt[b.id];
  if(h){
    if(h.state==='hunting') return '<span class="hunting">⏳ hunting…</span>';
    if(h.email) return `<a href="mailto:${h.email}" class="has" style="color:var(--draft)" title="found on site (${h.basis||''})">${h.email}</a>`;
    return `<span class="miss" title="${(h.miss||'').replace(/"/g,'&quot;')}">not found</span>`;
  }
  if(b.website) return `<button class="findmail" data-id="${b.id}" data-name="${(b.name||'').replace(/"/g,'&quot;')}" data-web="${(b.website||'').replace(/"/g,'&quot;')}" title="Visit ${siteName(b.website)} and hunt down an email address">✉ find email</button>`;
  return '<span class="miss">—</span>';
}
function renderBrokers(s){ if(!s) return; const rows=srt(s.topBrokers||[], brSort.col, brSort.dir);
  $('#brC').textContent=rows.length+' shown · click a broker · click a header to sort';
  $('#brTbl tbody').innerHTML=rows.map(b=>`<tr class="brow" data-id="${b.id||''}" data-name="${(b.name||'').replace(/"/g,'&quot;')}" style="cursor:pointer">
    <td>${b.name}${atype(b.agent_type)}<div class="firm">${b.firm||'—'}</div></td>
    <td class="n">${b.listings}</td>
    <td>${b.phone?`<a href="tel:${b.phone}" class="has" style="color:var(--active)">${b.phone}</a>`:'<span class="miss">—</span>'}</td>
    <td>${emailCell(b)}</td>
    <td>${webLink(b.website)}</td></tr>`).join('');
  document.querySelectorAll('.brow').forEach(r=>r.onclick=e=>{ if(e.target.tagName==='A'||e.target.tagName==='BUTTON') return; openContact(r.dataset.name, r.dataset.id); });
  document.querySelectorAll('#brTbl .findmail').forEach(x=>x.onclick=e=>{ e.stopPropagation(); findEmail(x.dataset.id, x.dataset.name, x.dataset.web); });
  markHdr('brTbl',brSort); }
function renderFirms(s){ if(!s) return; const rows=srt(s.topFirms||[], fmSort.col, fmSort.dir);
  $('#fmC').textContent='click a firm · click a header to sort';
  $('#fmTbl tbody').innerHTML=rows.map(f=>`<tr class="frow" data-firm="${(f.firm||'').replace(/"/g,'&quot;')}" style="cursor:pointer"><td>${f.firm}</td><td class="n">${f.listings}</td><td class="n" style="color:var(--draft)">${f.brokers}</td></tr>`).join('');
  document.querySelectorAll('.frow').forEach(r=>r.onclick=()=>openFirm(r.dataset.firm));
  markHdr('fmTbl',fmSort); }
// wire header clicks once
document.querySelectorAll('#brTbl th.srt').forEach(th=>th.onclick=()=>{ const c=th.dataset.col; if(brSort.col===c) brSort.dir*=-1; else {brSort.col=c; brSort.dir=-1;} renderBrokers(lastStats); });
document.querySelectorAll('#fmTbl th.srt').forEach(th=>th.onclick=()=>{ const c=th.dataset.col; if(fmSort.col===c) fmSort.dir*=-1; else {fmSort.col=c; fmSort.dir=-1;} renderFirms(lastStats); });

// Panel 7 — FHA approval expiring within 12 months. Own endpoint (daily-churn data → slow refresh,
// not the 3s poll). Soonest-to-lapse first; the Days cell reddens under 30d, ambers under 90d.
// Reuses the same srt()/markHdr() sort engine as the broker/firm tables.
let xpSort={col:'days',dir:1}, xpData=[], xpDays=+localStorage.getItem('crcp_xp_days')||365;
const xesc=s=>String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]));
function renderExpiring(){
  const rows=srt(xpData, xpSort.col, xpSort.dir);
  $('#xpC').textContent=rows.length+' projects · soonest first · click a header to sort';
  $('#xpTbl tbody').innerHTML=rows.map(r=>{
    const urg=r.days<=30?'color:#c0392b;font-weight:600':r.days<=90?'color:#b8860b':'';
    return `<tr class="xprow" data-q="${xesc(r.project)}" style="cursor:pointer" title="Open ${xesc(r.project)} in the Condos page">`+
      `<td>${xesc(r.project)}</td><td>${xesc(r.city)}</td><td>${xesc(r.zip)}</td><td>${xesc(r.expires)}</td><td class="n" style="${urg}">${r.days<=30?'⚠️ ':''}${r.days}d</td></tr>`;
  }).join('');
  // each expiring project drills to its record ("all data points href", per contrarian gate)
  document.querySelectorAll('#xpTbl .xprow').forEach(tr=>tr.onclick=()=>{ location.href='/condos.html?status=fha_approved&q='+encodeURIComponent(tr.dataset.q); });
  markHdr('xpTbl',xpSort);
}
document.querySelectorAll('#xpTbl th.srt').forEach(th=>th.onclick=()=>{ const c=th.dataset.col; if(xpSort.col===c) xpSort.dir*=-1; else {xpSort.col=c; xpSort.dir=1;} renderExpiring(); });
async function loadExpiring(){ try{ const d=await (await fetch('/api/fha-expiring?days='+xpDays)).json(); xpData=d.condos||[]; renderExpiring(); }catch(e){ $('#xpC').textContent='reconnecting…'; } }
// window toggle (6/12/24 mo): re-query + persist; reflects the persisted choice on load.
(function(){
  const wrap=document.querySelector('[data-xp-win]'), winLbl=document.getElementById('xpWin'); if(!wrap) return;
  const sync=b=>{ wrap.querySelectorAll('.wtab').forEach(x=>x.classList.toggle('on',x===b)); if(winLbl) winLbl.textContent=b.dataset.months; };
  const init=[...wrap.querySelectorAll('.wtab')].find(b=>+b.dataset.days===xpDays); if(init) sync(init);
  wrap.querySelectorAll('.wtab').forEach(b=>b.addEventListener('click',()=>{ xpDays=+b.dataset.days; localStorage.setItem('crcp_xp_days',xpDays); sync(b); loadExpiring(); }));
})();
loadExpiring(); setInterval(loadExpiring, 300000);

async function poll(){
  let s; try{ s=await (await fetch('/api/crcp/stats')).json(); }catch(e){ $('#livetxt').textContent='reconnecting…'; return; }
  lastStats=s;
  $('#livetxt').textContent='live · '+new Date(s.ts).toLocaleTimeString();
  // tiles
  $('#tiles').innerHTML = [
    tile('Listings','listings',s.listings,s.market||'LA County','gold','/mls.html'),
    tile('Brokers','brokers',s.brokers,(s.colist!=null?s.colist+' co-list links':''),'active','/broker-grid.html'),
    tile('Firms','firms',s.firms,'brokerages','active','/firms.html'),
    tile('Condos scraped','condos',s.condos,(s.condosByStatus?Object.entries(s.condosByStatus).map(([k,v])=>v+' '+k.replace('_',' ')).join(' · '):'awaiting Job A'),'staged','/condos.html'),
    tile('Residential agents','agents_residential',s.agents_residential||0,((s.condos_with_agent||0)+' condos w/ agent · '+(s.agents_res_phone||0)+'☎ '+(s.agents_res_email||0)+'✉'),'draft','/broker-grid.html?type=residential'),
    tile('FHA-approved','fhaApproved',s.fhaApproved,'warrantable (proxy)','active','/condos.html?status=fha_approved'),
    tile('Broker edges','edges',s.edges,'broker↔listing','gold','/brokers.html')
  ].join('');
  // Every stat tile drills to its filtered-list page ("all data points href", TK-10092).
  document.querySelectorAll('#tiles .tile[data-href]').forEach(t=>{ const h=t.dataset.href;
    if(h){ t.classList.add('clickable'); t.title='Open '+h.replace(/^\//,'').replace('.html',''); t.onclick=()=>{ location.href=h; }; } });
  // FHA-approved tile: left-click opens the warrantability modal (richer than a page);
  // data-href=/condos.html?status=fha_approved stays as the right-click/keyboard/scraper fallback.
  const fhaTile=document.querySelector('#tiles .tile[data-key="fhaApproved"]');
  if(fhaTile){ fhaTile.classList.add('clickable'); fhaTile.title='Click to list the FHA-approved condos';
    fhaTile.onclick=()=>{ window._waQ=''; openWarrant('fha_approved'); }; }
  // enrichment bars (the LIVE populating)
  // header count is itself a drill into the broker grid (stopPropagation so it doesn't toggle panel collapse)
  $('#enrLbl').innerHTML = `<span class="clink" title="Open the broker grid">${(s.brokers_phone||0)} phone · ${(s.brokers_email||0)} email ↗</span>`;
  const enrLink=$('#enrLbl .clink'); if(enrLink) enrLink.onclick=e=>{ e.stopPropagation(); location.href='/broker-grid.html'; };
  // each enrichment count drills to the broker grid (where phone/email/website are columns) — "all data points href"
  $('#enrBars').innerHTML =
      `<div class="wclk" title="Open the broker grid — phone column">`+bar('Phone captured', s.brokers_phone||0, s.brokers, 'var(--active)')+`</div>`
    + `<div class="wclk" title="Open the broker grid — email column">`+bar('Email captured', s.brokers_email||0, s.brokers, 'var(--draft)')+`</div>`
    + (s.brokers_web!=null?`<div class="wclk" title="Open the broker grid — website column">`+bar('Website found', s.brokers_web, s.brokers, 'var(--gold)')+`</div>`:'');
  document.querySelectorAll('#enrBars .wclk').forEach(el=>el.onclick=()=>{ location.href='/broker-grid.html'; });
  const enr=(s.brokers_phone||0)+(s.brokers_email||0);
  const penr=(prev.brokers_phone||0)+(prev.brokers_email||0);
  $('#enrMeta').innerHTML = enr>penr ? `<span class="has">▲ +${enr-penr} contacts since last refresh — Job B populating…</span>` : (enr===0?'Job B (broker enrichment) queued — contacts will populate here live.':'enrichment idle / complete');
  renderBrokers(s);
  // warrantability
  const waTot=(s.fhaApproved||0)+(s.fhaExpired||0);
  $('#wa').innerHTML =
      `<div class="wclk" data-st="fha_approved" title="Click to list the ${fmt(s.fhaApproved||0)} FHA-approved condos">`
        + bar('FHA-approved', s.fhaApproved||0, waTot, 'var(--active)') + `</div>`
    + `<div class="wclk" data-st="fha_expired" title="Click to list the ${fmt(s.fhaExpired||0)} FHA-expired condos">`
        + bar('FHA-expired (flag/review)', s.fhaExpired||0, waTot, 'var(--staged)') + `</div>`;
  // Panel 4 — FHA reference-list breakdown: caption the full HUD registry size + approval-method split.
  $('#waC').textContent = fmt(s.fhaTotal||waTot)+' HUD reference projects'
    + (s.fhaMethods ? ' · HRAP '+fmt(s.fhaMethods.HRAP||0)+' / DELRAP '+fmt(s.fhaMethods.DELRAP||0) : '');
  document.querySelectorAll('#wa .wclk').forEach(el=>el.onclick=()=>{ window._waQ=''; openWarrant(el.dataset.st); });
  // unwarrantable = scraped condos NOT FHA-approved (the non-QM lending opportunity)
  const _unwarr=(s.condos||0)-((s.condosByStatus&&s.condosByStatus.fha_approved)||0);
  const _uc=$('#unwarrC'); if(_uc) _uc.textContent = _unwarr>0 ? fmt(_unwarr) : '';
  renderFirms(s);
  prev=s;
  // flash changed tiles
  document.querySelectorAll('.delta.show').forEach(d=>d.closest('.tile').classList.add('flash'));
  setTimeout(()=>document.querySelectorAll('.flash').forEach(e=>e.classList.remove('flash')),800);
}
// ---- collapse-all-until-activated + density slider (persisted) ----
(function initPanels(){
  const LS_OPEN='crcp_open_panels', LS_DENS='crcp_dens';
  // restore density
  const dens=$('#dens'), saved=+localStorage.getItem(LS_DENS)||150;
  dens.value=saved; document.documentElement.style.setProperty('--tilemin', saved+'px');
  dens.addEventListener('input',()=>{ document.documentElement.style.setProperty('--tilemin', dens.value+'px'); localStorage.setItem(LS_DENS, dens.value); });
  // which panels were left open last time (default: ALL collapsed)
  let open; try{ open=new Set(JSON.parse(localStorage.getItem(LS_OPEN)||'[]')); }catch(e){ open=new Set(); }
  const fresh = localStorage.getItem(LS_OPEN)===null;          // no saved layout yet (TK-10330)
  const save=()=>localStorage.setItem(LS_OPEN, JSON.stringify([...open]));
  const panels=[...document.querySelectorAll('.panel')];
  panels.forEach((p,i)=>{
    const h=p.querySelector('h3'); if(!h) return;
    const key=h.textContent.trim().slice(0,40)||('p'+i); p.dataset.pk=key;
    if(!h.querySelector('.caret')){ const c=document.createElement('span'); c.className='caret'; c.textContent='▸'; h.prepend(c); }
    // TK-10330: land useful, not a wall of closed drawers. On a FRESH session open the
    // primary deal-sourcing workflow panels (segments -> call list -> firms -> urgency);
    // returning users keep whatever layout they saved.
    if(fresh){
      const KT=key.toLowerCase();
      const seedOpen = KT.includes('call segments')||KT.includes('top brokers')||KT.includes('top firms')||KT.includes('fha approval expiring');
      if(seedOpen) open.add(key); else p.classList.add('collapsed');
    }
    else if(!open.has(key)) p.classList.add('collapsed');      // start collapsed until activated
    h.addEventListener('click',()=>{
      p.classList.toggle('collapsed');
      if(p.classList.contains('collapsed')) open.delete(key); else open.add(key);
      save(); syncExpandBtn();
    });
  });
  const btn=$('#expandall');
  function syncExpandBtn(){ const allOpen=panels.every(p=>!p.classList.contains('collapsed'));
    btn.textContent = allOpen?'Collapse all ▴':'Expand all ▾'; }
  btn.addEventListener('click',()=>{
    const allOpen=panels.every(p=>!p.classList.contains('collapsed'));
    panels.forEach(p=>{ const key=p.dataset.pk;
      if(allOpen){ p.classList.add('collapsed'); open.delete(key); }
      else { p.classList.remove('collapsed'); open.add(key); } });
    save(); syncExpandBtn();
  });
  syncExpandBtn();
})();
poll(); setInterval(poll, 3000);
loadSegments(); setInterval(loadSegments, 20000);
// deep-link from the graphics dashboard: ?broker=<id>[&name=…]  ·  ?firm=<name>
(function(){ try{
  const P=new URLSearchParams(location.search);
  const bid=P.get('broker'), fm=P.get('firm'), bn=P.get('brokerName');
  if(bid){ openContact(P.get('name')||'', bid); }
  else if(bn){ openContact(bn, null); }   // name-only path (e.g. from a condo detail modal)
  else if(fm){ openFirm(fm); }
}catch(e){} })();

// ── Solicitation Studio — RENTV-style outreach content for cold & warm solicitations ──
(function(){
  const AUD=[
    {key:'unwarrantable',label:'Unwarrantable-condo owners',cnt:s=>((s&&s.condos)||0)-((s&&s.condosByStatus&&s.condosByStatus.fha_approved)||0),
      who:'owners in condo buildings that aren’t FHA-approved',pain:'conventional and FHA lenders keep declining their building',
      offer:'a non-QM / portfolio condo loan',proof:'we close the condos other lenders won’t touch',drill:'/condos.html?status=unwarrantable'},
    {key:'fha_expired',label:'FHA-expired HOAs',cnt:s=>(s&&s.fhaExpired)||0,
      who:'HOA boards whose FHA approval has lapsed',pain:'nobody in the building can buy with FHA financing until it’s re-certified',
      offer:'FHA re-certification help + bridge financing',proof:'we get buildings re-approved and buyers to the closing table',drill:'/condos.html?status=fha_expired'},
    {key:'res_agents',label:'Residential agents',cnt:s=>(s&&s.agents_residential)||0,
      who:'residential agents with buyers stuck on tricky condos',pain:'their buyers keep getting denied on non-warrantable or lapsed-FHA condos',
      offer:'a lender partner who says yes on the hard condo deals',proof:'send me the deal your last lender killed',drill:'/broker-grid.html?type=residential'},
    {key:'brokers',label:'Commercial brokers',cnt:s=>(s&&s.brokers)||0,
      who:'commercial brokers with multifamily / mixed-use listings',pain:'their buyers need creative financing to close',
      offer:'CRE / bridge financing',proof:'we fund the deals banks slow-walk',drill:'/broker-grid.html'},
    {key:'realtor_dscr',label:'Referring agents (DSCR)',cnt:s=>(s&&s.agents_residential)||0,
      who:'agents whose buyer got turned down for a tax-return-heavy commercial loan',pain:'the deal died in underwriting over docs, not the property',
      offer:'a DSCR loan that qualifies on the property’s own rents — no tax returns',proof:'send me the rent roll and I’ll tell you same-day if it pencils',drill:'/broker-grid.html?type=residential'},
  ];
  let tone='cold', type='ad', audKey='unwarrantable';
  const SIGN='— Frank · your commercial-lending desk';
  function gen(a){
    const cold=tone==='cold', open=cold?`I work with ${a.who} and noticed ${a.pain}.`:`Great connecting — following up on ${a.who} where ${a.pain}.`;
    if(type==='ad') return {subject:`Ad · ${a.label} · ${cold?'cold':'warm'}`,
      body:`HEADLINE: ${cold?'Turned down on the condo?':'Still working that condo deal?'}\n\n${open}\nWe offer ${a.offer} — ${a.proof}.\n\nCTA: Reply "RATE" for a same-day scenario review.\n${SIGN}`};
    if(type==='video') return {subject:`Video script (~25s) · ${a.label} · ${cold?'cold':'warm'}`,
      body:`[0-4s]  ON-SCREEN: "${cold?'Condo financing falling through?':'Let’s close that condo.'}"\n[4-12s] VO: ${open}\n[12-20s] VO: We offer ${a.offer}. ${a.proof}.\n[20-25s] ON-SCREEN + VO: "Send me the deal." ${SIGN}\nB-ROLL: condo exteriors, a signed closing, a handshake.`};
    if(type==='music') return {subject:`Music / jingle brief · ${a.label} · ${cold?'cold':'warm'}`,
      body:`MOOD: confident, warm, trustworthy (not salesy).\nTEMPO: 90-100 BPM, light piano + soft percussion.\nLENGTH: 15s bed + 5s tag.\nHOOK LINE (sung/spoken): "When the bank says no — we find the yes."\nVOICE TAG: "${a.offer}. ${a.proof}."\nUSE: bed under the ${a.label.toLowerCase()} ${type} + social reels.\n${SIGN}`};
    return {subject:`Social post · ${a.label} · ${cold?'cold':'warm'}`,
      body:`${cold?'🏢 ':'👋 '}${open}\n\nWe offer ${a.offer}. ${a.proof}.\n\nDM "RATE" for a same-day scenario review.\n#CREfinance #nonQM #condoloans #realestate #${a.key}`};
  }
  function render(){
    const a=AUD.find(x=>x.key===audKey)||AUD[0];
    const out=gen(a), q=(lastStats||{}), n=a.cnt(q);
    const el=document.getElementById('solOut'); if(!el) return;
    el.innerHTML=`<h4>${out.subject}</h4>`+
      `<div class="subj">Audience: ${a.label}${n?` · ~${fmt(n)} in pipeline`:''} · <a href="${a.drill}" style="color:var(--gold)">see the list ↗</a></div>`+
      `<pre id="solText"></pre>`+
      `<div class="solactions"><button class="prime" id="solCopy">📋 Copy</button>`+
      `<button id="solQueue">🎬 Queue to producer</button>`+
      `<button id="solRe">↻ Re-draft</button></div>`+
      `<div class="solgate">Draft only — video/music/ad rendering &amp; any publish are gated (queued for Steve, never auto-sent).</div>`;
    el.querySelector('#solText').textContent=out.body;
    el.querySelector('#solCopy').onclick=()=>{ navigator.clipboard&&navigator.clipboard.writeText(out.body); const b=el.querySelector('#solCopy'); b.textContent='✓ Copied'; setTimeout(()=>b.textContent='📋 Copy',1200); };
    el.querySelector('#solRe').onclick=render;
    el.querySelector('#solQueue').onclick=()=>{
      const b=el.querySelector('#solQueue'); b.disabled=true; b.textContent='queuing…';
      fetch('/api/solicitation/queue',{method:'POST',headers:{'content-type':'application/json'},
        body:JSON.stringify({tone,type,audience:a.key,subject:out.subject,body:out.body})})
        .then(r=>r.json()).then(d=>{ b.textContent=d&&d.ok?('✓ Queued #'+(d.count||'')+' — gated'):'✓ Queued (draft)'; })
        .catch(()=>{ b.textContent='✓ Queued (local draft)'; });
    };
  }
  function buildAud(){
    const wrap=document.getElementById('solAud'); if(!wrap) return;
    const q=lastStats||{};
    wrap.innerHTML=AUD.map(a=>`<button type="button" class="achip${a.key===audKey?' on':''}" data-k="${a.key}">${a.label}<span class="n">${fmt(a.cnt(q))}</span></button>`).join('');
    wrap.querySelectorAll('.achip').forEach(c=>c.onclick=()=>{ audKey=c.dataset.k; wrap.querySelectorAll('.achip').forEach(x=>x.classList.toggle('on',x===c)); render(); });
  }
  const tw=document.getElementById('solTone'); if(tw) tw.querySelectorAll('.soltab').forEach(b=>b.onclick=()=>{ tone=b.dataset.tone; tw.querySelectorAll('.soltab').forEach(x=>x.classList.toggle('on',x===b)); render(); });
  const ty=document.getElementById('solTypes'); if(ty) ty.querySelectorAll('.soltype').forEach(b=>b.onclick=()=>{ type=b.dataset.type; ty.querySelectorAll('.soltype').forEach(x=>x.classList.toggle('on',x===b)); render(); });
  buildAud(); render();
  // refresh the audience counts once live stats arrive
  let _tries=0; const _t=setInterval(()=>{ if(lastStats||_tries++>20){ buildAud(); render(); clearInterval(_t); } }, 1500);
})();

// ── DSCR Qualifier — rents ÷ PITI, no-tax-returns qualification + Frank's referral note ──
(function(){
  const panel=document.getElementById('dscrPanel'); if(!panel) return;
  let basis='current', pitiMode='build', payType='pi';
  // Monthly P&I from loan/rate/amort — reuse the ranker's amortization (finance.js) so the desk
  // and the property ranker never disagree; fall back to an inline standard mortgage formula if
  // finance.js didn't load for any reason.
  function monthlyPI(loan, ratePct, amortYears){
    if(!(loan>0)) return 0;
    if(window.CREFin && CREFin.debtService) return CREFin.debtService(loan, ratePct, amortYears)/12;
    const r=ratePct/100/12, n=amortYears*12;
    return r===0 ? loan/n : loan*(r*Math.pow(1+r,n))/(Math.pow(1+r,n)-1);
  }
  const num=id=>{ const el=document.getElementById(id); const v=el?parseFloat(el.value):NaN; return isFinite(v)?v:0; };
  const usd=n=>'$'+Math.round(n).toLocaleString();
  // ── rent roll ──
  const DEF=[1850,1850,1850,1850,1850,1850]; // seeds the six-unit story
  function unitRows(){ return Array.from(document.querySelectorAll('#dUnits .unitrow')); }
  function grossMonthly(){ return unitRows().reduce((s,r)=>{ const v=parseFloat(r.querySelector('.urent').value); return s+(isFinite(v)?v:0); },0); }
  function addUnit(rent){
    const wrap=document.getElementById('dUnits'); const i=unitRows().length+1;
    const row=document.createElement('div'); row.className='unitrow';
    row.innerHTML=`<input type="text" class="ulabel" value="Unit ${i}" aria-label="Unit label">`+
      `<input type="number" class="urent" value="${rent!=null?rent:1800}" min="0" step="25" aria-label="Monthly rent">`+
      `<button type="button" class="udel" title="Remove unit">×</button>`;
    row.querySelector('.urent').addEventListener('input',recompute);
    row.querySelector('.udel').addEventListener('click',()=>{ if(unitRows().length>1){ row.remove(); recompute(); } });
    wrap.appendChild(row);
  }
  function verdict(dscr, thresh){
    if(dscr==null||!isFinite(dscr)) return {cls:'',txt:'—'};
    if(dscr>=thresh) return {cls:'pass',txt:'✓ Approves — no tax returns'};
    if(dscr>=1.0)    return {cls:'marg',txt:'⚠ Marginal — covers, below the line'};
    return {cls:'fail',txt:'✕ Short — rents don’t cover PITI'};
  }
  function buildNote(g){
    const addr=(document.getElementById('dAddr').value||'').trim()||'that property';
    const units=unitRows().length, basisWord=basis==='projected'?'projected fair-market':'current';
    const cap=basisWord[0].toUpperCase()+basisWord.slice(1);
    const rentsLbl = g.vac>0 ? `${cap} rents at ${g.vac}% vacancy` : `${cap} rents`;
    const rentsVal = g.vac>0 ? `${usd(g.eff)}/mo effective (${usd(g.gross)} gross)` : `${usd(g.gross)}/mo  (${usd(g.gross*12)}/yr)`;
    const payLbl = (pitiMode==='build'&&g.payType==='io') ? 'Interest-only payment' : 'Full payment (PITI)';
    let line, close;
    if(g.dscr==null){ line='the payment nets to zero'; close='Send me the rent roll and I’ll pencil it.'; }
    else if(g.dscr>=g.thresh){ line=`a ${g.dscr.toFixed(2)} DSCR — clears the ${g.thresh.toFixed(2)} line with ${usd(g.surplus)}/mo to spare`; close='It qualifies as-is — happy to send terms and get an app started whenever your seller’s ready.'; }
    else if(g.dscr>=1.0){ line=`a ${g.dscr.toFixed(2)} DSCR — the rents cover the payment but it’s under a typical ${g.thresh.toFixed(2)} approval line`; close=`We can still get there: about ${usd(g.rentsNeeded-g.eff)}/mo more in rent, a bit more down, or a lender at ${g.dscr.toFixed(2)} min gets it done. Want me to run those?`; }
    else { line=`a ${g.dscr.toFixed(2)} DSCR — the rents fall about ${usd(g.shortfall)}/mo short of the payment`; close=`It doesn’t pencil at asking on rents alone — projected fair-market rents or more down would move it. Want me to model it?`; }
    return `Hey — thanks for the list. I called the agent on ${addr} and ran DSCR numbers on it as an alternative to commercial financing on that ${units}-unit.\n\n`+
      `The nice part: it’s a DSCR loan, so no tax returns — it qualifies on the building’s own rents.\n\n`+
      `  • ${rentsLbl}:  ${rentsVal}\n`+
      `  • ${payLbl}:  ${usd(g.piti)}/mo\n`+
      `  • DSCR:  ${g.dscr==null?'—':g.dscr.toFixed(2)}  →  ${line}\n\n`+
      `${close}\n\n`+
      `Happy to run a same-day scenario on any of the others on your list.\n— Frank · your commercial-lending desk`;
  }
  function recompute(){
    const gross=grossMonthly();
    const vac=Math.max(0,Math.min(100,num('dVac')));   // vacancy %; 0 = fully occupied
    const eff=gross*(1-vac/100);                        // effective rents used for DSCR
    document.getElementById('dRentSum').textContent=usd(gross)+(vac>0?` → ${usd(eff)} @ ${vac}% vac`:'');
    document.getElementById('dRentAnn').textContent=usd((vac>0?eff:gross)*12);
    let piti, pi=null;
    if(pitiMode==='build'){
      // interest-only = loan × monthly rate; else amortizing P&I (CREFin, single source of truth)
      pi = payType==='io' ? num('dLoan')*(num('dRate')/100/12) : monthlyPI(num('dLoan'),num('dRate'),num('dAmort')||30);
      piti = pi + num('dTax') + num('dIns') + num('dHoa');
    } else { piti = num('dPiti'); }
    const thresh=num('dThresh')||1.20;
    const dscr = piti>0 ? eff/piti : null;             // DSCR on EFFECTIVE rents
    const g={ gross, eff, vac, payType, piti, thresh, dscr,
      surplus: eff-piti, shortfall: piti-eff,
      maxPiti: thresh>0 ? eff/thresh : 0,               // most PITI these rents support at the approval line
      rentsNeeded: piti*thresh };                       // effective rents needed to hit the approval line
    // headline
    document.getElementById('dRatio').textContent = dscr==null?'—':dscr.toFixed(2);
    const v=verdict(dscr,thresh), badge=document.getElementById('dBadge');
    badge.className='dbadge '+v.cls; badge.textContent=v.txt;
    document.getElementById('dRatio').style.color = v.cls==='pass'?'var(--active)':v.cls==='marg'?'var(--staged)':v.cls==='fail'?'var(--del)':'var(--fg)';
    // breakdown
    const gap = dscr==null?'':(dscr>=thresh
      ? `<div class="r"><span>Monthly surplus over payment</span><b style="color:var(--active)">${usd(g.surplus)}</b></div>`
      : `<div class="r"><span>Rents needed to hit ${thresh.toFixed(2)}</span><b>${usd(g.rentsNeeded)}/mo</b></div>`+
        `<div class="r"><span>${eff>=piti?'Cushion above payment':'Monthly shortfall'}</span><b style="color:${eff>=piti?'var(--active)':'var(--del)'}">${usd(Math.abs(g.surplus))}</b></div>`);
    document.getElementById('dBrk').innerHTML =
      `<div class="r"><span>Gross monthly rents</span><b>${usd(gross)}</b></div>`+
      (vac>0?`<div class="r"><span>Effective rents (${vac}% vacancy)</span><b>${usd(eff)}</b></div>`:'')+
      `<div class="r"><span>PITI (${pitiMode==='build'&&payType==='io'?'interest-only':'payment'})</span><b>${usd(piti)}</b></div>`+
      `<div class="r hi"><span>Max payment these rents support @ ${thresh.toFixed(2)}</span><b>${usd(g.maxPiti)}/mo</b></div>`+
      gap;
    // Frank's note
    document.getElementById('dNote').textContent = buildNote(g);
    return g;
  }
  // ── wire toggles ──
  document.querySelectorAll('#dRentBasis button').forEach(b=>b.onclick=()=>{ basis=b.dataset.v; b.parentNode.querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b)); recompute(); });
  document.querySelectorAll('#dPitiMode button').forEach(b=>b.onclick=()=>{ pitiMode=b.dataset.v; b.parentNode.querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b)); document.getElementById('dBuild').style.display=pitiMode==='build'?'':'none'; document.getElementById('dDirect').style.display=pitiMode==='direct'?'':'none'; recompute(); });
  document.querySelectorAll('#dPayType button').forEach(b=>b.onclick=()=>{ payType=b.dataset.v; b.parentNode.querySelectorAll('button').forEach(x=>x.classList.toggle('on',x===b)); recompute(); });
  ['dLoan','dRate','dAmort','dTax','dIns','dHoa','dPiti','dThresh','dAddr','dVac'].forEach(id=>{ const el=document.getElementById(id); if(el) el.addEventListener('input',recompute); });
  document.getElementById('dAddUnit').onclick=()=>{ addUnit(1800); recompute(); };
  document.getElementById('dCopy').onclick=()=>{ const t=document.getElementById('dNote').textContent; navigator.clipboard&&navigator.clipboard.writeText(t); const b=document.getElementById('dCopy'); b.textContent='✓ Copied'; setTimeout(()=>b.textContent='📋 Copy note',1200); };
  document.getElementById('dQueue').onclick=()=>{
    const b=document.getElementById('dQueue'); b.disabled=true; b.textContent='queuing…';
    const g=recompute(), addr=(document.getElementById('dAddr').value||'').trim()||'DSCR scenario';
    fetch('/api/solicitation/queue',{method:'POST',headers:{'content-type':'application/json'},
      body:JSON.stringify({tone:'warm',type:'dscr-note',audience:'realtor_dscr',
        subject:`DSCR note · ${addr} · ${g.dscr==null?'—':g.dscr.toFixed(2)}`,body:document.getElementById('dNote').textContent})})
      .then(r=>r.json()).then(d=>{ b.textContent=d&&d.ok?('✓ Queued #'+(d.count||'')+' — gated'):'✓ Queued (draft)'; })
      .catch(()=>{ b.textContent='✓ Queued (local draft)'; })
      .finally(()=>{ setTimeout(()=>{ b.disabled=false; b.textContent='🎬 Queue to producer'; },2500); });
  };
  // seed the six-unit rent roll and compute once
  DEF.forEach(r=>addUnit(r));
  recompute();
})();
</script>
  <script src="/col-resize.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script src="/crcp-theme.js" defer></script>
<script>
// Day/Night theme — pre-applied before first paint (see head snippet).
// apply() only PAINTS — does NOT persist (passive loads must not lock in a theme;
// only an explicit toggle click below saves a pref, so everyone gets the Day default).
(function(){
  var root=document.documentElement;
  function applyTheme(t){ root.setAttribute('data-theme',t); }
  var cur; try{ cur=localStorage.getItem('crcp-theme'); }catch(e){}
  applyTheme(cur==='dark'?'dark':'light');
  // Wire up the toggle button injected by crcp-theme.js (defer — may not exist yet;
  // observe the header for it and intercept its click to persist crcp-theme).
  function wireToggle(){
    var btn=document.getElementById('crcpThemeToggle'); if(!btn||btn.__wired2) return;
    btn.__wired2=true;
    btn.addEventListener('click',function(e){
      e.stopImmediatePropagation();   // suppress crcp-theme.js's own handler so we don't double-toggle
      var nt=root.getAttribute('data-theme')==='light'?'dark':'light';
      applyTheme(nt); try{ localStorage.setItem('crcp-theme',nt); }catch(e){}
    },true);   // capture phase — fires before crcp-theme.js's own click handler
  }
  if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',wireToggle); else wireToggle();
  // also wire after defer scripts finish (crcp-theme.js injects the button on DOMContentLoaded)
  document.addEventListener('DOMContentLoaded',wireToggle);
})();
</script>
<script>
// ── TK-10330: honest loading / connection state ────────────────────────────
// The cold-load used to read as "broken": empty KPI strip + empty panels while a
// green "live" pulse sat next to "reconnecting…". This adds (a) skeleton KPI tiles
// so the strip is never blank, (b) a real error banner, and (c) an honest amber
// pulse while reconnecting. Purely additive — poll() still owns the real render.
(function(){
  function seedSkeletonTiles(){
    var t=document.getElementById('tiles');
    if(!t || t.children.length) return;               // poll() already filled it
    var labels=['Listings','Brokers','Firms','Condos','Res. agents','FHA-approved','Broker edges'];
    var cls=['gold','active','draft','staged','draft','active','gold'];
    t.innerHTML=labels.map(function(k,i){
      return '<div class="tile skel '+cls[i]+'"><div class="k">'+k+'</div><div class="v">—</div><div class="sub"> </div></div>';
    }).join('');
  }
  function ensureBanner(){
    var w=document.querySelector('.wrap'); if(!w) return null;
    var b=document.getElementById('crcpConnBanner');
    if(!b){ b=document.createElement('div'); b.id='crcpConnBanner';
      b.textContent='⚠ Can’t reach the server — showing the last known data. Retrying…';
      w.insertBefore(b, w.firstChild); }
    return b;
  }
  function tick(){
    var lt=document.getElementById('livetxt'); var pulse=document.querySelector('.pulse'); var b=ensureBanner();
    var reconnecting = lt && /reconnect/i.test(lt.textContent||'');
    if(pulse) pulse.classList.toggle('reconnecting', !!reconnecting);
    if(b) b.classList.toggle('show', !!reconnecting);
    // once real tiles arrive, the skeleton class is naturally gone (poll replaces innerHTML)
  }
  function start(){ seedSkeletonTiles(); tick(); setInterval(tick, 1500); }
  if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',start); else start();
})();
</script>
  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>