[object Object]

← back to Sample Followup Sweep

FMPro write-back: stamp Date Email Sent to Vendor after 10 Days on send + backfill engine + console FMPro chip

de3da216dcedf8a9f716aba21194f606c8a38629 · 2026-08-17 08:05:44 -0700 · Steve Abrams

- scripts/fmpro.mjs: reuses filemaker-mcp client; exact combo-sku+request-date match (avoids vid begins-with over-match and DW#->master dupes); stamp/plan/note/backfill modes
- server.js: /api/send-one auto-stamps via stampFmpro(); FMPro chip column + /api/state fmposted; data/fmpro-posted.json state
- Backfilled 53 already-sent SKUs live (12 O&L canary + 41 fleet) with each vendor's real send date

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit de3da216dcedf8a9f716aba21194f606c8a38629
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 17 08:05:44 2026 -0700

    FMPro write-back: stamp Date Email Sent to Vendor after 10 Days on send + backfill engine + console FMPro chip
    
    - scripts/fmpro.mjs: reuses filemaker-mcp client; exact combo-sku+request-date match (avoids vid begins-with over-match and DW#->master dupes); stamp/plan/note/backfill modes
    - server.js: /api/send-one auto-stamps via stampFmpro(); FMPro chip column + /api/state fmposted; data/fmpro-posted.json state
    - Backfilled 53 already-sent SKUs live (12 O&L canary + 41 fleet) with each vendor's real send date
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 data/fmpro-posted.json |  22 ++++++
 scripts/fmpro.mjs      | 210 +++++++++++++++++++++++++++++++++++++++++++++++++
 server.js              |  31 +++++++-
 3 files changed, 259 insertions(+), 4 deletions(-)

diff --git a/data/fmpro-posted.json b/data/fmpro-posted.json
new file mode 100644
index 0000000..15532a7
--- /dev/null
+++ b/data/fmpro-posted.json
@@ -0,0 +1,22 @@
+{
+  "byVid": {
+    "DGD": { "date": "08/14/2026", "count": 12, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "GREEN": { "date": "08/15/2026", "count": 7, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "KRA": { "date": "08/14/2026", "count": 4, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "SCA": { "date": "08/14/2026", "count": 5, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "THI": { "date": "08/15/2026", "count": 4, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "PF": { "date": "08/15/2026", "count": 4, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "QUA": { "date": "08/14/2026", "count": 1, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "ARTE INTERNATIONAL": { "date": "08/14/2026", "count": 2, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "CON": { "date": "08/14/2026", "count": 2, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "THIB": { "date": "08/15/2026", "count": 2, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "SANDB": { "date": "08/14/2026", "count": 2, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "PIN": { "date": "08/14/2026", "count": 2, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "JOFA": { "date": "08/14/2026", "count": 1, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "BREWSTER": { "date": "08/14/2026", "count": 1, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "OSB": { "date": "08/14/2026", "count": 1, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "KOR": { "date": "08/14/2026", "count": 1, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "RBL": { "date": "08/14/2026", "count": 1, "lastPostedAt": "2026-08-17T14:40:00.000Z" },
+    "OLP": { "date": "08/15/2026", "count": 1, "lastPostedAt": "2026-08-17T14:40:00.000Z" }
+  }
+}
diff --git a/scripts/fmpro.mjs b/scripts/fmpro.mjs
new file mode 100644
index 0000000..833fc60
--- /dev/null
+++ b/scripts/fmpro.mjs
@@ -0,0 +1,210 @@
+#!/usr/bin/env node
+// FMPro write-back engine for the sample follow-up system.
+//
+// Two writes to the WALLPAPER (WALLPAPER2) memo records, keyed by the vendor's
+// `vid` + the 10–60 day outstanding window:
+//   • "Date Email Sent to Vendor after 10 Days" — stamped the day the follow-up email goes out.
+//   • "Priority Memo Notes"                     — a plain-language note when the vendor replies.
+//
+// Reuses the filemaker-mcp client (FileMaker Cloud / Claris ID auth) so we never
+// re-implement the Cognito handshake. Creds are read from ~/.claude.json's
+// mcpServers.filemaker.env block (single source of truth) at runtime.
+//
+// CLI:
+//   node scripts/fmpro.mjs backfill                       # stamp every already-sent vendor's outstanding SKUs
+//   node scripts/fmpro.mjs stamp --vid DGD --date 08/14/2026
+//   node scripts/fmpro.mjs note  --record 536324 --text "Discontinued"
+//   node scripts/fmpro.mjs note  --vid DGD --sku DWKK123171 --text "Sending now"
+//
+// server.js spawns `stamp --vid <vid> --date <today>` after a successful send so
+// the FileMaker field is recorded automatically (the gap this whole build closes).
+
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { homedir } from 'node:os';
+
+const __dir = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dir, '..');
+
+const DB = 'WALLPAPER';
+const LAYOUT = 'Report for old memo samples';     // exposes vid, combo sku, today for client, Date WP Sample Sent
+const FIELD_SENT = 'Date Email Sent to Vendor after 10 Days';
+const FIELD_NOTES = 'Priority Memo Notes';
+const MIN_AGE_DAYS = 10;   // don't chase newer than 10 days
+const MAX_AGE_DAYS = 60;   // >60 = dead lead, don't chase (Steve's rule)
+
+// --- load FileMaker Cloud creds from the canonical MCP config, then the client ---
+function loadFmEnv() {
+  const cfg = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'));
+  const env = cfg?.mcpServers?.filemaker?.env || {};
+  for (const k of ['FM_CLOUD_HOST', 'FM_CLARIS_EMAIL', 'FM_CLARIS_PASSWORD']) {
+    if (!env[k]) throw new Error(`~/.claude.json mcpServers.filemaker.env.${k} missing`);
+    process.env[k] = env[k];
+  }
+  process.env.FM_READONLY = '0'; // this engine writes
+}
+loadFmEnv();
+const fm = await import('/Users/macstudio3/Projects/filemaker-mcp/src/fm-client.js');
+
+// --- date helpers (FileMaker wants MM/DD/YYYY; dates are judged in Pacific time) ---
+const pad = (n) => String(n).padStart(2, '0');
+function fmtDate(d) { return `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`; }
+function ptDateFromISO(iso) {
+  // Convert an ISO instant to a MM/DD/YYYY calendar date in America/Los_Angeles.
+  const s = new Date(iso).toLocaleDateString('en-US', { timeZone: 'America/Los_Angeles' }); // M/D/YYYY
+  const [m, day, y] = s.split('/');
+  return `${pad(m)}/${pad(day)}/${y}`;
+}
+function windowRange(today = new Date()) {
+  const lo = new Date(today); lo.setDate(lo.getDate() - MAX_AGE_DAYS); // oldest still chased
+  const hi = new Date(today); hi.setDate(hi.getDate() - MIN_AGE_DAYS); // newest chased (>10d)
+  return `${fmtDate(lo)}...${fmtDate(hi)}`;
+}
+
+// --- match ONE page SKU to its live memo record, exactly ---
+// Keyed by the exact DW# (`combo sku`) shown on the console — NOT vid (FileMaker text
+// find is begins-with-word, so vid over-matches: "SAN"→SANDB, "GREEN"→unrelated rows).
+// `==` forces an exact match; `"="` matches an EMPTY field, so already-stamped/fulfilled
+// rows are excluded (idempotent). Disambiguated by request date when a SKU repeats.
+async function matchPageSku(dw, requested) {
+  // Require BOTH the exact DW# and the exact request date. A DW# alone maps to many
+  // records (the live memo + blank pattern-masters + year-old orders); the request
+  // date pins it to the one current memo. No request date = don't guess, skip.
+  if (!dw || !String(dw).trim() || !requested) return [];
+  const query = {
+    'combo sku': `==${dw}`,
+    'today for client': `==${requested}`,   // exact page-row request date
+    'Date WP Sample Sent': '=',             // still outstanding
+    [FIELD_SENT]: '=',                      // not yet stamped
+  };
+  try {
+    const { records } = await fm.findRecords(DB, LAYOUT, query, { limit: 50 });
+    return records.map((r) => ({
+      recordId: r.recordId,
+      sku: r.fieldData['combo sku'] || '',
+      client: (r.fieldData['company for client fileS'] || r.fieldData['Clients::Company'] || '').trim(),
+      requested: r.fieldData['today for client'] || '',
+    }));
+  } catch (e) {
+    if (e.fmCode === '401') return []; // no records found
+    throw e;
+  }
+}
+
+// --- plan/stamp one vendor from its exact page SKUs (fleet.json items) ---
+async function planVendor(v) {
+  const seen = new Set();
+  const rows = [];
+  for (const it of (v.items || [])) {
+    const matches = await matchPageSku(it.dw, it.initialReq || it.req);
+    for (const m of matches) {
+      if (seen.has(m.recordId)) continue;
+      seen.add(m.recordId);
+      rows.push({ ...m, date: v.date, vid: v.vid, vendor: v.name });
+    }
+  }
+  return rows;
+}
+async function stampVendor(v) {
+  const rows = await planVendor(v);
+  const stamped = [];
+  for (const r of rows) {
+    const res = await fm.updateRecord(DB, LAYOUT, r.recordId, { [FIELD_SENT]: r.date }, { dryRun: false });
+    if (res.committed) stamped.push(r);
+  }
+  return { vid: v.vid, date: v.date, count: stamped.length, found: rows.length, stamped };
+}
+
+// --- write a plain-language reply note to a specific memo record ---
+async function noteRecord(recordId, text) {
+  const res = await fm.updateRecord(DB, LAYOUT, recordId, { [FIELD_NOTES]: text }, { dryRun: false });
+  return { recordId, text, committed: !!res.committed };
+}
+async function findRecordIdByVidSku(vid, sku) {
+  const { records } = await fm.findRecords(DB, LAYOUT, { vid, 'combo sku': `==${sku}`, 'Date WP Sample Sent': '=' }, { limit: 10 });
+  return records[0]?.recordId || null;
+}
+
+// --- fmpro-posted state (drives the console chip) ---
+const POSTED = join(ROOT, 'data', 'fmpro-posted.json');
+function readPosted() { try { return JSON.parse(readFileSync(POSTED, 'utf8')); } catch { return { byVid: {} }; } }
+function writePosted(p) { mkdirSync(join(ROOT, 'data'), { recursive: true }); writeFileSync(POSTED, JSON.stringify(p, null, 2)); }
+function recordPosted(vid, r) {
+  const p = readPosted();
+  const prev = p.byVid[vid] || { count: 0 };
+  p.byVid[vid] = { date: r.date, count: (prev.count || 0) + r.count, lastPostedAt: new Date().toISOString() };
+  writePosted(p);
+}
+
+// --- sent-vendor roster: fleet vendors whose contacts email is in the Sent snapshot ---
+function sentVendors() {
+  const fleet = JSON.parse(readFileSync(join(ROOT, 'data', 'fleet.json'), 'utf8')).vendors;
+  const contacts = JSON.parse(readFileSync(join(ROOT, 'data', 'contacts.json'), 'utf8'));
+  const sent = JSON.parse(readFileSync(join(ROOT, 'data', 'sent.json'), 'utf8')).byEmail || {};
+  const norm = (s) => String(s || '').toLowerCase().trim();
+  const sentMap = {}; for (const [k, v] of Object.entries(sent)) sentMap[norm(k)] = v;
+  const out = [];
+  for (const v of fleet) {
+    const c = contacts[v.slug] || {};
+    if (c.disposition === 'no-chase') continue;
+    const addrs = norm(c.sample_email).split(',').map((a) => a.trim()).filter((a) => a.includes('@'));
+    const hits = addrs.map((a) => sentMap[a]).filter(Boolean);
+    if (!hits.length) continue;
+    const lastISO = hits.map((h) => h.lastSent).sort().pop();
+    out.push({ slug: v.slug, vid: v.vid, name: v.name, items: v.items || [], date: ptDateFromISO(lastISO) });
+  }
+  return out;
+}
+
+// ---------------- CLI ----------------
+function arg(name) { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : undefined; }
+const cmd = process.argv[2];
+
+if (cmd === 'plan') {
+  // READ-ONLY: show exactly which sent-vendor records would be stamped, and with what date.
+  const vendors = sentVendors();
+  const plan = [];
+  for (const v of vendors) {
+    const rows = await planVendor(v);
+    rows.forEach((r) => plan.push(r));
+  }
+  console.log(`PLAN (read-only) — ${plan.length} sent-vendor record(s) need stamping:\n`);
+  for (const p of plan) console.log(`  ${p.recordId}  ${p.date}  [${p.vid}] ${p.sku}  ${p.client}  (req ${p.requested})  — ${p.vendor}`);
+  console.log(`\nJSON:`); console.log(JSON.stringify(plan));
+} else if (cmd === 'backfill') {
+  const vendors = sentVendors();
+  console.log(`FMPro backfill — ${vendors.length} already-sent vendors · window ${windowRange()} (${MIN_AGE_DAYS}–${MAX_AGE_DAYS}d)\n`);
+  let total = 0;
+  for (const v of vendors) {
+    try {
+      const r = await stampVendor(v);
+      if (r.count) recordPosted(v.vid, r);
+      total += r.count;
+      const note = r.found === 0 ? 'already clean / none outstanding' : `stamped ${r.count}/${r.found} with ${v.date}`;
+      console.log(`  ${r.count ? '✓' : '·'} ${v.name.padEnd(34)} [${v.vid}]  ${note}`);
+      r.stamped.forEach((s) => console.log(`       - ${s.sku}  ${s.client}  (req ${s.requested})`));
+    } catch (e) {
+      console.log(`  ✗ ${v.name} [${v.vid}]  ERROR: ${e.message}`);
+    }
+  }
+  console.log(`\nDone. ${total} record(s) newly stamped. State → data/fmpro-posted.json`);
+} else if (cmd === 'stamp') {
+  // Single vendor, keyed by --slug (server.js passes this at send time). Matches
+  // that vendor's exact page SKUs — never vid — so it can't over-match other vendors.
+  const slug = arg('slug'); const date = arg('date') || fmtDate(new Date());
+  if (!slug) { console.error('need --slug'); process.exit(1); }
+  const fleetV = JSON.parse(readFileSync(join(ROOT, 'data', 'fleet.json'), 'utf8')).vendors.find((x) => x.slug === slug);
+  if (!fleetV) { console.error(`unknown --slug ${slug}`); process.exit(1); }
+  const r = await stampVendor({ slug: fleetV.slug, vid: fleetV.vid, name: fleetV.name, items: fleetV.items, date });
+  if (r.count) recordPosted(fleetV.vid, r);
+  console.log(JSON.stringify(r));
+} else if (cmd === 'note') {
+  const text = arg('text'); if (!text) { console.error('need --text'); process.exit(1); }
+  let recordId = arg('record');
+  if (!recordId) { const vid = arg('vid'), sku = arg('sku'); if (!vid || !sku) { console.error('need --record OR --vid + --sku'); process.exit(1); } recordId = await findRecordIdByVidSku(vid, sku); }
+  if (!recordId) { console.error('record not found'); process.exit(1); }
+  console.log(JSON.stringify(await noteRecord(recordId, text)));
+} else {
+  console.log('usage: fmpro.mjs backfill | stamp --vid V --date MM/DD/YYYY | note (--record R | --vid V --sku S) --text "..."');
+}
diff --git a/server.js b/server.js
index a6232be..c3e905b 100644
--- a/server.js
+++ b/server.js
@@ -7,6 +7,7 @@
 const http = require('http');
 const fs = require('fs');
 const path = require('path');
+const { spawn } = require('child_process');
 const { compose } = require('./lib/compose');
 
 const ROOT = __dirname;
@@ -18,6 +19,19 @@ const readJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8'))
 const fleet = () => readJSON(p('data', 'fleet.json'), { vendors: [] });
 const contacts = () => readJSON(p('data', 'contacts.json'), {});
 const sent = () => readJSON(p('data', 'sent.json'), { byEmail: {} });
+const fmposted = () => readJSON(p('data', 'fmpro-posted.json'), { byVid: {} }); // FileMaker write-back state (chip)
+
+// After a send, stamp "Date Email Sent to Vendor after 10 Days" on that vendor's exact
+// page SKUs in FileMaker (WALLPAPER2) and record it for the console chip. Fire-and-forget:
+// the email already went out, so a FileMaker hiccup must never fail the send response.
+function stampFmpro(slug) {
+  try {
+    const child = spawn(process.execPath, [p('scripts', 'fmpro.mjs'), 'stamp', '--slug', slug],
+      { cwd: ROOT, stdio: 'ignore', detached: true });
+    child.on('error', () => {});
+    child.unref();
+  } catch (e) { /* never blocks the send */ }
+}
 const staged = () => readJSON(p('out', 'all-drafts.json'), []); // the EXACT letters staged/sent
 function stagedFor(slug) { return (staged() || []).find(d => (d.slugs || []).includes(slug)); }
 
@@ -43,7 +57,7 @@ const server = http.createServer(async (req, res) => {
   const u = new URL(req.url, 'http://x');
 
   if (u.pathname === '/' ) return send(res, 200, PAGE, 'text/html; charset=utf-8');
-  if (u.pathname === '/api/state') return send(res, 200, { fleet: fleet(), contacts: contacts(), sent: sent() });
+  if (u.pathname === '/api/state') return send(res, 200, { fleet: fleet(), contacts: contacts(), sent: sent(), fmposted: fmposted() });
 
   // Re-harvest push: Claude searches info@ Sent (subject "Outstanding Memos") via MCP and
   // POSTs { byEmail } (or a raw George message list) here to refresh the sent snapshot.
@@ -123,6 +137,7 @@ const server = http.createServer(async (req, res) => {
         let ok = false, mid = ''; try { const j = JSON.parse(gb); ok = !!j.success; mid = j.messageId || ''; } catch (e) {}
         if (ok) {
           try { const s = sent(); s.byEmail = s.byEmail || {}; const iso = new Date().toISOString(); for (let a of String(payload.to).split(',')) { a = a.trim().toLowerCase(); if (!a.includes('@')) continue; const cur = s.byEmail[a]; if (!cur) s.byEmail[a] = { lastSent: iso, count: 1 }; else { cur.count++; cur.lastSent = iso; } } fs.writeFileSync(p('data', 'sent.json'), JSON.stringify(s, null, 2)); } catch (e) {}
+          stampFmpro(b.slug); // write the sent-date back to FileMaker + light the FMPro chip
           return send(res, 200, { ok: true, messageId: mid, to: payload.to });
         }
         return send(res, 502, { error: 'George send blocked/failed', detail: gb.slice(0, 200) });
@@ -171,6 +186,7 @@ const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Sample Fol
  .badge{font-size:11px;padding:2px 8px;border-radius:20px;font-weight:700}
  .ok{background:#e5f6ea;color:#137a34} .need{background:#fdf0dd;color:#9a6b12} .dead{background:#eee;color:#888}
  .sentb{background:#e5eefc;color:#1a56c4} .sentx{font-size:11px;color:#1a56c4;font-weight:700;margin-left:4px}
+ .fmb{background:#ecfdf5;color:#0f766e;border:1px solid #99f6e4} td.fmcell{white-space:nowrap;font-size:12px}
  td.sentcell{white-space:nowrap;font-size:12px;color:#555} td.sentcell .dt{color:#333}
  .vid{color:#999;font-size:11px}
  input.em{width:190px} input.ac{width:100px}
@@ -209,7 +225,7 @@ const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Sample Fol
  </div>
 </header>
 <div class="wrap"><table><thead><tr>
- <th></th><th>Vendor</th><th>Items</th><th>Re-req</th><th>Oldest</th><th>Status</th><th>Email sent</th><th>Sample email</th><th>DW acct #</th><th></th>
+ <th></th><th>Vendor</th><th>Items</th><th>Re-req</th><th>Oldest</th><th>Status</th><th>Email sent</th><th>FMPro</th><th>Sample email</th><th>DW acct #</th><th></th>
 </tr></thead><tbody id="rows"></tbody></table>
 <p class="muted">Queue writes <code>out/send-queue.json</code>. Ready = sample email + acct present. Then in chat: “create the queued drafts” → they land in info@ Drafts for you to send. 15 rows had no vendor id and aren’t shown.</p>
 </div>
@@ -217,7 +233,7 @@ const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Sample Fol
 <script>
 let S={fleet:{vendors:[]},contacts:{},sent:{byEmail:{}}};
 const sortKey=localStorage.getItem('sfsort')||'items';
-async function load(){const r=await fetch('/api/state');S=await r.json();if(!S.sent)S.sent={byEmail:{}};document.getElementById('sort').value=sortKey;render();
+async function load(){const r=await fetch('/api/state');S=await r.json();if(!S.sent)S.sent={byEmail:{}};if(!S.fmposted)S.fmposted={byVid:{}};document.getElementById('sort').value=sortKey;render();
  const nSent=S.fleet.vendors.filter(v=>sentInfo(v).sent).length;
  const meta=document.getElementById('meta');
  meta.textContent=S.fleet.vendors.length+' vendors · '+S.fleet.totalItems+' items · window '+S.fleet.window+' · '+nSent+' emailed / '+(S.fleet.vendors.length-nSent)+' not yet';
@@ -255,6 +271,12 @@ function itemsTable(v){
 function sentCell(v){const s=sentInfo(v);
  if(!s.sent)return '<span class=muted>— not sent</span>';
  return '<span class="badge sentb">SENT</span> <span class=dt title="'+esc(s.lastSent)+'">'+esc(fmtSent(s.lastSent))+'</span>'+(s.count>1?'<span class=sentx>×'+s.count+'</span>':'');}
+// FMPro write-back chip: has this vendor's sent-date been posted back to FileMaker?
+function fmInfo(v){const bv=(S.fmposted&&S.fmposted.byVid)||{};return bv[v.vid]||null;}
+function fmCell(v){const f=fmInfo(v);const s=sentInfo(v);
+ if(f&&f.count)return '<span class="badge fmb" title="Posted to FileMaker: Date Email Sent to Vendor after 10 Days = '+esc(f.date||'')+' ('+esc(f.lastPostedAt||'')+')">✓ FMPro '+esc(f.date||'')+'</span>';
+ if(s.sent)return '<span class=muted title="Emailed but sent-date not yet written back to FileMaker">— not posted</span>';
+ return '<span class=muted>—</span>';}
 function render(){const t=document.getElementById('rows');t.innerHTML='';for(const v of sorted()){const c=S.contacts[v.slug]||{};const r=ready(v);
  const noChase=c.disposition==='no-chase';
  const tr=document.createElement('tr');if(noChase)tr.style.opacity='.5';tr.innerHTML=
@@ -265,13 +287,14 @@ function render(){const t=document.getElementById('rows');t.innerHTML='';for(con
  '<td>'+v.items[0].age+'d</td>'+
  '<td>'+(noChase?'<span class="badge dead" title="'+esc(c.disposition_reason||'')+'">NO-CHASE</span>':'<span class="badge '+(r?'ok':'need')+'">'+(r?'READY':'NEEDS')+'</span>')+'</td>'+
  '<td class="sentcell">'+sentCell(v)+'</td>'+
+ '<td class="fmcell">'+fmCell(v)+'</td>'+
  '<td><input class="em" data-slug="'+v.slug+'" data-f="sample_email" value="'+esc(c.sample_email||'')+'" placeholder="sample email"></td>'+
  '<td><input class="ac" data-slug="'+v.slug+'" data-f="account_number" value="'+esc(c.account_number||'')+'" placeholder="acct #"></td>'+
  '<td class=actcell><button data-prev="'+v.slug+'">preview</button>'+
    (ready(v)?'<button class="sendbtn'+(sentInfo(v).sent?' resend':'')+'" data-send="'+v.slug+'">'+(sentInfo(v).sent?'resend':'send')+'</button>':'')+'</td>';
  t.appendChild(tr);
  const dr=document.createElement('tr');dr.className='detailrow';dr.style.display='none';
- dr.innerHTML='<td></td><td colspan=9>'+itemsTable(v)+'</td>';t.appendChild(dr);}
+ dr.innerHTML='<td></td><td colspan=10>'+itemsTable(v)+'</td>';t.appendChild(dr);}
  t.querySelectorAll('input.em,input.ac').forEach(i=>i.addEventListener('change',saveContact));
  t.querySelectorAll('button[data-prev]').forEach(b=>b.addEventListener('click',()=>preview(b.dataset.prev)));
  t.querySelectorAll('button[data-send]').forEach(b=>b.addEventListener('click',async e=>{const slug=b.dataset.send;const c=S.contacts[slug]||{};

← eabe843 auto-data-snapshot: 2026-08-15T15:50:00 (1 data files) — dat  ·  back to Sample Followup Sweep  ·  Reply loop: poll info@ for vendor replies + 3-business-day o 6a7ae10 →