← back to Marketing Command Center
MCC Clients: per-contact notes field below each firm card
2aac0932350dbb5fbdd0080ab5a24c6f8af1f6e7 · 2026-07-17 08:45:25 -0700 · Steve
- auto-saving, auto-growing textarea under each broker/listing card, persisted
per group in localStorage (keyed by the same recId as contacted/actions)
- gold 'has-note' highlight when a note exists; notes included in CSV export
Files touched
M public/panels/clients.htmlM public/panels/clients.js
Diff
commit 2aac0932350dbb5fbdd0080ab5a24c6f8af1f6e7
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jul 17 08:45:25 2026 -0700
MCC Clients: per-contact notes field below each firm card
- auto-saving, auto-growing textarea under each broker/listing card, persisted
per group in localStorage (keyed by the same recId as contacted/actions)
- gold 'has-note' highlight when a note exists; notes included in CSV export
---
public/panels/clients.html | 7 +++++++
public/panels/clients.js | 32 ++++++++++++++++++++++++++++----
2 files changed, 35 insertions(+), 4 deletions(-)
diff --git a/public/panels/clients.html b/public/panels/clients.html
index 55e8a72..90d7db3 100644
--- a/public/panels/clients.html
+++ b/public/panels/clients.html
@@ -21,6 +21,13 @@
.cl-best b { color: #5a4600; font-weight: 600; }
#cl-list.cl-compact .cl-chip { font-size: 9.5px; padding: 1px 6px; }
#cl-list.cl-compact .cl-addr, #cl-list.cl-compact .cl-best { font-size: 9.5px; }
+ .cl-note { width: 100%; margin-top: 5px; font: inherit; font-size: 11px; color: #333; line-height: 1.35;
+ border: 1px solid var(--line, #dcdcdc); border-radius: 6px; padding: 4px 7px; resize: vertical;
+ min-height: 24px; background: #fffdf5; box-sizing: border-box; }
+ .cl-note:focus { outline: none; border-color: #d4af37; box-shadow: 0 0 0 2px rgba(212,175,55,.15); background: #fff; }
+ .cl-note::placeholder { color: #aaa; }
+ .cl-note.has-note { border-color: #d4af37; background: #fffaf0; }
+ #cl-list.cl-compact .cl-note { font-size: 9.5px; min-height: 20px; padding: 2px 6px; }
</style>
<div id="cl-root" style="max-width:960px;">
<div class="muted-banner" id="cl-banner">Loading client & prospect groups…</div>
diff --git a/public/panels/clients.js b/public/panels/clients.js
index 02dde7d..3c538da 100644
--- a/public/panels/clients.js
+++ b/public/panels/clients.js
@@ -19,6 +19,10 @@ window.MCC_PANELS['clients'] = {
const AK = id => 'mcc_cl_acted_' + id;
const acts = id => { try { return JSON.parse(localStorage.getItem(AK(id)) || '{}'); } catch { return {}; } };
const saveActs = (id, a) => { try { localStorage.setItem(AK(id), JSON.stringify(a)); } catch {} };
+ // Per-contact free-text notes: keyed by group id → { recId: noteText }. Local-only.
+ const NK = id => 'mcc_cl_notes_' + id;
+ const notesOf = id => { try { return JSON.parse(localStorage.getItem(NK(id)) || '{}'); } catch { return {}; } };
+ const saveNotes = (id, o) => { try { localStorage.setItem(NK(id), JSON.stringify(o)); } catch {} };
const fmtWhen = ts => { try { return new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } catch { return ''; } };
// Normalize a record from either a "clients" (FM) or "prospects" (CSV) dataset.
@@ -205,16 +209,18 @@ window.MCC_PANELS['clients'] = {
function exportCSV(d) {
const rows = currentMatches();
const A = acts(d.id);
+ const N = notesOf(d.id);
+ const noteOf = r => N[r.id] || '';
const actedIn = r => { const a = A[r.id]; return a && a.in ? fmtWhen(a.in) : ''; };
const actedIg = r => { const a = A[r.id]; return a && a.ig ? fmtWhen(a.ig) : ''; };
const cell = v => { v = String(v == null ? '' : v); return /[",\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; };
let headers, line;
if (d.kind === 'prospects') {
- headers = ['Business', 'Category', 'Address', 'City', 'State', 'Phone', 'Website', 'Email', 'Best contact', 'LinkedIn', 'Instagram', 'LinkedIn actioned', 'Instagram actioned'];
- line = r => [r._raw.title, r.category || '', r.address || '', r._raw.city, r._raw.state || '', r._raw.phone, r._raw.website, r._raw.email, bestChannels(r).contact, r._raw.linkedin || liURL(r.q), r._raw.instagram || igURL(r.q), actedIn(r), actedIg(r)];
+ headers = ['Business', 'Category', 'Address', 'City', 'State', 'Phone', 'Website', 'Email', 'Best contact', 'LinkedIn', 'Instagram', 'LinkedIn actioned', 'Instagram actioned', 'Notes'];
+ line = r => [r._raw.title, r.category || '', r.address || '', r._raw.city, r._raw.state || '', r._raw.phone, r._raw.website, r._raw.email, bestChannels(r).contact, r._raw.linkedin || liURL(r.q), r._raw.instagram || igURL(r.q), actedIn(r), actedIg(r), noteOf(r)];
} else {
- headers = ['Account', 'Company', 'Name', 'City', 'State', 'Email', 'Phone', 'LinkedIn Search', 'Instagram Search', 'LinkedIn actioned', 'Instagram actioned'];
- line = r => [r._raw.account, r._raw.company, r._raw.name, r._raw.city, r._raw.state, r._raw.email, r._raw.phone, liURL(r.q), igURL(r.q), actedIn(r), actedIg(r)];
+ headers = ['Account', 'Company', 'Name', 'City', 'State', 'Email', 'Phone', 'LinkedIn Search', 'Instagram Search', 'LinkedIn actioned', 'Instagram actioned', 'Notes'];
+ line = r => [r._raw.account, r._raw.company, r._raw.name, r._raw.city, r._raw.state, r._raw.email, r._raw.phone, liURL(r.q), igURL(r.q), actedIn(r), actedIg(r), noteOf(r)];
}
const csv = [headers.join(','), ...rows.map(r => line(r).map(cell).join(','))].join('\r\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
@@ -230,8 +236,10 @@ window.MCC_PANELS['clients'] = {
const shown = matches.slice(0, CAP);
const more = matches.length - shown.length;
const A = acts(d.id);
+ const N = notesOf(d.id);
$('#cl-list').innerHTML = `<div class="card"><div>${shown.map((r, i) => {
const on = set.has(r.id);
+ const note = N[r.id] || '';
const liDir = !!r._raw.linkedin, igDir = !!r._raw.instagram;
const a = A[r.id] || {};
const sub = r.sub ? `<span class="muted" style="font-size:11px;">${esc(r.sub)}</span>` : '';
@@ -258,6 +266,7 @@ window.MCC_PANELS['clients'] = {
${addr}
<span class="cl-chips" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:5px;">${chips}</span>
${bestLine}
+ <textarea class="cl-note${note ? ' has-note' : ''}" data-idx="${i}" rows="1" placeholder="📝 Add a note — call outcome, who to ask for, follow-up date, status…" onclick="event.stopPropagation()">${esc(note)}</textarea>
</span>
</label>`;
}).join('')}</div>${more > 0 ? `<div class="muted" style="font-size:11px;margin-top:8px;">Showing ${shown.length} of ${matches.length.toLocaleString()} — type in the filter to narrow.</div>` : ''}</div>`;
@@ -274,6 +283,21 @@ window.MCC_PANELS['clients'] = {
const row = cb.closest('.cl-row'); if (row) { row.style.opacity = cb.checked ? '.5' : ''; const b = row.querySelector('b'); if (b) b.style.textDecoration = cb.checked ? 'line-through' : ''; }
$('#cl-progress').textContent = `${s.size.toLocaleString()} / ${RECS.length.toLocaleString()} contacted`;
});
+ // Per-contact notes: auto-grow + auto-save to localStorage on each keystroke.
+ $('#cl-list').querySelectorAll('textarea.cl-note').forEach(ta => {
+ const grow = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 220) + 'px'; };
+ if (ta.value.trim()) grow();
+ ta.addEventListener('focus', grow);
+ ta.addEventListener('input', () => {
+ grow();
+ const rec = shown[+ta.dataset.idx]; if (!rec) return;
+ const O = notesOf(d.id);
+ const v = ta.value.trim();
+ if (v) O[rec.id] = ta.value; else delete O[rec.id];
+ saveNotes(d.id, O);
+ ta.classList.toggle('has-note', !!v);
+ });
+ });
}
// Auto-open the smallest group so the panel isn't empty on first view.
← 30afe18 MCC Clients: show full address + phone + real domain + best
·
back to Marketing Command Center
·
clients panel: chip clicks open target in a right-side drawe 922c32f →