← back to Sample Followup Sweep

server.js

374 lines

'use strict';
// Sample Follow-Up Sweep — selection console.
// Sweep → group → pick vendors (1..all) → enter sample email/account → preview → QUEUE.
// Queue writes out/send-queue.json; actual info@ Gmail drafts are created on the
// authenticated MCP path (Claude), so George's external-send gate stays intact.

const http = require('http');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const { compose } = require('./lib/compose');
const { createGmailDraftArtifact, georgeRequest } = require('./lib/george-transport');

const ROOT = __dirname;
const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
const SHIP_TO = { name: 'Designer Wallcoverings', line1: '15442 Ventura Blvd. #102', city_state_zip: 'Sherman Oaks, CA 91403', phone: '1-888-373-4564' };

const p = (...a) => path.join(ROOT, ...a);
const readJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
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)
const resendq = () => readJSON(p('data', 'resend-queue.json'), { queue: [] });   // 3-biz-day resend queue
const replies = () => readJSON(p('data', 'replies.json'), { byVid: {} });         // vendor reply status

// 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, pass = 1) {
  try {
    const args = [p('scripts', 'fmpro.mjs'), 'stamp', '--slug', slug];
    if (pass === 2) args.push('--pass', '2');   // TK-11409: the resend writes the 2nd-request field
    // TK-11409: output used to go to stdio:'ignore'. That is the ROOT ENABLER of this
    // ticket's bug — a stamp that matched zero records was indistinguishable from one that
    // wrote every row, so the resend wrote nothing for weeks with no signal anywhere. The
    // send still must never fail on a FileMaker hiccup, so this stays detached and
    // non-blocking; it just no longer throws the evidence away.
    const logPath = p('data', 'fmpro-stamp.log');
    let out = 'ignore';
    try { out = fs.openSync(logPath, 'a'); } catch (e) { out = 'ignore'; }
    const stamp = `\n===== ${new Date().toISOString()} slug=${slug} pass=${pass} =====\n`;
    if (out !== 'ignore') { try { fs.writeSync(out, stamp); } catch (e) {} }
    const child = spawn(process.execPath, args,
      { cwd: ROOT, stdio: out === 'ignore' ? 'ignore' : ['ignore', out, out], 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
// TK-11255: a slug is legitimately NON-UNIQUE — declared aliases (Anna French ->
// Thibaut) ship as items:0 stub rows sharing another vendor's slug. A bare
// .find(x => x.slug === s) therefore depends on array order to land on the real
// row. It happens to work today only because stubs sort last; make it explicit.
function vendorBySlug(vendors, slug) {
  const hits = vendors.filter((v) => v.slug === slug);
  return hits.find((v) => v.items && v.items.length) || hits[0] || undefined;
}
function stagedFor(slug) { return (staged() || []).find(d => (d.slugs || []).includes(slug)); }

function vendorObj(v, c) {
  const cc = c[v.slug] || {};
  return { name: cc.name || v.name, account_number: cc.account_number || '', sample_email: cc.sample_email || '', ship_to: SHIP_TO };
}
function draftFor(v, c) {
  const rows = v.items.map(i => ({ mfr: i.mfr }));
  return compose(vendorObj(v, c), rows);
}

function body(req) {
  return new Promise(res => { let b = ''; req.on('data', d => b += d); req.on('end', () => res(b ? JSON.parse(b) : {})); });
}
function send(res, code, obj, type) {
  res.writeHead(code, { 'Content-Type': type || 'application/json' });
  res.end(typeof obj === 'string' ? obj : JSON.stringify(obj));
}

const server = http.createServer(async (req, res) => {
  if (req.headers.authorization !== AUTH) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW"' }); return res.end('auth required'); }
  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(), fmposted: fmposted(), resend: resendq(), replies: replies() });

  // Write a plain-language reply note to FileMaker Priority Memo Notes (one-click from the console).
  if (u.pathname === '/api/note' && req.method === 'POST') {
    const b = await body(req);
    if (!b.slug || !b.sku || !b.text) return send(res, 400, { error: 'need slug, sku, text' });
    const child = spawn(process.execPath, [p('scripts', 'fmpro.mjs'), 'note', '--slug', b.slug, '--sku', b.sku, '--text', b.text], { cwd: ROOT });
    let out = ''; child.stdout.on('data', d => out += d); child.stderr.on('data', d => out += d);
    child.on('close', () => send(res, 200, { ok: /committed":true/.test(out), detail: out.slice(0, 300) }));
    return;
  }

  // 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.
  if (u.pathname === '/api/sent' && req.method === 'POST') {
    const b = await body(req);
    let byEmail = b.byEmail && typeof b.byEmail === 'object' ? b.byEmail : null;
    if (!byEmail && Array.isArray(b.messages)) {
      byEmail = {};
      for (const m of b.messages) {
        const subj = String(m.subject || '');
        if (!/Sample Follow-Up.*Outstanding Memos/i.test(subj) || /^\s*Re:/i.test(subj)) continue;
        const ms = m.internalDate ? Number(m.internalDate) : (m.date ? Date.parse(m.date) : NaN);
        if (!isFinite(ms)) continue;
        const iso = new Date(ms).toISOString();
        for (let a of String(m.to || '').split(',')) {
          a = a.trim().toLowerCase().replace(/^.*<|>.*$/g, '').trim();
          if (!a.includes('@')) continue;
          const cur = byEmail[a];
          if (!cur) byEmail[a] = { lastSent: iso, count: 1 };
          else { cur.count++; if (iso > cur.lastSent) cur.lastSent = iso; }
        }
      }
    }
    if (!byEmail) return send(res, 400, { error: 'provide { byEmail } or { messages }' });
    const out = { harvested: new Date().toISOString(), source: b.source || 'POST /api/sent', byEmail };
    fs.writeFileSync(p('data', 'sent.json'), JSON.stringify(out, null, 2));
    return send(res, 200, { ok: true, addresses: Object.keys(byEmail).length });
  }

  if (u.pathname === '/api/contact' && req.method === 'POST') {
    const b = await body(req); const c = contacts();
    c[b.slug] = Object.assign({}, c[b.slug], { sample_email: b.sample_email || '', account_number: b.account_number || '' });
    if (b.name) c[b.slug].name = b.name;
    fs.writeFileSync(p('data', 'contacts.json'), JSON.stringify(c, null, 2));
    return send(res, 200, { ok: true });
  }

  if (u.pathname === '/api/preview' && req.method === 'POST') {
    const b = await body(req);
    // Serve the EXACT letter that was staged/sent (consolidated + cleaned), so preview == what the vendor got.
    const st = stagedFor(b.slug);
    if (st) return send(res, 200, { to: st.to, subject: st.subject, html: st.body, count: st.items, exact: true });
    const v = vendorBySlug(fleet().vendors, b.slug);
    if (!v) return send(res, 404, { error: 'no vendor' });
    const d = draftFor(v, contacts());
    return send(res, 200, { to: d.to, subject: d.subject, html: d.html, count: v.items.length, exact: false });
  }

  // Per-vendor send: fires THIS vendor's exact letter through George with the approval token.
  // Human-in-the-loop: only runs on a click in the authed console. Reads creds at runtime, never logs them.
  if (u.pathname === '/api/send-one' && req.method === 'POST') {
    const b = await body(req);
    const c = contacts();
    const cc = c[b.slug] || {};
    if (!cc.sample_email || !cc.account_number) return send(res, 400, { error: 'vendor not ready (needs sample email + account #)' });
    // TK-12320 (Cody): a vendor with 0 outstanding items has nothing to chase — refuse server-side,
    // including a stale staged draft, so no click can send an empty/obsolete letter.
    const _v = vendorBySlug(fleet().vendors, b.slug);
    if (!_v) return send(res, 404, { error: 'no vendor' });
    if (!_v.items || !_v.items.length) return send(res, 409, { error: 'no outstanding items for this vendor — nothing to chase', noItems: true });
    const st = stagedFor(b.slug);
    let payload;
    if (st) payload = { account: 'info', to: st.to, subject: st.subject, body: st.body };
    else { const v = vendorBySlug(fleet().vendors, b.slug); if (!v) return send(res, 404, { error: 'no vendor' }); const d = draftFor(v, c); payload = { account: 'info', to: d.to, subject: d.subject, body: d.html }; }
    // Was this vendor already emailed BEFORE this send? Computed from sent.json (the record of
    // fact), not from b.force — someone can force a genuine first send, and that must stay pass 1.
    const _addrs = String(payload.to).split(',').map(a => a.trim().toLowerCase()).filter(a => a.includes('@'));
    const _be = (sent().byEmail) || {};
    const isResend = _addrs.length > 0 && _addrs.every(a => _be[a]);
    // Resend guard (Cody FIX FIRST): refuse if this vendor's recipients were already emailed, unless {force:true}.
    if (!b.force && isResend) {
      return send(res, 409, { error: 'already sent to this vendor — click again to force-resend', to: payload.to, alreadySent: true });
    }
    // George's canonical creds live in the DW-MCP .env (the file George's server loads into `creds`);
    // GEORGE_EXTERNAL_SEND_TOKEN lives in george-gmail/.env. Search both, canonical first. (2026-08-15 fix:
    // george-gmail/.env has no GEORGE_BASIC_AUTH, so the old single-file read sent an empty pass → 401.)
    const _GENV_FILES = ['/Users/macstudio3/Projects/Designer-Wallcoverings/DW-MCP/.env', '/Users/macstudio3/Projects/george-gmail/.env'];
    const genv = (k) => { for (const f of _GENV_FILES) { try { const m = fs.readFileSync(f, 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch (e) {} } return ''; };
    let gauth = genv('GEORGE_BASIC_AUTH'); if (!gauth.includes(':')) gauth = 'admin:' + genv('GEORGE_BASIC_AUTH_PASS');
    const token = genv('GEORGE_EXTERNAL_SEND_TOKEN');
    if (!token) return send(res, 500, { error: 'GEORGE_EXTERNAL_SEND_TOKEN not configured' });
    georgeRequest({ path: '/api/send', payload, headers: { Authorization: 'Basic ' + Buffer.from(gauth).toString('base64'), 'X-Send-Approval': token } }).then(({ body: gb }) => {
        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) {}
          // 1st chase -> "Date Email Sent to Vendor after 10 Days"; resend (2nd request) ->
          // "Date Sample Request Letter Sent". Both light the FMPro chip. (TK-11409)
          stampFmpro(b.slug, isResend ? 2 : 1);
          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) });
    }).catch((error) => send(res, error.code === 'PRE_SEND_COMPLIANCE_BLOCKED' ? 422 : 502, { error: error.message, reasons: error.reasons || [] }));
    return;
  }

  if (u.pathname === '/api/queue' && req.method === 'POST') {
    const b = await body(req); const c = contacts(); const vs = fleet().vendors;
    const picked = (b.slugs || []).map(s => vendorBySlug(vs, s)).filter(Boolean);
    const queue = picked.map(v => {
      const cc = c[v.slug] || {}; const d = draftFor(v, c);
      const missing = []; if (!cc.sample_email) missing.push('sample_email'); if (!cc.account_number) missing.push('account_number');
      const sendable = missing.length === 0 ? createGmailDraftArtifact({ account: 'info', to: d.to, subject: d.subject, body: d.html }) : { to: d.to, subject: d.subject, body: d.html };
      return { slug: v.slug, name: cc.name || v.name, ...sendable, items: v.items.length, ready: missing.length === 0, missing };
    });
    fs.mkdirSync(p('out'), { recursive: true });
    fs.writeFileSync(p('out', 'send-queue.json'), JSON.stringify({ generated: new Date().toISOString(), queue }, null, 2));
    return send(res, 200, { queued: queue.length, ready: queue.filter(q => q.ready).length, notReady: queue.filter(q => !q.ready).map(q => ({ name: q.name, missing: q.missing })) });
  }

  send(res, 404, { error: 'not found' });
});

const srv = server.listen(process.env.PORT || 64845, '127.0.0.1', () => {
  const port = srv.address().port;
  fs.writeFileSync(p('out', 'viewer-port.txt'), String(port));
  console.log(`Sample Follow-Up console → http://127.0.0.1:${port}/  (admin / DW2024!)`);
});

const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Sample Follow-Up — Vendor Console</title>
<style>
 :root{--b:#e2e2e2}
 body{margin:0;font-family:Arial,Helvetica,sans-serif;color:#1c1c1c;background:#f4f4f5}
 header{position:sticky;top:0;background:#fff;border-bottom:1px solid var(--b);padding:14px 20px;z-index:5}
 h1{font-size:17px;margin:0 0 4px} .sub{color:#777;font-size:13px}
 .bar{display:flex;gap:10px;align-items:center;margin-top:10px;flex-wrap:wrap}
 button{font:inherit;padding:8px 14px;border:1px solid #bbb;border-radius:6px;background:#fff;cursor:pointer}
 button.primary{background:#111;color:#fff;border-color:#111}
 select,input{font:inherit;padding:6px 8px;border:1px solid #bbb;border-radius:6px}
 table{border-collapse:collapse;width:100%;background:#fff}
 th,td{border-bottom:1px solid var(--b);padding:8px 10px;font-size:13px;text-align:left;vertical-align:middle}
 th{background:#fafafa;position:sticky;top:96px;color:#555;font-weight:600}
 .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}
 .wrap{padding:0 20px 40px}
 #modal{position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;align-items:center;justify-content:center;z-index:20}
 #modal .card{background:#fff;max-width:680px;width:92%;max-height:86vh;overflow:auto;border-radius:10px;padding:22px 26px}
 #status{font-size:13px;color:#137a34}
 .muted{color:#999;font-size:12px}
 button.exp{border:none;background:none;cursor:pointer;font:inherit;color:#0645ad;padding:0}
 table.detail{width:100%;margin:4px 0;background:#fafafd;border:1px solid var(--b)}
 table.detail th{position:static;top:auto;background:#f0f0f3;font-size:11px}
 table.detail td{font-size:12px;padding:5px 8px}
 tr.rr td{background:#f6f0ff}
 .badge.rrb{background:#ece2fb;color:#5b2ea6}
 .detailrow>td{padding:0 10px 8px}
 td.actcell{white-space:nowrap}
 .sendbtn{margin-left:6px;background:#137a34;color:#fff;border-color:#137a34}
 .sendbtn.resend{background:#fff;color:#137a34}
 .sendbtn:disabled{opacity:.6;cursor:default}
</style></head><body>
<header>
 <h1>Sample Follow-Up — Vendor Console</h1>
 <div class="sub" id="meta">loading…</div>
 <div class="bar">
   <label><input type="checkbox" id="all"> select all</label>
   <select id="sort">
     <option value="items">Sort: most items</option>
     <option value="age">Sort: oldest first</option>
     <option value="name">Sort: vendor A–Z</option>
     <option value="ready">Sort: ready first</option>
     <option value="unsent">Sort: not-yet-sent first</option>
     <option value="sent">Sort: sent first</option>
   </select>
   <button class="primary" id="queueBtn">Queue selected → Drafts</button>
   <span id="status"></span>
 </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>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>
<div id="modal" onclick="if(event.target.id==='modal')this.style.display='none'"><div class="card" id="mbody"></div></div>
<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:{}};if(!S.fmposted)S.fmposted={byVid:{}};if(!S.resend)S.resend={queue:[]};if(!S.replies)S.replies={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';
 // Freshness of the Sent-folder snapshot — a stale sent.json silently mislabels rows, so surface its age.
 const h=S.sent&&S.sent.harvested?new Date(S.sent.harvested):null;
 if(h){const hrs=(Date.now()-h.getTime())/3.6e6;const label=hrs<1?'just now':hrs<24?Math.round(hrs)+'h ago':Math.round(hrs/24)+'d ago';
   const span=document.createElement('span');span.style.marginLeft='8px';
   span.style.color=hrs>24?'#9a6b12':'#999';span.title='sent.json harvested '+S.sent.harvested;
   span.textContent=(hrs>24?'⚠ ':'')+'· Sent data '+label+(hrs>24?' — re-harvest recommended':'');
   meta.appendChild(span);}}
function ready(v){const c=S.contacts[v.slug]||{};return !!(c.sample_email&&c.account_number&&v.items&&v.items.length);}
// Resolve whether this vendor's follow-up was actually emailed, from the Sent-folder snapshot.
// Matches the vendor's recipient address(es) (contacts.sample_email) against sent.byEmail.
function sentInfo(v){const c=S.contacts[v.slug]||{};const be=(S.sent&&S.sent.byEmail)||{};
 const addrs=String(c.sample_email||'').split(',').map(a=>a.trim().toLowerCase()).filter(a=>a.includes('@'));
 let last=null,count=0;for(const a of addrs){const e=be[a];if(e){count+=e.count||1;if(!last||e.lastSent>last)last=e.lastSent;}}
 return{sent:!!last,lastSent:last,count};}
function fmtSent(iso){try{const d=new Date(iso);return d.toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}catch(e){return iso;}}
function age0(v){return (v.items&&v.items[0]&&v.items[0].age)||0;}
function sorted(){const k=document.getElementById('sort').value;localStorage.setItem('sfsort',k);const a=[...S.fleet.vendors];
 if(k==='items')a.sort((x,y)=>y.count-x.count);if(k==='age')a.sort((x,y)=>age0(y)-age0(x));
 if(k==='name')a.sort((x,y)=>x.name.localeCompare(y.name));if(k==='ready')a.sort((x,y)=>(ready(y)?1:0)-(ready(x)?1:0));
 if(k==='unsent')a.sort((x,y)=>(sentInfo(x).sent?1:0)-(sentInfo(y).sent?1:0));
 if(k==='sent')a.sort((x,y)=>{const sx=sentInfo(x),sy=sentInfo(y);return (sy.lastSent||'').localeCompare(sx.lastSent||'');});return a;}
function itemsTable(v){
 let h='<table class=detail><thead><tr><th>Pattern (Mfr#)</th><th>DW#</th><th>Client</th><th>Initial request</th><th>Re-req</th></tr></thead><tbody>';
 for(const i of v.items){
   const m=String(i.mfr||'');const badMfr=!m.trim()||m.indexOf('***')>=0||m.toLowerCase().indexOf('enter mfr')>=0;
   const mfrCell=badMfr?'<span class="badge need" title="FileMaker data-entry error — no real mfr number">⚠ needs mfr#</span>':esc(i.mfr);
   h+='<tr'+(i.reRequested?' class=rr':'')+'><td>'+mfrCell+'</td><td class=vid>'+esc(i.dw||'-')+'</td><td>'+esc(i.client||'')+'</td>'+
      '<td>'+esc(i.initialReq||i.req||'-')+'</td>'+
      '<td>'+(i.reRequested?'<span class="badge rrb">RE-REQ ×'+i.timesRequested+'</span>':'')+'</td></tr>';
 }
 return h+'</tbody></table>';
}
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>';}
// Reply / 3-biz-day resend status (poll-replies.mjs feeds these)
function resendDue(v){return ((S.resend&&S.resend.queue)||[]).some(q=>q.vid===v.vid);}
function repliedInfo(v){return (S.replies&&S.replies.byVid&&S.replies.byVid[v.vid])||null;}
function replyBadge(v){const rep=repliedInfo(v);
 if(rep&&rep.replied)return ' <span class="badge" style="background:#e6f4ea;color:#137a34" title="'+esc(rep.snippet||'')+'">✉ replied</span>';
 if(resendDue(v))return ' <span class="badge" style="background:#fdecc8;color:#8a5a00" title="3+ business days, no reply — use the resend button">⏰ resend due</span>';
 return '';}
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=
 '<td><input type=checkbox class=pick data-slug="'+v.slug+'"'+(noChase?' disabled':'')+'></td>'+
 '<td><b>'+esc(v.name)+'</b><div class=vid>'+esc(v.vid)+'</div></td>'+
 '<td><button class=exp data-exp="'+v.slug+'">▶ '+v.count+'</button></td>'+
 '<td>'+(v.reReqCount?'<span class="badge rrb">'+v.reReqCount+'</span>':'<span class=muted>–</span>')+'</td>'+
 '<td>'+(v.items&&v.items.length?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>')+replyBadge(v)+'</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=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]||{};
  if(!confirm('Send the follow-up letter now to '+(c.sample_email||slug)+' ?'))return;
  const fire=async(force)=>{const r=await fetch('/api/send-one',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug,force})});return[r.status,await r.json()];};
  b.disabled=true;b.textContent='sending…';
  try{let[code,d]=await fire(false);
   if(code===409){if(!confirm('⚠ Already emailed '+(d.to||slug)+'. Send a SECOND copy anyway?')){await load();return;} [code,d]=await fire(true);}
   if(d&&d.ok){await load();}else{alert('Send failed: '+((d&&d.error)||'unknown')+((d&&d.detail)?'\\n'+d.detail:''));await load();}}
  catch(err){alert('Send error: '+err.message);await load();}}));
 t.querySelectorAll('button.exp').forEach(b=>b.addEventListener('click',e=>{const dr=e.target.closest('tr').nextElementSibling;const open=dr.style.display==='none';dr.style.display=open?'table-row':'none';e.target.textContent=(open?'▼ ':'▶ ')+e.target.textContent.slice(2);}));}
async function saveContact(e){const el=e.target;const slug=el.dataset.slug;const row=[...document.querySelectorAll('input[data-slug="'+slug+'"]')];
 const em=row.find(x=>x.dataset.f==='sample_email').value.trim();const ac=row.find(x=>x.dataset.f==='account_number').value.trim();
 await fetch('/api/contact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug,sample_email:em,account_number:ac})});
 S.contacts[slug]=Object.assign({},S.contacts[slug],{sample_email:em,account_number:ac});render();}
async function preview(slug){const r=await fetch('/api/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug})});const d=await r.json();
 const tag=d.exact?'<span style="color:#137a34;font-weight:700">✓ exact letter sent to vendor</span>':'<span style="color:#9a6b12">live preview — not the staged letter</span>';
 document.getElementById('mbody').innerHTML='<div class=muted>To: '+esc(d.to)+' · '+d.count+' items · '+tag+'</div><hr>'+d.html;document.getElementById('modal').style.display='flex';}
document.getElementById('all').addEventListener('change',e=>{document.querySelectorAll('.pick').forEach(c=>c.checked=e.target.checked);});
document.getElementById('sort').addEventListener('change',render);
document.getElementById('queueBtn').addEventListener('click',async()=>{
 const slugs=[...document.querySelectorAll('.pick:checked')].map(c=>c.dataset.slug);
 if(!slugs.length){alert('Select at least one vendor.');return;}
 const r=await fetch('/api/queue',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slugs})});const d=await r.json();
 let m='Queued '+d.queued+' vendor(s): '+d.ready+' ready to draft.';if(d.notReady.length)m+=' Needs fields: '+d.notReady.map(x=>x.name).join(', ')+'.';
 m+='  →  Now tell Claude in chat: “create the queued drafts”.';document.getElementById('status').textContent=m;});
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
load();
</script></body></html>`;