β 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
M lib/fm-notes.jsM public/index.htmlM server.js
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 β