← 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
A data/fmpro-posted.jsonA scripts/fmpro.mjsM server.js
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 →