← 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
M public/index.htmlM scripts/serve.js
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,'"')}" data-city="${(p.city||'').replace(/"/g,'"')}" 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,'"')}" 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,'"')}" data-city="${(p.city||'').replace(/"/g,'"')}" 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,'"')}" 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,'"')}" 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,'"')}" data-city="${(p.city||'').replace(/"/g,'"')}" 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,'"')}" data-city="${(p.city||'').replace(/"/g,'"')}" 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,'"')}" data-city="${(p.city||'').replace(/"/g,'"')}">📋 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 & 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 & 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 →