[object Object]

← back to Commercialrealestate

Agent CRM: all-inline (no outside links) + inline Notes field

4d0bb9f52cab3c1e354aabe879983bbf68e70aca · 2026-07-17 09:30:33 -0700 · Steve

- Removed every external link from the listing/agent surfaces: address no longer links to source;
  dropped firm/agent Google + LinkedIn + DRE searches, LoopNet, and 'View listing'. No target=_blank.
- Firm cell is now an in-app filter (click = show only that firm) instead of a Google link.
- Phone/email are inline display + copy (⧉) buttons, not tel:/mailto: that launch other apps.
  Source listing URL + live-comp URLs are copy-buttons too, so nothing auto-opens a tab.
- Contact modal is now the inline 'Agent window' with a persisted Notes field (saved with Save info);
  notes surface as a 🗒 chip on the row/card and render inline in the row panel.
- Kept the genuinely-inline panels (Live comps, Property history render in-place).

Verified headless: 0 external/tel/mailto links in table + modal, firm filters inline, notes save->chip->inline render, 0 errors. (Separate 'Best Leads for Frank' broker panel still has its own external contact links — left untouched.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 4d0bb9f52cab3c1e354aabe879983bbf68e70aca
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 17 09:30:33 2026 -0700

    Agent CRM: all-inline (no outside links) + inline Notes field
    
    - Removed every external link from the listing/agent surfaces: address no longer links to source;
      dropped firm/agent Google + LinkedIn + DRE searches, LoopNet, and 'View listing'. No target=_blank.
    - Firm cell is now an in-app filter (click = show only that firm) instead of a Google link.
    - Phone/email are inline display + copy (⧉) buttons, not tel:/mailto: that launch other apps.
      Source listing URL + live-comp URLs are copy-buttons too, so nothing auto-opens a tab.
    - Contact modal is now the inline 'Agent window' with a persisted Notes field (saved with Save info);
      notes surface as a 🗒 chip on the row/card and render inline in the row panel.
    - Kept the genuinely-inline panels (Live comps, Property history render in-place).
    
    Verified headless: 0 external/tel/mailto links in table + modal, firm filters inline, notes save->chip->inline render, 0 errors. (Separate 'Best Leads for Frank' broker panel still has its own external contact links — left untouched.)
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 public/index.html | 108 +++++++++++++++++++++++++++---------------------------
 scripts/serve.js  |   1 +
 2 files changed, 56 insertions(+), 53 deletions(-)

diff --git a/public/index.html b/public/index.html
index 4d1785c..a13a3be 100644
--- a/public/index.html
+++ b/public/index.html
@@ -249,6 +249,18 @@
   .composeCard .cm-info .crmf { background:#0b0e13; border:1px solid #2a3340; border-radius:8px; color:var(--ink); font-size:12px; padding:6px 8px; min-width:140px; flex:1 1 140px; margin:0; }
   .composeCard .cm-info .crmf:focus { outline:none; border-color:var(--blue); }
   .composeH .flink { font-size:11px; font-weight:400; margin-left:8px; }
+  /* Inline (no-outside-link) agent bits */
+  .aname { font-weight:600; color:var(--ink); }
+  .flink { cursor:pointer; }
+  .listtbl td .flink { border-bottom:1px dotted #3a4453; }
+  .listtbl td .flink:hover { color:var(--blue); border-bottom-color:var(--blue); }
+  .copyb { background:transparent; border:0; color:var(--mut); cursor:pointer; font-size:11px; padding:0 3px; border-radius:4px; }
+  .copyb:hover { color:var(--blue); }
+  .crm-note, .lexpbody .crm-note { margin-top:6px; font-size:12px; color:var(--mut); white-space:pre-wrap; background:#0c1017; border:1px solid #232b36; border-radius:8px; padding:6px 9px; text-align:left; }
+  .composeCard .cm-notes { margin-bottom:10px; }
+  .composeCard .cm-notes .deeplbl { margin-bottom:4px; }
+  .composeCard .cm-notes textarea { width:100%; box-sizing:border-box; background:#0b0e13; border:1px solid #2a3340; border-radius:8px; color:var(--ink); font-size:12.5px; padding:7px 9px; font-family:inherit; resize:vertical; line-height:1.5; }
+  .composeCard .cm-notes textarea:focus { outline:none; border-color:var(--blue); }
   /* ---- Amazon-style docked left filter rail (desktop) ---- */
   .shell { display:flex; align-items:flex-start; }
   main { flex:1 1 auto; min-width:0; }
@@ -808,7 +820,7 @@ function renderList(rows){
   const head='<th scope="col" class="stick sortable" data-sortk="addr" title="Sort by address">Property'+arrow('addr')+'</th>'+cols.map(c=>`<th scope="col" class="sortable" data-sortk="${c.k}" title="Sort by ${esc(c.label)}">${esc(c.label)}${arrow(c.k)}</th>`).join('');
   const colspan=cols.length+1;
   const body=rows.map(p=>`<tr class="lrow" data-id="${p.id}" title="Click the row to open agent contact + deeper searches">`
-    +`<td class="stick"><span class="exptog" aria-hidden="true">▸</span><button class="starbtn ${SHORT.has(p.id)?'on':''}" data-star="${p.id}" aria-label="${SHORT.has(p.id)?'Remove from':'Add to'} shortlist" aria-pressed="${SHORT.has(p.id)}" title="Shortlist">★</button> <a class="la" href="${safeUrl(p.source)}" target="_blank" rel="noopener noreferrer">${esc(p.address)}</a><div class="lc">${esc(p.city)}, CA ${p.zip||''}</div></td>`
+    +`<td class="stick"><span class="exptog" aria-hidden="true">▸</span><button class="starbtn ${SHORT.has(p.id)?'on':''}" data-star="${p.id}" aria-label="${SHORT.has(p.id)?'Remove from':'Add to'} shortlist" aria-pressed="${SHORT.has(p.id)}" title="Shortlist">★</button> <span class="la">${esc(p.address)}</span><div class="lc">${esc(p.city)}, CA ${p.zip||''}</div></td>`
     +cols.map(c=>`<td>${colCell(c.k,p)}</td>`).join('')+`</tr>`
     +`<tr class="lexp" data-for="${p.id}"><td colspan="${colspan}"><div class="lexpbody" id="exp-${p.id}"></div></td></tr>`).join('');
   return `<div class="listwrap"><table class="listtbl"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
@@ -818,33 +830,21 @@ function renderList(rows){
 // #lc-/#hist- containers the card view uses so fetchLiveComps/fetchHistory work unchanged.
 function expandBody(p){
   const a=agentInfo(p), firm=a.firm, id=p.id;
+  // In-app actions ONLY — no external links. Live comps + Property history render inline in the
+  // panel below; "More from this firm" filters the grid in place.
   const deep=[];
-  deep.push(`<a href="${safeUrl(p.source)}" target="_blank" rel="noopener noreferrer">View listing ↗</a>`);
-  deep.push(loopnetLink(p));
-  deep.push(`<button class="lcbtn" data-id="${id}" title="Pull fresh comps from LoopNet via a cloud browser (~$0.03)">🔄 Live comps</button>`);
-  deep.push(`<button class="histbtn" data-id="${id}" data-address="${(p.address||'').replace(/"/g,'&quot;')}" data-city="${(p.city||'').replace(/"/g,'&quot;')}" data-zip="${p.zip||''}" title="LA County assessor + permit + film history (free)">📜 Property history</button>`);
-  const ds=[]; // deeper agent/firm searches
-  if(firm&&firm!=='Unknown'){
-    ds.push(`<a href="${gsearch('"'+firm+'" commercial real estate Los Angeles')}" target="_blank" rel="noopener noreferrer">🏢 ${esc(firm)} — web</a>`);
-    ds.push(`<a href="https://www.linkedin.com/search/results/companies/?keywords=${encodeURIComponent(firm)}" target="_blank" rel="noopener noreferrer">LinkedIn</a>`);
-    ds.push(`<button data-firmfilter="${esc(firm).replace(/"/g,'&quot;')}" title="Filter the grid to this firm's listings">▦ More from this firm</button>`);
-  }
-  if(a.name){
-    ds.push(`<a href="${gsearch('"'+a.name+'"'+(firm&&firm!=='Unknown'?' "'+firm+'"':'')+' real estate agent')}" target="_blank" rel="noopener noreferrer">🧑‍💼 ${esc(a.name)} — web</a>`);
-    ds.push(`<a href="https://www.linkedin.com/search/results/people/?keywords=${encodeURIComponent(a.name+(firm&&firm!=='Unknown'?' '+firm:''))}" target="_blank" rel="noopener noreferrer">LinkedIn</a>`);
-    ds.push(`<a href="${gsearch('"'+a.name+'" DRE license California real estate')}" target="_blank" rel="noopener noreferrer">DRE license</a>`);
-  }
-  // Agent summary + the two contact actions. Full editing (info fields, letter log) lives in the
-  // single contact modal that ✎ Contact opens — no duplicate form here.
-  const tel=a.phone?`<a class="ain" href="tel:${esc(a.phone).replace(/[^0-9+]/g,'')}">📞 ${esc(a.phone)}</a>`:'';
-  const eml=a.email?`<a class="ain" href="mailto:${esc(a.email)}">✉ ${esc(a.email)}</a>`:'';
-  const summary=`<div class="crm-summary">${a.name?agentCell(a.name,firm)+' ':'<span class="amut">no agent on file — add via ✎ Contact</span> '}${tel} ${eml}`
-    +`${a.contacted_at?`<span class="achip done">contacted ${fmtWhen(a.contacted_at)}</span>`:''}${a.letters.length?`<span class="achip">✎ ${a.letters.length} saved</span>`:''}</div>`;
-  return `<div class="src">${deep.join(' · ')}</div>`
-    +(ds.length?`<div class="deep"><span class="deeplbl">Deeper searches</span> ${ds.join(' ')}</div>`:'')
+  deep.push(`<button class="lcbtn" data-id="${id}" title="Pull fresh comps inline (~$0.03)">🔄 Live comps</button>`);
+  deep.push(`<button class="histbtn" data-id="${id}" data-address="${(p.address||'').replace(/"/g,'&quot;')}" data-city="${(p.city||'').replace(/"/g,'&quot;')}" data-zip="${p.zip||''}" title="LA County assessor + permit + film history (inline, free)">📜 Property history</button>`);
+  if(firm&&firm!=='Unknown') deep.push(`<button data-firmfilter="${esc(firm).replace(/"/g,'&quot;')}" title="Show only ${esc(firm)} listings">▦ More from ${esc(firm)}</button>`);
+  // Agent summary + notes preview. Full editing (info, notes, letters, draft) lives in the inline
+  // agent window that ✎ Open launches — no external search, no new tabs.
+  const summary=`<div class="crm-summary">${a.name?`<span class="aname">${esc(a.name)}</span> `:'<span class="amut">no agent on file — add via ✎ Open</span> '}${contactBits(a)}`
+    +`${a.contacted_at?`<span class="achip done">contacted ${fmtWhen(a.contacted_at)}</span>`:''}${a.letters.length?`<span class="achip">✎ ${a.letters.length} saved</span>`:''}</div>`
+    +(a.notes?`<div class="crm-note">🗒 ${esc(a.notes)}</div>`:'');
+  return `<div class="src">${deep.join(' ')}</div>`
     +`<div class="crm">${summary}
         <div class="crm-acts">
-          <button class="abtn contactbtn" data-contact="${id}">✎ Contact / manage</button>
+          <button class="abtn contactbtn" data-contact="${id}">✎ Open agent window</button>
           <button class="abtn markbtn" data-mark="${id}">${a.contacted_at?'↻ Update contacted date':'✓ Mark contacted'}</button>
         </div>
       </div>`
@@ -952,32 +952,33 @@ function agentInfo(p){
   const bh=window.BROKER_HIST[p.id]||window.BROKER_HIST_ADDR[(p.address||'').toLowerCase().trim()];
   const dName=p.broker_name||(bh&&bh.current_broker&&(bh.current_broker.name||bh.current_broker.firm))||'';
   return { name:c.name||dName||'', phone:c.phone||p.broker_phone||'', email:c.email||p.broker_email||'',
-           firm:c.firm||firmLabel(p), contacted_at:c.contacted_at||null, letters:(c.letters||[]) };
+           firm:c.firm||firmLabel(p), contacted_at:c.contacted_at||null, notes:c.notes||'', letters:(c.letters||[]) };
 }
-function gsearch(q){ return 'https://www.google.com/search?q='+encodeURIComponent(q); }
-// Only http(s) URLs are safe in an href — blocks a javascript:/data: scheme sneaking in via p.source.
+function gsearch(q){ return 'https://www.google.com/search?q='+encodeURIComponent(q); } // retained; no longer linked out
 function safeUrl(u){ u=String(u==null?'':u); return /^https?:\/\//i.test(u)?u:'#'; }
-// Listing-firm cell → scoped web search for that brokerage + its agents.
+// Firm cell — clicking filters the grid to that firm IN-APP (no external link).
 function firmCell(p){ const n=firmLabel(p); if(!n||n==='Unknown') return esc(n||'—');
-  return `<a class="flink" href="${gsearch('"'+n+'" commercial real estate brokerage Los Angeles')}" target="_blank" rel="noopener noreferrer" title="Find ${esc(n)} — brokerage & its agents (opens Google)">${esc(n)} ↗</a>`; }
-// Broker/agent name cell → scoped web search for that specific agent.
-function agentCell(name,firm){ if(!name) return '—'; const q='"'+name+'"'+(firm&&firm!=='Unknown'?' "'+firm+'"':'')+' real estate agent';
-  return `<a class="flink" href="${gsearch(q)}" target="_blank" rel="noopener noreferrer" title="Find ${esc(name)} — agent profile (opens Google)">${esc(name)} ↗</a>`; }
-// The Agent · contact column cell: name + tap-to-call/email + Contact/Mark-contacted buttons +
-// contacted-date & saved-letter chips. Data-light rows still get the action buttons (Steve fills
-// the contact info in the row's inline panel as he works the lead).
+  return `<span class="flink" data-firmfilter="${esc(n).replace(/"/g,'&quot;')}" title="Show only ${esc(n)} listings">${esc(n)}</span>`; }
+// Agent name — plain in-app text (no external search).
+function agentCell(name){ return name?esc(name):'—'; }
+// Inline phone/email: shown as text with a copy button — no tel:/mailto: that launch outside apps.
+function contactBits(a){
+  const tel=a.phone?`<span class="ain">📞 ${esc(a.phone)} <button class="copyb" data-copyval="${esc(a.phone)}" title="Copy phone">⧉</button></span>`:'';
+  const eml=a.email?`<span class="ain">✉ ${esc(a.email)} <button class="copyb" data-copyval="${esc(a.email)}" title="Copy email">⧉</button></span>`:'';
+  return (tel||eml)?`<div class="ainrow">${tel} ${eml}</div>`:'';
+}
+// The Agent · contact column cell: name + copyable phone/email + Open/Mark buttons + chips.
 function agentContactCell(p){
   const a=agentInfo(p);
-  const nm=a.name?agentCell(a.name,a.firm):`<span class="amut">no agent on file</span>`;
-  const tel=a.phone?`<a class="ain" href="tel:${esc(a.phone).replace(/[^0-9+]/g,'')}" title="Call ${esc(a.phone)}">📞 ${esc(a.phone)}</a>`:'';
-  const eml=a.email?`<a class="ain" href="mailto:${esc(a.email)}" title="Email ${esc(a.email)}">✉ ${esc(a.email)}</a>`:'';
+  const nm=a.name?`<span class="aname">${esc(a.name)}</span>`:`<span class="amut">no agent on file</span>`;
   const contacted=a.contacted_at?`<span class="achip done" title="Marked contacted ${esc(a.contacted_at)}">✓ ${fmtWhen(a.contacted_at)}</span>`:'';
   const lc=a.letters.length?`<span class="achip" title="${a.letters.length} saved letter(s)">✎ ${a.letters.length}</span>`:'';
+  const nt=a.notes?`<span class="achip" title="Has notes">🗒 notes</span>`:'';
   const btns=`<span class="acts">`
-    +`<button class="abtn contactbtn" data-contact="${p.id}" title="Write & save a letter to this agent (saved, not sent)">✎ Contact</button>`
-    +`<button class="abtn markbtn" data-mark="${p.id}" title="${a.contacted_at?'Already marked — click to update the date':'Mark this agent contacted (saves the date)'}">${a.contacted_at?'↻':'✓'} Contacted</button>`
+    +`<button class="abtn contactbtn" data-contact="${p.id}" title="Open the inline agent window — notes, letters, draft">✎ Open</button>`
+    +`<button class="abtn markbtn" data-mark="${p.id}" title="${a.contacted_at?'Update the contacted date':'Mark contacted (saves the date)'}">${a.contacted_at?'↻':'✓'} Contacted</button>`
     +`</span>`;
-  return `<div class="agentcell">${nm}${tel||eml?`<div class="ainrow">${tel} ${eml}</div>`:''}<div class="chiprow">${contacted}${lc}</div>${btns}</div>`;
+  return `<div class="agentcell">${nm}${contactBits(a)}<div class="chiprow">${contacted}${lc}${nt}</div>${btns}</div>`;
 }
 function fmtWhen(iso){ try{ return new Date(iso).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}); }catch(_){ return String(iso||'').slice(0,10); } }
 
@@ -1058,7 +1059,7 @@ function card(p){
     ${vis('upside')&&p.upside_note?`<div class="small">💡 ${esc(p.upside_note)}</div>`:''}
     ${vis('demo')?demoLine(p):''}
     ${vis('rent')?rentLine(p):''}
-    ${vis('links')?`<div class="src"><a href="${safeUrl(p.source)}" target="_blank" rel="noopener noreferrer">View listing ↗</a> · ${loopnetLink(p)} · <button class="lcbtn" data-id="${p.id}" title="Pull fresh comps from LoopNet via a cloud browser (~$0.03)">🔄 Live comps</button> · <button class="histbtn" data-id="${p.id}" data-address="${(p.address||'').replace(/"/g,'&quot;')}" data-city="${(p.city||'').replace(/"/g,'&quot;')}" data-zip="${p.zip||''}" title="LA County assessor + permit + film history (free)">📜 Property history</button></div>`:''}
+    ${vis('links')?`<div class="src"><button class="lcbtn" data-id="${p.id}" title="Pull fresh comps inline (~$0.03)">🔄 Live comps</button> <button class="histbtn" data-id="${p.id}" data-address="${(p.address||'').replace(/"/g,'&quot;')}" data-city="${(p.city||'').replace(/"/g,'&quot;')}" data-zip="${p.zip||''}" title="LA County assessor + permit + film history (inline, free)">📜 Property history</button>${p.source?` <button class="copyb" data-copyval="${esc(p.source)}" title="Copy the source listing URL">⧉ listing URL</button>`:''}</div>`:''}
     ${anyAssessorVisible()?`<div class="assessline small" data-addr="${(p.address||'').replace(/"/g,'&quot;')}" data-city="${(p.city||'').replace(/"/g,'&quot;')}">📋 assessor…</div>`:''}
     ${cardAgentBlock(p)}
     <div class="lcout" id="lc-${p.id}"></div>
@@ -1069,14 +1070,12 @@ function card(p){
 // contact modal + mark-contacted as the list rows. Compact: name/phone/email + chips + buttons.
 function cardAgentBlock(p){
   const a=agentInfo(p), id=p.id;
-  const nm=a.name?agentCell(a.name,a.firm):`<span class="amut">no agent on file</span>`;
-  const tel=a.phone?`<a class="ain" href="tel:${esc(a.phone).replace(/[^0-9+]/g,'')}" title="Call ${esc(a.phone)}">📞 ${esc(a.phone)}</a>`:'';
-  const eml=a.email?`<a class="ain" href="mailto:${esc(a.email)}" title="Email ${esc(a.email)}">✉ ${esc(a.email)}</a>`:'';
-  const chips=`${a.contacted_at?`<span class="achip done" title="Contacted ${esc(a.contacted_at)}">✓ ${fmtWhen(a.contacted_at)}</span>`:''}${a.letters.length?`<span class="achip" title="${a.letters.length} saved letter(s)">✎ ${a.letters.length}</span>`:''}`;
+  const nm=a.name?`<span class="aname">${esc(a.name)}</span>`:`<span class="amut">no agent on file</span>`;
+  const chips=`${a.contacted_at?`<span class="achip done" title="Contacted ${esc(a.contacted_at)}">✓ ${fmtWhen(a.contacted_at)}</span>`:''}${a.letters.length?`<span class="achip" title="${a.letters.length} saved letter(s)">✎ ${a.letters.length}</span>`:''}${a.notes?`<span class="achip" title="Has notes">🗒 notes</span>`:''}`;
   return `<div class="cardagent" id="cardagent-${id}">`
     +`<div class="ca-top">🧑‍💼 ${nm} ${chips}</div>`
-    +(tel||eml?`<div class="ainrow">${tel} ${eml}</div>`:'')
-    +`<div class="acts"><button class="abtn contactbtn" data-contact="${id}" title="Open the contact modal — write &amp; save a letter (saved, not sent)">✎ Contact</button>`
+    +contactBits(a)
+    +`<div class="acts"><button class="abtn contactbtn" data-contact="${id}" title="Open the inline agent window — notes, letters, draft">✎ Open</button>`
     +`<button class="abtn markbtn" data-mark="${id}" title="${a.contacted_at?'Update contacted date':'Mark contacted (saves the date)'}">${a.contacted_at?'↻':'✓'} Contacted</button></div>`
     +`</div>`;
 }
@@ -1550,7 +1549,7 @@ async function fetchLiveComps(id){
     window.liveComps[id] = j.comps||[];
     const none = (!j.comps||!j.comps.length)?' — none scraped (source may have blocked)':'';
     out.innerHTML = `<div class="small">Live comps via ${j.source||'web'} (cost ${j.cost})${none}:</div>` +
-      (j.comps||[]).map(c=>`<div class="lc-item"><a href="${c.url}" target="_blank" rel="noopener noreferrer">${c.title||c.url}</a> — ${[c.price,c.cap,c.units,c.sf].filter(Boolean).join(' · ')}</div>`).join('');
+      (j.comps||[]).map(c=>`<div class="lc-item"><span>${esc(c.title||c.url||'comp')}</span>${c.url?` <button class="copyb" data-copyval="${esc(c.url)}" title="Copy comp URL">⧉</button>`:''} — ${esc([c.price,c.cap,c.units,c.sf].filter(Boolean).join(' · '))}</div>`).join('');
   }catch(e){ out.innerHTML = `<span class="b bad">⚠ ${e.message}</span>`; }
 }
 
@@ -1602,12 +1601,11 @@ async function deleteLetter(id,idx){
 function agentDraft(p,a){ return `Hi ${a.name||'there'},\n\nI'm interested in ${p.address||'your listing'}${p.city?' in '+p.city:''}${p.price?' (listed '+fmt(p.price)+')':''}. Could you share the setup — current rents / in-place NOI, any financing in place, and whether an offer near list would be entertained?\n\nThanks,\nFrank`; }
 // The full per-listing contact modal body (editable info + mark-contacted + compose/save + letter log).
 function contactModalBody(p){
-  const a=agentInfo(p), id=p.id, firm=a.firm;
-  const web=a.name?` <a class="flink" href="${gsearch('"'+a.name+'"'+(firm&&firm!=='Unknown'?' "'+firm+'"':'')+' real estate agent')}" target="_blank" rel="noopener noreferrer">find agent ↗</a>`:'';
+  const a=agentInfo(p), id=p.id;
   const letters=a.letters.length?`<div class="crm-letters"><div class="deeplbl">Saved letters (${a.letters.length}) — saved, not sent</div>`
     +a.letters.map((l,i)=>`<div class="lett"><div class="letth"><b>${esc(l.subject||'(no subject)')}</b>${l.draft_id?`<span class="achip done" title="Gmail draft created${l.to?' to '+esc(l.to):''} — review & send in Gmail">📤 drafted</span>`:''}<span class="letts">${fmtWhen(l.ts)}</span><button class="letdel" data-letdel="${id}" data-idx="${l.id||i}" title="Delete this saved letter">✕</button></div><div class="lettb">${esc(l.body||'')}</div></div>`).join('')+`</div>`:'';
   return `<div class="composeCard">
-    <div class="composeH">✎ Agent contact — <b>${esc(p.address||'listing')}</b>${p.city?', '+esc(p.city):''}${web}<button class="composeX" title="Close">✕</button></div>
+    <div class="composeH">🧑‍💼 Agent window — <b>${esc(p.address||'listing')}</b>${p.city?', '+esc(p.city):''}<button class="composeX" title="Close">✕</button></div>
     <div class="cm-info">
       <input class="crmf" data-info="name" data-id="${id}" placeholder="Agent name" value="${esc(a.name||'')}">
       <input class="crmf" data-info="phone" data-id="${id}" placeholder="Phone" value="${esc(a.phone||'')}">
@@ -1616,6 +1614,9 @@ function contactModalBody(p){
       <button class="abtn markbtn" data-mark="${id}">${a.contacted_at?'↻ Update contacted date':'✓ Mark contacted'}</button>
       ${a.contacted_at?`<span class="achip done">contacted ${fmtWhen(a.contacted_at)}</span>`:''}
     </div>
+    <div class="cm-notes"><div class="deeplbl">Notes</div>
+      <textarea class="crmf" data-info="notes" data-id="${id}" rows="3" placeholder="Notes on this agent / listing (saved with Save info)">${esc(a.notes||'')}</textarea>
+    </div>
     <div class="composeGate">⚑ <b>Save letter</b> stores it in this listing's log. <b>Send via George</b> creates a Gmail <b>DRAFT</b> to the agent — it is NOT sent; you review &amp; hit send in Gmail.</div>
     <input id="composeSubj" placeholder="Subject" value="${esc('Interest in '+(p.address||'your listing')+(p.city?', '+p.city:''))}">
     <textarea id="composeBody" rows="8">${esc(agentDraft(p,a))}</textarea>
@@ -1766,6 +1767,7 @@ fetch('data/ranked.json').then(r=>r.json()).then(async d=>{
   $('#examples').onclick=e=>{ if(e.target.classList.contains('ex')){ $('#q').value=e.target.textContent; $('#q').focus(); } };
   $('#chatform').onsubmit=e=>{ e.preventDefault(); const t=$('#q').value.trim(); if(!t) return; $('#q').value=''; ask(t); };
   $('#grid').onclick=e=>{ const cp=e.target.closest('[data-copy]'); if(cp){ copySummary(cp.dataset.copy, cp); return; } const qk=e.target.closest('[data-quick]'); if(qk){ applyQuick(qk.dataset.quick); return; } const more=e.target.closest('#showmore'); if(more){ _shown=Math.min(_shown+PAGE(), _rows.length); paintPage(); return; } const st=e.target.closest('[data-star]'); if(st){ toggleStar(st.dataset.star); st.classList.toggle('on',SHORT.has(st.dataset.star)); const sb=$('#shortbtn'); if(sb) sb.textContent='⭐ Shortlist ('+SHORT.size+')'; if(F.shortlist) render(); return; } const th=e.target.closest('.listtbl th[data-sortk]'); if(th){ const k=th.dataset.sortk; if(colSort&&colSort.key===k) colSort.dir*=-1; else colSort={key:k,dir:['type','firm','warr','broker','status','zip','addr','rec'].includes(k)?1:-1}; render(); return; } const b=e.target.closest('.lcbtn'); if(b){ fetchLiveComps(b.dataset.id); return; } const h=e.target.closest('.histbtn'); if(h){ fetchHistory(h); return; }
+    const cv=e.target.closest('[data-copyval]'); if(cv){ navigator.clipboard.writeText(cv.dataset.copyval).then(()=>toast('📋 copied')).catch(()=>toast('⚠ copy failed')); return; }
     const ff=e.target.closest('[data-firmfilter]'); if(ff){ F.firms=new Set([ff.dataset.firmfilter]); syncChips(); render(); return; }
     const ct=e.target.closest('[data-contact]'); if(ct){ openCompose(ct.dataset.contact); return; }
     const mk=e.target.closest('[data-mark]'); if(mk){ markContacted(mk.dataset.mark); return; }
diff --git a/scripts/serve.js b/scripts/serve.js
index 670bf7d..12412d2 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -100,6 +100,7 @@ app.post('/api/contacts/:key/info', (req, res) => {
     if (b.email != null) rec.email = clip(b.email, 200);
     if (b.firm != null) rec.firm = clip(b.firm, 200);
     if (b.address != null) rec.address = clip(b.address, 300);
+    if (b.notes != null) rec.notes = clip(b.notes, 8000);
     rec.updated_at = new Date().toISOString(); return rec;
   }).then(rec => res.json({ ok: true, contact: rec })).catch(jsonErr(res));
 });

← ed5770b Harden agent-contact CRM (contrarian gate): serialize writes  ·  back to Commercialrealestate  ·  Consolidate everything into ONE tabbed Agent window (links O 79bc0cf →