[object Object]

← back to Dw Pitch Followup

Add Internal-notes editor to pitch cards β€” πŸ—’ Notes button next to Done reads/writes FileMaker Invoice::Internal notes with dated entries

58c847e6a6372ed239707428a7082b60244e7e72 Β· 2026-07-15 10:40:25 -0700 Β· Steve

Files touched

Diff

commit 58c847e6a6372ed239707428a7082b60244e7e72
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 15 10:40:25 2026 -0700

    Add Internal-notes editor to pitch cards β€” πŸ—’ Notes button next to Done reads/writes FileMaker Invoice::Internal notes with dated entries
---
 lib/fm-notes.js   | 40 +++++++++++++++++++++++++---------------
 public/index.html | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++-
 server.js         |  6 ++++--
 3 files changed, 79 insertions(+), 18 deletions(-)

diff --git a/lib/fm-notes.js b/lib/fm-notes.js
index 9ff7f3d..4248fc3 100644
--- a/lib/fm-notes.js
+++ b/lib/fm-notes.js
@@ -1,12 +1,13 @@
 // FileMaker "Internal notes" access β€” the ONE deliberate write path in this tool.
 //
 // The note Steve sees on the client screen in FileMaker Pro is the related
-// Invoice::Internal notes field (repeating, 4 reps β€” we use rep 1) shown on the
-// Clients "Projects" layout: it belongs to the FIRST related invoice record.
-// We read it from the client's Projects record and write it back through the
-// same layout using the Data API related-field syntax
-// (`Invoice::Internal notes.<relatedRecordId>`), so the web edit lands on
-// exactly the record FM Pro displays.
+// Invoice::Internal notes field (repeating, 4 reps β€” we use rep 1): it belongs
+// to the client's FIRST related invoice record. Writing that related field
+// THROUGH the Clients "Projects" layout gets rejected by the Data API (FM 960),
+// so we resolve the invoice number from the client's Invoice portal, then read
+// and write the invoice record DIRECTLY in the invoice DB via the
+// "CFA order detail" layout (exposes Internal notes(1..4); verified the
+// recordIds match the Clients-side portal β€” same underlying table).
 //
 // UNLIKE lib/fm.js this module does NOT force FM_READONLY=1 β€” it is imported
 // only by server.js (never alongside the read-only build pipeline, which runs
@@ -28,23 +29,32 @@ loadFmEnv();
 
 const client = await import(`${FM_MCP_DIR}/src/fm-client.js`);
 
-const LAYOUT = 'Projects'; // Clients layout that carries Invoice::Internal notes + the Invoice portal
+const CLIENT_LAYOUT = 'Projects';        // Clients layout with the Invoice portal
+const INVOICE_LAYOUT = 'CFA order detail'; // invoice layout exposing Internal notes(1..4)
 
 // FileMaker stores line breaks as \r; the browser wants \n.
 const fromFm = (s) => String(s ?? '').replace(/\r/g, '\n');
 const toFm = (s) => String(s ?? '').replace(/\r?\n/g, '\r');
 
 export async function getNotes(account) {
-  const r = await client.findRecords('Clients', LAYOUT, { 'Account Number': `==${account}` }, { limit: 1 });
+  const r = await client.findRecords('Clients', CLIENT_LAYOUT, { 'Account Number': `==${account}` }, { limit: 1 });
   const rec = (r.records || [])[0];
   if (!rec) return null;
   const inv = (rec.portalData?.Invoice || [])[0] || null;
+  const invoice = inv ? String(inv['Invoice::Invoice'] || '').trim() : '';
+  const base = { account: String(account), invoice: invoice || null, invoiceRecordId: null, notes: '', extraReps: [] };
+  if (!invoice) return base;
+
+  const ir = await client.findRecords('invoice', INVOICE_LAYOUT, { Invoice: `==${invoice}` }, { limit: 1 });
+  const irec = (ir.records || [])[0];
+  if (!irec) return base;
+  const f = irec.fieldData || {};
   return {
-    account: String(account),
-    clientRecordId: rec.recordId,
-    invoice: inv ? String(inv['Invoice::Invoice'] || '') : null,
-    invoiceRecordId: inv ? inv.recordId : null,
-    notes: fromFm(rec.fieldData['Invoice::Internal notes']),
+    ...base,
+    invoiceRecordId: irec.recordId,
+    notes: fromFm(f['Internal notes(1)']),
+    // reps 2-4 are rarely used but shown read-only when present
+    extraReps: [2, 3, 4].map((n) => fromFm(f[`Internal notes(${n})`])).filter(Boolean),
   };
 }
 
@@ -65,8 +75,8 @@ export async function addNote(account, note) {
   delete process.env.FM_READONLY;
   try {
     const res = await client.updateRecord(
-      'Clients', LAYOUT, cur.clientRecordId,
-      { [`Invoice::Internal notes.${cur.invoiceRecordId}`]: toFm(next) },
+      'invoice', INVOICE_LAYOUT, cur.invoiceRecordId,
+      { 'Internal notes(1)': toFm(next) },
       { dryRun: false },
     );
     if (!res.committed) throw new Error(res.note || 'FileMaker did not commit the note');
diff --git a/public/index.html b/public/index.html
index bf8f085..23687b0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -31,6 +31,17 @@
   .btn.reorder{background:#3a1512;color:var(--red);border-color:var(--red);font-weight:700}
   .btn.done{background:transparent;color:var(--mut);border-color:var(--line);font-size:12px;padding:5px 9px;margin-left:auto}
   .btn.done:hover{color:var(--green);border-color:var(--green)}
+  .btn.note{background:transparent;color:var(--mut);border-color:var(--line);font-size:12px;padding:5px 9px;margin-left:auto}
+  .btn.note:hover{color:var(--gold);border-color:var(--gold)}
+  .card .acts .btn.note~.btn.done{margin-left:0}
+  .notebox{margin-top:6px;border:1px solid var(--line);border-radius:7px;padding:8px;background:#221e19}
+  .notebox .nb-cur{white-space:pre-wrap;color:var(--mut);font-size:calc(11px*var(--scale));max-height:130px;overflow:auto;margin-bottom:6px;line-height:1.45}
+  .notebox .nb-cur b{color:var(--ink)}
+  .notebox textarea{width:100%;box-sizing:border-box;background:#1a1611;color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;min-height:52px;resize:vertical}
+  .notebox .nb-acts{display:flex;gap:7px;align-items:center;margin-top:6px}
+  .notebox .nb-stat{font-size:11px;color:var(--mut);margin-right:auto}
+  .notebox .nb-stat.ok{color:var(--green)}
+  .notebox .nb-stat.err{color:var(--red)}
   a.fmlink{color:var(--gold);text-decoration:none;border-bottom:1px dotted color-mix(in srgb,var(--gold) 55%,transparent)}
   a.fmlink:hover{filter:brightness(1.12);border-bottom-style:solid}
   .pgallery{margin-top:10px;display:flex;flex-direction:column;gap:6px}
@@ -363,7 +374,7 @@ function cardHTML(list,r){
     ${facts}${projLine}${quoteItems}${postFlag(r)}${discoBanner(r)}
     <div class="acts">${needsReorder(r)
       ? `<button class="btn reorder" data-view>Reorder Samples??</button>`
-      : `<button class="btn gold" data-gen>Generate letter</button><button class="btn" data-view>Details β–Έ</button>`}<button class="btn done" data-done title="Mark completed β€” removes it from this pitch system">βœ“ Done</button></div>
+      : `<button class="btn gold" data-gen>Generate letter</button><button class="btn" data-view>Details β–Έ</button>`}<button class="btn note" data-note title="Internal notes β€” reads/writes the FileMaker Internal notes field, new entries dated today">πŸ—’ Notes</button><button class="btn done" data-done title="Mark completed β€” removes it from this pitch system">βœ“ Done</button></div>
   </div>`;
 }
 
@@ -667,7 +678,45 @@ document.getElementById('board').addEventListener('click',e=>{
   if(e.target.matches('[data-done]')) return markDone(card);
   if(e.target.matches('[data-gen]')) return openPanelFor(card,{generate:true});
   if(e.target.matches('[data-view]')) return openPanelFor(card,{generate:false});
+  if(e.target.matches('[data-note]')) return toggleNotes(card);
+  if(e.target.matches('[data-nb-cancel]')) return card.querySelector('.notebox')?.remove();
+  if(e.target.matches('[data-nb-save]')) return saveNote(card);
 });
+// ---------- INTERNAL NOTES (FileMaker Invoice::Internal notes, dated entries) ----------
+async function toggleNotes(card){
+  const open=card.querySelector('.notebox'); if(open){open.remove();return}
+  const r=card._row; const acct=r&&r.account; if(!acct)return;
+  const box=document.createElement('div'); box.className='notebox';
+  box.innerHTML=`<div class="nb-cur">loading current notes…</div>
+    <textarea placeholder="Add an internal note β€” saved into FileMaker prefixed with today's date"></textarea>
+    <div class="nb-acts"><span class="nb-stat"></span><button class="btn" data-nb-cancel>Cancel</button><button class="btn gold" data-nb-save>Save note</button></div>`;
+  card.appendChild(box); box.querySelector('textarea').focus();
+  try{
+    const res=await fetch(API('/api/notes?account='+encodeURIComponent(acct)));
+    const j=await res.json(); if(!res.ok)throw new Error(j.error||('HTTP '+res.status));
+    paintNotes(box,j);
+  }catch(err){ const cur=box.querySelector('.nb-cur'); if(cur)cur.innerHTML=`<span style="color:var(--red)">Could not load notes: ${esc(err.message)}</span>`; }
+}
+function paintNotes(box,j){
+  const cur=box.querySelector('.nb-cur'); if(!cur)return;
+  const head=`<b>Internal notes</b>${j.invoice?` Β· invoice #${esc(j.invoice)}`:''}`;
+  const extras=(j.extraReps||[]).length?'\n<i>β€” other note slots β€”</i>\n'+j.extraReps.map(esc).join('\n'):'';
+  cur.innerHTML=head+'\n'+(j.notes?esc(j.notes):'<i>β€” none yet β€”</i>')+extras;
+}
+async function saveNote(card){
+  const box=card.querySelector('.notebox'); if(!box)return;
+  const ta=box.querySelector('textarea'), stat=box.querySelector('.nb-stat'), btn=box.querySelector('[data-nb-save]');
+  const note=ta.value.trim(); const acct=card._row&&card._row.account;
+  if(!note){stat.textContent='Type a note first';stat.className='nb-stat err';return}
+  btn.disabled=true; stat.textContent='saving to FileMaker…'; stat.className='nb-stat';
+  try{
+    const res=await fetch(API('/api/notes'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account:String(acct),note})});
+    const j=await res.json(); if(!res.ok)throw new Error(j.error||('HTTP '+res.status));
+    paintNotes(box,j); ta.value='';
+    stat.textContent='βœ“ saved β€” '+j.added; stat.className='nb-stat ok';
+  }catch(err){ stat.textContent='βœ— '+err.message; stat.className='nb-stat err'; }
+  btn.disabled=false;
+}
 // Sent-log viewer β€” the permanent record of every email sent.
 async function openSentLog(){
   active=null; document.querySelectorAll('.card.active').forEach(c=>c.classList.remove('active'));
diff --git a/server.js b/server.js
index 611b311..fb6ec55 100644
--- a/server.js
+++ b/server.js
@@ -1,4 +1,6 @@
-// dw-pitch-followup viewer β€” READ-ONLY.
+// dw-pitch-followup viewer β€” READ-ONLY against FileMaker, with ONE deliberate
+// exception: POST /api/notes prepends a dated line to the client's
+// Invoice::Internal notes (the notes box on the FM Pro client screen).
 //   GET  /                 β†’ the 3-tab pitch viewer (public/index.html)
 //   GET  /healthz          β†’ liveness
 //   GET  /api/lists        β†’ data/lists.json (the 3 built lists)
@@ -811,7 +813,7 @@ app.get('/api/notes', async (req, res) => {
     if (!/^\d+$/.test(account)) return res.status(400).json({ error: 'account required' });
     const out = await (await fmNotes()).getNotes(account);
     if (!out) return res.status(404).json({ error: `no client record for account ${account}` });
-    res.json({ account: out.account, invoice: out.invoice, notes: out.notes });
+    res.json({ account: out.account, invoice: out.invoice, notes: out.notes, extraReps: out.extraReps || [] });
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 app.post('/api/notes', async (req, res) => {

← e35cf91 auto-save: 2026-07-15T10:35:56 (2 files) β€” server.js lib/fm-  Β·  back to Dw Pitch Followup  Β·  Show invoice # + current internal note on every pitch card β€” 177117e β†’