← back to Dw Pitch Followup
public/index.html
872 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DW Client Follow-Up · Pitch Viewer</title>
<style>
:root{
--bg:#13110e; --panel:#1c1915; --panel2:#211d18; --line:#34302a;
--ink:#efeae1; --mut:#a59c8d; --gold:#c8a96a; --green:#7fae6a; --amber:#d9a441; --red:#cf6a5a;
--scale:1;
}
*{box-sizing:border-box}
body{margin:0;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink)}
header{padding:14px 24px 9px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#181511,#13110e)}
h1{margin:0;font-size:18px;letter-spacing:.04em;font-weight:600}
h1 small{color:var(--mut);font-weight:400;font-size:12.5px;letter-spacing:0}
.toolbar{position:sticky;top:0;z-index:20;display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:10px 24px;background:var(--panel2);border-bottom:1px solid var(--line)}
.toolbar label{color:var(--mut);font-size:12.5px;margin-right:5px}
select,input[type=search]{background:#15120f;color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:13px}
input[type=range]{vertical-align:middle;width:110px}
.btn{background:#2a2620;color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:6px 11px;cursor:pointer;font-size:13px}
.btn:hover{border-color:var(--gold)}
.btn.gold{background:var(--gold);color:#1a1611;border-color:var(--gold);font-weight:600}
.btn.send{background:var(--gold);color:#1a1611;border-color:var(--gold);font-weight:700;padding:7px 14px}
.btn.send:hover{filter:brightness(1.08)}
.btn.send:disabled{opacity:.45;cursor:not-allowed}
.pletter .acts{align-items:center;gap:8px;padding-bottom:8px;border-bottom:1px solid var(--line);margin-bottom:8px}
.pletter .acts .send{margin-left:auto}
.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}
.noteline{color:var(--mut);font-size:calc(11px*var(--scale));margin:3px 0;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.noteline i{opacity:.6}
.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}
.pgallery:empty{display:none}
.pgallery .pg-h{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
.pgallery .pg-item{display:flex;align-items:center;gap:9px}
.pgallery img{width:40px;height:40px;object-fit:cover;border-radius:6px;border:1px solid var(--line);flex:0 0 auto}
.pgallery .pg-ph{width:40px;height:40px;border-radius:6px;background:#2a251e;flex:0 0 auto}
.pgallery a{color:var(--gold);text-decoration:none;font-size:13px}
.pgallery a:hover{text-decoration:underline}
.pgallery .pg-name{font-size:13px;color:var(--ink)}
.followup{background:var(--panel2);border:1px solid var(--line);border-radius:8px}
.fu-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.fu-badge{font-weight:700;font-size:12.5px;padding:2px 10px;border-radius:12px;border:1px solid var(--line);color:var(--mut)}
.fu-badge.yes{background:#16351c;color:#7ee093;border-color:#2f6b3d}
.fu-badge.no{background:#3a1512;color:#f0a3a0;border-color:#7a3330}
.fu-badge.pend{opacity:.7}
.fu-re{background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:6px;cursor:pointer;font-size:12px;padding:1px 7px}
.fu-re:hover{color:var(--gold);border-color:var(--gold)}
.fu-src{font-size:10px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em;margin-left:auto}
.fu-notes .fu-reason{margin-top:6px;font-size:13px;color:var(--ink)}
.fu-notes .fu-dl{margin-top:4px;font-size:12px;color:var(--amber)}
.fu-notes .fu-ul{margin:6px 0 0;padding-left:18px;font-size:12.5px;color:var(--mut);line-height:1.45}
.emhdr{cursor:pointer;font-weight:600;user-select:none;display:flex;align-items:center;gap:6px}
.emhdr:hover{color:var(--gold)}
.emhdr .emcaret{color:var(--mut);font-size:11px}
.emails{margin-top:6px;display:flex;flex-direction:column;gap:7px;max-height:340px;overflow-y:auto}
.emails[hidden]{display:none}
.emails .emcount{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
.emails .em{border-left:2px solid var(--line);padding:2px 0 3px 9px;font-size:12.5px;line-height:1.4}
.emails .em .emrow{cursor:pointer;user-select:none}
.emails .em .emrow:hover{color:var(--gold)}
.emails .em .emrow .emcaret{color:var(--mut);font-size:10px}
.emails .em .emdate{color:var(--mut)}
.emails .em .emsnip{color:var(--mut);font-size:11.5px;margin-top:2px}
.emails .em .embody{white-space:pre-wrap;font-size:12px;line-height:1.5;color:var(--ink);background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:8px 10px;margin-top:5px;max-height:320px;overflow-y:auto}
.emails .em .embody[hidden]{display:none}
.reorder-note{color:var(--red);font-weight:600;margin:6px 0}
.spacer{flex:1}
.selcount{color:var(--gold);font-size:13px}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
.seg button{background:#15120f;color:var(--mut);border:none;padding:6px 12px;font-size:12.5px;cursor:pointer}
.seg button.on{background:var(--gold);color:#1a1611;font-weight:600}
#bucketWrap{display:none}
body.flat #bucketWrap{display:inline}
main{padding:14px 24px 60px;transition:margin-right .18s ease}
body.panel-open main{margin-right:468px}
.board{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;align-items:start}
body.flat .board{grid-template-columns:minmax(0,760px);justify-content:center}
@media(max-width:1100px){.board{grid-template-columns:1fr}}
.col{background:var(--panel2);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;max-height:calc(100vh - 150px);overflow:hidden}
.col.wide{grid-column:span 2}
.chip.fu-yes{background:#16351c;color:#7ee093;border-color:#2f6b3d}
.chip.fu-no{background:#332017;color:#e0b07e;border-color:#6b4a2f}
.col-tri{background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:6px;cursor:pointer;font-size:11px;line-height:1;padding:2px 7px;margin-left:6px}
.col-tri:hover{color:var(--gold);border-color:var(--gold)}
.col-tri:disabled{opacity:.6;cursor:default}
.col-exp{background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:6px;cursor:pointer;font-size:12px;line-height:1;padding:2px 6px;margin-left:6px}
.col-exp:hover{color:var(--gold);border-color:var(--gold)}
.col-head{padding:10px 13px 9px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#231f19,#211d18)}
.col-title{display:flex;align-items:center;gap:8px;font-weight:600;font-size:14px}
.col-title .n{margin-left:auto;padding:1px 9px;border-radius:11px;background:#2c2822;color:var(--gold);font-size:12px}
.col-defn{font-size:11px;color:var(--mut);margin:4px 0 7px;line-height:1.35}
body.flat .col-defn{display:none}
.col-sort{width:100%}
.col-body{overflow-y:auto;padding:9px;display:flex;flex-direction:column;gap:8px}
.col-empty{color:var(--mut);font-size:12.5px;padding:20px 6px;text-align:center}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:9px 11px;display:flex;flex-direction:column;gap:5px;position:relative;font-size:calc(12.5px*var(--scale))}
.card.sel{border-color:var(--gold);box-shadow:0 0 0 1px var(--gold) inset}
.card.active{border-color:var(--gold)}
.card .top{display:flex;align-items:flex-start;gap:8px}
.card input[type=checkbox]{width:16px;height:16px;margin-top:2px;accent-color:var(--gold);cursor:pointer;flex:0 0 auto}
.co{font-weight:600;font-size:calc(13.5px*var(--scale))}
.btag{font-size:calc(10px*var(--scale));padding:1px 6px;border-radius:9px;background:#2c2822;color:var(--gold);border:1px solid var(--line);margin-right:5px;vertical-align:middle}
.sub{color:var(--mut);font-size:calc(11.5px*var(--scale));word-break:break-word}
.chips{display:flex;flex-wrap:wrap;gap:5px}
.chip{font-size:calc(10.5px*var(--scale));padding:2px 7px;border-radius:10px;background:#26221c;color:var(--mut);border:1px solid var(--line)}
.chip.k{color:var(--gold)} .chip.g{color:var(--green)} .chip.a{color:var(--amber)} .chip.r{color:var(--red)}
.chip.sent{color:var(--green);border-color:var(--green)}
.chip.life{color:var(--green);border-color:var(--green);font-weight:600}
.proj{font-size:calc(11px*var(--scale));color:var(--gold)}
.postflag{background:#3a2a12;color:var(--amber);border:1px solid var(--amber);border-radius:6px;padding:4px 9px;font-size:calc(11px*var(--scale));font-weight:600;margin:3px 0;line-height:1.4}
.postflag a{color:var(--amber)}
/* Discontinued-sample warning: sampled item(s) carry a FileMaker Date Discontinued → dead stock. */
.disco-ban{background:#3a1512;color:var(--red);border:1px solid var(--red);border-radius:6px;padding:5px 9px;font-size:calc(11px*var(--scale));font-weight:700;margin:4px 0;line-height:1.4}
.disco-ban .sub{display:block;font-weight:600;color:#e79a8d;font-size:calc(10px*var(--scale));margin-top:2px}
.disco-ban.all{animation:discoblink 1.05s steps(1,end) infinite}
@keyframes discoblink{50%{background:var(--red);color:#2a0d0a;border-color:#fff}}
.dtag{display:inline-block;background:#3a1512;color:var(--red);border:1px solid var(--red);border-radius:4px;padding:0 5px;margin-left:6px;font-size:calc(9.5px*var(--scale));font-weight:700;letter-spacing:.03em;vertical-align:middle}
.acts{display:flex;gap:7px;margin-top:2px;flex-wrap:wrap}
/* RIGHT PANEL */
.panel{position:fixed;top:0;right:0;height:100vh;width:452px;max-width:94vw;background:var(--panel2);border-left:1px solid var(--line);box-shadow:-10px 0 30px rgba(0,0,0,.45);transform:translateX(100%);transition:transform .18s ease;z-index:40;display:flex;flex-direction:column;overflow-y:auto;padding:16px 18px 40px}
.panel.open{transform:translateX(0)}
.panel-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;border-bottom:1px solid var(--line);padding-bottom:10px;margin-bottom:12px}
.pname{font-weight:600;font-size:16px}
.psub{color:var(--mut);font-size:12.5px;word-break:break-word}
.pctx{font-size:12.5px;margin-bottom:14px}
.drow{margin:5px 0;color:var(--mut)}
.drow b{color:var(--ink);font-weight:600}
.slist{margin:3px 0 0;padding-left:0;list-style:none}
.slist li{padding:1px 0}
.dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;vertical-align:middle}
.dot.ok{background:var(--green)} .dot.pend{background:var(--amber)}
.inv{margin:2px 0} .inv a{color:var(--gold)}
.lastcorr{color:var(--mut)} .lastcorr.found{color:var(--green)}
.lifebig{font-size:15px;color:var(--green);font-weight:700}
body.log-mode .pletter{display:none}
.pletter .subj{font-size:12.5px;color:var(--gold);margin:6px 0 5px}
textarea{width:100%;min-height:250px;background:#15120f;color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:9px;font:13px/1.5 ui-monospace,Menlo,monospace;resize:vertical}
.ctxnote{font-size:11.5px;color:var(--mut);margin:5px 0}
.ctxnote.found{color:var(--green)}
.sendstat{font-size:11.5px;margin-right:8px}
.meta{color:var(--mut);font-size:12px}
a{color:var(--gold)}
</style>
</head>
<body>
<header>
<h1>Designer Wallcoverings — Client Follow-Up <small>· compact · columns or one list · letters open in the right panel</small></h1>
</header>
<div class="toolbar">
<span><input type="search" id="q" placeholder="filter…" /></span>
<span><label>Layout</label>
<span class="seg" id="layoutSeg"><button data-layout="board">Columns</button><button data-layout="flat">One list</button></span>
</span>
<span id="bucketWrap"><label>Show</label>
<select id="bucket">
<option value="all">All types</option><option value="list1">Samples Sent ≥90%</option>
<option value="list2">Invoiced · No Order</option>
</select>
</span>
<span><label>Size</label><input type="range" id="density" min="1" max="5" step="1" /></span>
<span><label title="OFF hides manufacturer numbers"><input type="checkbox" id="showMfr" /> mfr #</label></span>
<span class="spacer"></span>
<span class="selcount" id="selcount">0 selected</span>
<button class="btn" id="sentBtn">📤 Sent log</button>
<button class="btn" id="copyBtn">Copy selected</button>
<button class="btn gold" id="exportBtn">Export ▾</button>
<span class="meta" id="built"></span>
</div>
<main><div class="board" id="board"></div></main>
<aside class="panel" id="panel">
<div class="panel-head">
<div><div class="pname" id="pName"></div><div class="psub" id="pSub"></div></div>
<button class="btn" id="pClose">✕ close</button>
</div>
<div class="pctx" id="pCtx"></div>
<div class="pletter">
<div class="acts">
<button class="btn gold" id="pGen">Generate letter</button>
<button class="btn" id="pRegen">↻ Regenerate</button>
<button class="btn" id="pCopy">Copy</button>
<button class="btn send" id="pSend">✉ Send now (info@)</button>
</div>
<div class="subj" id="pSubj"></div>
<div class="ctxnote" id="pNote"></div>
<textarea id="pBody" spellcheck="false" placeholder="Click “Generate letter” to compose a follow-up grounded in this client’s history…"></textarea>
<div id="pGallery" class="pgallery"></div>
<div style="margin-top:6px"><span class="sendstat" id="pSendStat"></span><span class="meta" id="pVariant"></span></div>
</div>
</aside>
<script>
// PARKED lists are hidden from the board + bucket dropdown but still built server-side.
// Un-park by removing the id from PARKED (one-liner). Vendor Memos Overdue = list3.
const ALL_LISTS = ['list1','list2','list3'];
const PARKED = new Set(['list3']);
const LISTS = ALL_LISTS.filter(l=>!PARKED.has(l));
const API = p => `${location.protocol}//${location.host}${p}`;
const FM_HOST = 'designerwallcoverings.account.filemaker-cloud.com';
// Flip to true ONCE the two FileMaker scripts exist (then the links jump to the exact record):
// GotoInvoice — Enter Find Mode → Set Field [invoice::Invoice ; Get(ScriptParameter)] → Perform Find
// GotoClient — Enter Find Mode → Set Field [Clients::Account Number ; Get(ScriptParameter)] → Perform Find
// Until then it's false: the buttons still open the right FM file (no error), just don't auto-jump.
// ENABLED: links pass a find param to GotoInvoice / GotoClient (add those 2 scripts in FileMaker).
const USE_FM_SCRIPT = true;
const fmpInvoice = inv => USE_FM_SCRIPT ? `fmp://${FM_HOST}/invoice?script=GotoInvoice¶m=${encodeURIComponent(inv)}` : `fmp://${FM_HOST}/invoice`;
const fmpClient = acct => USE_FM_SCRIPT ? `fmp://${FM_HOST}/Clients?script=GotoClient¶m=${encodeURIComponent(acct)}` : `fmp://${FM_HOST}/Clients`;
// Rendered link helpers (used on cards + drawer).
const invoiceLink = (inv,txt) => inv ? `<a class="fmlink" href="${fmpInvoice(inv)}" title="Open invoice #${esc(inv)} in FileMaker Pro">🗂 ${esc(txt||('#'+inv))}</a>` : (txt?esc(txt):'');
const clientLink = (acct,label) => acct ? `<a class="fmlink" href="${fmpClient(acct)}" title="Open client ${esc(acct)} in FileMaker Pro">${esc(label)}</a>` : esc(label);
const META = {
list1:{title:'Samples Sent ≥90%', tag:'Samples ≥90%', defn:'≥90% of ordered samples shipped — follow up to convert.'},
list2:{title:'Invoiced · No Order', tag:'Inv · No Order', defn:'Got a real quote in the last ~4 months (with totals + details) but never booked an order.'},
list3:{title:'Vendor Memos Overdue', tag:'Vendor Memo', defn:'Sample memos WE requested 8–180 days ago, not yet arrived.'},
};
const SORTS = {
list1:[['Lifetime sales ↓','lifetime_sales:desc'],['Lifetime sales ↑','lifetime_sales:asc'],['Newest sent','created_at:desc'],['Oldest sent','created_at:asc'],['Company A→Z','company:asc'],['Email A→Z','email:asc'],['Account #','account:asc'],['Most samples ordered','ordered:desc'],['Most sent','sent:desc'],['Most missing','missing:desc'],['% sent ↓','ratio:desc']],
list2:[['Quote $ ↓','quote_total:desc'],['Quote $ ↑','quote_total:asc'],['Newest quoted','created_at:desc'],['Oldest quoted','created_at:asc'],['Company A→Z','company:asc'],['Email A→Z','email:asc'],['Account #','account:asc'],['Most quotes','quote_count:desc']],
list3:[['Most overdue','days_overdue:desc'],['Least overdue','days_overdue:asc'],['Vendor A→Z','vendor:asc'],['Client A→Z','company:asc'],['SKU A→Z','sku:asc'],['Pattern A→Z','pattern:asc'],['Account #','account:asc']],
};
const FLAT_SORTS=[['Lifetime sales ↓','lifetime_sales:desc'],['Lifetime sales ↑','lifetime_sales:asc'],['Newest','created_at:desc'],['Oldest','created_at:asc'],['Company A→Z','company:asc'],['Account #','account:asc']];
let DATA=null;
const VIEW={}, RENDERED={};
const CHUNK=30;
let layout='board';
let active=null; // {card,row,list,seed}
const LS=(k,d)=>{try{return JSON.parse(localStorage.getItem('dwpf:'+k))??d}catch{return d}};
const setLS=(k,v)=>localStorage.setItem('dwpf:'+k,JSON.stringify(v));
// COMPLETED cards: dismissed by the operator, persisted, never shown again on this pitch system.
const doneKey=(list,r)=>'done:'+list+':'+rowKey(list,r);
const isDone=(list,r)=>!!LS(doneKey(list,r),null);
function markDone(card){
const list=card.dataset.list, r=card._row; if(!r)return;
setLS(doneKey(list,r),{at:Date.now()});
const bodyEl=card.closest('.col-body'); const cid=bodyEl?bodyEl.id.replace('body-',''):list;
// drop from the in-memory VIEW so a scroll-append can't bring it back this session
const arr=VIEW[cid]||[]; const idx=arr.findIndex(x=>x.r===r);
if(idx>=0){arr.splice(idx,1); if(idx<RENDERED[cid])RENDERED[cid]--;}
if(active&&active.card===card)closePanel();
card.remove();
const n=document.getElementById('n-'+cid); if(n)n.textContent=Math.max(0,(+n.textContent||1)-1);
}
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
const fmtWhen=iso=>{if(!iso)return'';const d=new Date(iso);return isNaN(d)?'':d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})};
// Date-only ISO (YYYY-MM-DD) must be parsed as LOCAL, not UTC — else `new Date('2026-07-01')`
// is UTC midnight and renders as Jun 30 in PT. Append midnight-local for the date-only case.
const fmtDay=iso=>{if(!iso)return'';const d=new Date(/^\d{4}-\d{2}-\d{2}$/.test(iso)?iso+'T00:00:00':iso);return isNaN(d)?'':d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})};
const dateChip=r=>r.when_iso?`<span class="chip" title="${esc(r.when_iso)}">🕓 ${esc(r.when_label||'when')}: ${esc(fmtDay(r.when_iso))}</span>`:'';
// Follow-up verdict chip on the card, read from the server-cached triage (blank until analyzed).
function fuChip(r){
if(!r||!r.email) return '';
const v=(window.FU_CACHE||{})[String(r.email).toLowerCase()]; if(!v) return '';
if(v.followUp==='yes') return `<span class="chip fu-yes" title="${esc(v.reason||'follow up')}">🧭 Follow up</span>`;
if(v.followUp==='no') return `<span class="chip fu-no" title="${esc(v.reason||'hold')}">🧭 Hold</span>`;
return '';
}
const money=n=>'$'+(Number(n)||0).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
// Nothing shipped yet: samples are pending but ZERO have been sent → block the letter, prompt a reorder.
const needsReorder=r=>Array.isArray(r.samples_pending)&&r.samples_pending.length>0&&Array.isArray(r.samples_sent)&&r.samples_sent.length===0;
// Samples were sent, but the item carries a FileMaker Date Discontinued → it's really just
// discontinued. `all` = EVERY shipped sample is dead stock → the blanket "how did the samples go /
// ready to order?" pitch is inappropriate; card + panel blink a red DO-NOT-SEND banner. (Steve 2026-07-13)
function discoState(r){
const sent=Array.isArray(r.samples_sent)?r.samples_sent:[];
const dead=sent.filter(s=>s&&s.disco);
if(!sent.length||!dead.length)return null;
return {count:dead.length,total:sent.length,all:dead.length===sent.length,items:dead};
}
function discoBanner(r){
const d=discoState(r); if(!d)return'';
const dates=[...new Set(d.items.map(s=>fmtDay(s.disco)).filter(Boolean))].slice(0,3).join(', ');
const msg=d.all
? `⛔ DO NOT send a blanket follow-up — all ${d.total} sampled item(s) are DISCONTINUED.`
: `⚠️ ${d.count} of ${d.total} sampled item(s) DISCONTINUED — don't blanket-pitch “how did the samples go”.`;
return `<div class="disco-ban ${d.all?'all':''}">${msg}<span class="sub">Discontinued ${esc(dates||'—')} — offer alternatives, don't chase the sale on dead stock.</span></div>`;
}
// Compact card flag: any invoice placed since we sent samples (possible conversion).
function postFlag(r){
// BOOKED orders only — a quote is NOT an order (else a "quoted · no order" card contradicts itself).
const p=(Array.isArray(r.post_sample_invoices)?r.post_sample_invoices:[]).filter(i=>i.booked); if(!p.length) return '';
const detail=p.slice(0,3).map(i=>{const it=goodLbl(i.item);return `#${esc(i.invoice)} ${esc(money(i.total))}${it?` · ${esc(it)}`:''}`}).join(' · ')+(p.length>3?` +${p.length-3} more`:'');
return `<div class="postflag">🧾 Ordered since sampling — ${detail}</div>`;
}
// Panel: full detail — #, total, sku/item, quote/booked, date.
function postSampleBlock(r){
const p=Array.isArray(r.post_sample_invoices)?r.post_sample_invoices:[]; if(!p.length) return '';
const anyBooked=p.some(i=>i.booked);
const rows=p.map(i=>{const it=goodLbl(i.item);return `<div class="inv"><span class="chip ${i.booked?'g':'a'}">${i.booked?'BOOKED':'QUOTE'}</span> ${invoiceLink(i.invoice)} <b>${esc(money(i.total))}</b>${it?` · ${esc(it)}`:''}${i.date?` · ${esc(fmtDay(i.date))}`:''}</div>`}).join('');
const heading=anyBooked?'Ordered since we sent samples':'Quoted since we sent samples (no order yet)';
return `<div class="postflag" style="margin-bottom:12px">🧾 <b>${heading}${r.post_sample_since?` (since ${esc(fmtDay(r.post_sample_since))})`:''}:</b>${rows}</div>`;
}
function stripMfr(s){return String(s||'').replace(/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*/g,t=>/\d/.test(t)?'':t).replace(/\s*[|·•—–-]+\s*/g,' · ').replace(/(?:^[\s·]+|[\s·]+$)/g,'').replace(/(?:·\s*){2,}/g,'· ').replace(/\bSample\b\s*$/i,'').replace(/\s{2,}/g,' ').trim().replace(/[·\s]+$/,'')}
function showMfr(){return !!document.getElementById('showMfr')?.checked}
const lbl=s=>showMfr()?String(s||'').trim():stripMfr(s);
// goodLbl: drop item strings that stripMfr reduced to punctuation junk (e.g. "/ · .") —
// needs >=2 alphabetic chars to be a real, showable detail; else return '' (caller filters).
const goodLbl=s=>{const t=lbl(s);return t.replace(/[^a-z]/gi,'').length>=2?t:''};
function rowKey(list,r){return list==='list3'?`${r.account}|${r.sku}`:r.account}
function selSet(list){return new Set(LS('sel:'+list,[]))}
function saveSel(list,set){setLS('sel:'+list,[...set]);paintSelCount()}
function totalSel(){return LISTS.reduce((n,l)=>n+selSet(l).size,0)}
function paintSelCount(){document.getElementById('selcount').textContent=`${totalSel()} selected`}
function filterSort(list){
const rows=(DATA[list]||[]).slice();
const q=document.getElementById('q').value.trim().toLowerCase();
let v=q?rows.filter(r=>JSON.stringify(r).toLowerCase().includes(q)):rows;
v=v.filter(r=>!isDone(list,r)); // hide completed
const [f,dir]=(LS('sort:'+list,SORTS[list][0][1])).split(':');
v.sort((a,b)=>{let x=a[f],y=b[f]; if(f==='lifetime_sales'||typeof x==='number'||typeof y==='number'){x=+x||0;y=+y||0;return dir==='asc'?x-y:y-x;} x=String(x||'');y=String(y||'');return dir==='asc'?x.localeCompare(y):y.localeCompare(x)});
return v.map(r=>({r,list}));
}
function flatRows(){
const bucket=LS('bucket','all'); const buckets=bucket==='all'?LISTS:[bucket];
const q=document.getElementById('q').value.trim().toLowerCase();
let all=[]; for(const list of buckets){for(const r of (DATA[list]||[])){if(isDone(list,r))continue;all.push({r,list})}}
if(q) all=all.filter(x=>JSON.stringify(x.r).toLowerCase().includes(q));
const [f,dir]=(LS('sort:flat','lifetime_sales:desc')).split(':');
all.sort((a,b)=>{let x=a.r[f],y=b.r[f]; if(f==='lifetime_sales'||typeof x==='number'||typeof y==='number'){x=+x||0;y=+y||0;return dir==='asc'?x-y:y-x;} x=String(x||'');y=String(y||'');return dir==='asc'?x.localeCompare(y):y.localeCompare(x)});
return all;
}
function cardHTML(list,r){
const k=rowKey(list,r),sel=selSet(list).has(k);
// Emailed-date chip: local send record (this browser) OR the server-side sent index
// (every email ever sent from this tool, by recipient) — so the chip shows cross-browser.
const sentRec=LS('sent:'+list+':'+k,null);
const idxTs=(!sentRec&&r.email&&window.SENT_IDX)?SENT_IDX[String(r.email).toLowerCase()]:null;
const sentAt=sentRec?sentRec.at:(idxTs||null);
const sentChip=sentAt?` <span class="chip sent" title="${esc(new Date(sentAt).toISOString())}">✓ emailed ${esc(fmtWhen(new Date(sentAt).toISOString()))}</span>`:'';
const lifeChip=r.lifetime_sales?` <span class="chip life">💰 ${esc(money(r.lifetime_sales))} lifetime</span>`:'';
const projLine=r.project?`<div class="proj">🏠 ${esc(lbl(r.project))}</div>`:'';
const qi=(list==='list2'&&Array.isArray(r.quote_items))?r.quote_items.map(goodLbl).filter(Boolean):[];
const quoteItems=qi.length?`<div class="proj">🧾 ${esc(qi.join(' · '))}</div>`:'';
const btag=layout==='flat'?`<span class="btag">${esc(META[list].tag)}</span>`:'';
let head,facts='';
if(list==='list3'){
head=`<div><div class="co">${btag}${esc(r.vendor)} <span class="chip r">${r.days_overdue}d</span>${sentChip}</div><div class="sub">${esc(r.company||('account '+r.account))} · acct ${esc(r.account)}</div></div>`;
facts=`<div class="chips">${showMfr()?`<span class="chip k">${esc(r.sku||'—')}</span>`:''}<span class="chip">${esc(lbl(r.pattern)||'pattern')}</span>${dateChip(r)}</div>`;
} else if(list==='list2'){
head=`<div><div class="co">${btag}${esc(r.company)}${sentChip}</div><div class="sub">${esc(r.email||'no email')} · ${clientLink(r.account,'acct '+r.account)}</div></div>`;
const qCountChip=(r.quote_count>1)?` <span class="chip a">${r.quote_count} quotes</span>`:'';
facts=`<div class="chips"><span class="chip a">💬 ${esc(money(r.quote_total))} quoted · no order</span>${qCountChip}${fuChip(r)}${dateChip(r)}</div>`;
} else {
head=`<div><div class="co">${btag}${esc(r.company)} <span class="chip g">${r.ratio}%</span>${sentChip}</div><div class="sub">${esc(r.email||'no email')} · ${clientLink(r.account,'acct '+r.account)}</div></div>`;
facts=`<div class="chips"><span class="chip">${r.sent}/${r.ordered} sent</span>${r.missing?`<span class="chip a">${r.missing} missing</span>`:''}${lifeChip}${fuChip(r)}${dateChip(r)}</div>`;
}
return `<div class="card ${sel?'sel':''}" data-k="${esc(k)}" data-list="${list}">
<div class="top"><input type="checkbox" ${sel?'checked':''} data-sel/>${head}</div>
${facts}${projLine}${quoteItems}${postFlag(r)}${discoBanner(r)}
<div class="noteline" data-noteline hidden></div>
<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 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>`;
}
function renderChunk(cid,list){
const body=document.getElementById('body-'+cid); const slice=VIEW[cid].slice(RENDERED[cid],RENDERED[cid]+CHUNK);
if(!slice.length)return;
const frag=document.createElement('div'); frag.innerHTML=slice.map(x=>cardHTML(x.list,x.r)).join('');
const added=[...frag.children];
added.forEach((el,i)=>{el._row=slice[i].r;el._list=slice[i].list;body.appendChild(el)});
RENDERED[cid]+=slice.length;
loadNoteChips(added); // paint invoice# + internal-notes line (batched, cached)
}
function rerenderContainer(cid,rows){
VIEW[cid]=rows; RENDERED[cid]=0;
const body=document.getElementById('body-'+cid); body.innerHTML=rows.length?'':'<div class="col-empty">No matching rows.</div>';
const nEl=document.getElementById('n-'+cid); if(nEl)nEl.textContent=rows.length;
renderChunk(cid);
}
function rerenderAll(){
if(layout==='board'){LISTS.forEach(l=>rerenderContainer(l,filterSort(l)))}
else{rerenderContainer('flat',flatRows())}
}
// ---------- RIGHT PANEL ----------
function samplesBlock(r){
const sent=Array.isArray(r.samples_sent)?r.samples_sent:[], pend=Array.isArray(r.samples_pending)?r.samples_pending:[];
if(!sent.length&&!pend.length)return'';
const li=(arr,cls,withDate)=>arr.slice(0,20).map(s=>{
const nm=typeof s==='string'?s:(s.label||s.sku||'sample');
const w=(withDate&&s&&s.date)?` · ${esc(fmtDay(s.date))}`:'';
const img=(s&&s.image)?`<img src="${esc(s.image)}" style="width:22px;height:22px;object-fit:cover;border-radius:4px;vertical-align:middle;margin-right:6px;border:1px solid var(--line)">`:`<span class="dot ${cls}"></span>`;
const name=(s&&s.url)?`<a href="${esc(s.url)}" target="_blank">${esc(nm)}</a>`:esc(nm);
const dead=(s&&s.disco)?`<span class="dtag" title="Discontinued ${esc(s.disco)}">DISCONTINUED · ${esc(fmtDay(s.disco))}</span>`:'';
return `<li>${img}${name}${w}${dead}</li>`;
}).join('');
let out='';
if(sent.length)out+=`<div class="drow"><b>Samples sent (${sent.length})</b><ul class="slist">${li(sent,'ok',true)}</ul></div>`;
if(pend.length)out+=`<div class="drow"><b>Still pending (${pend.length})</b><ul class="slist">${li(pend,'pend',false)}</ul></div>`;
return out;
}
function merchBlock(r){
const inv=Array.isArray(r.merch_invoices)?r.merch_invoices:[];
const life=r.lifetime_sales||0, quoted=r.quoted_total||0;
if(!inv.length && !life){return '<div class="drow">No merchandise orders on record.</div>'}
let out=(!life && quoted)
? `<div class="drow"><span class="lifebig">No orders booked</span> <span class="chip a">${esc(money(quoted))} in open quotes</span></div>`
: `<div class="drow"><span class="lifebig">Lifetime sales: ${esc(money(life))}</span>${quoted?` <span class="chip a">+ ${esc(money(quoted))} open quotes</span>`:''}</div>`;
if(inv.length){
// Each invoice: BOOKED/QUOTE badge · invoice# (opens in FileMaker) · total · item detail · date.
const invRow=x=>{const it=goodLbl(x.item);return `<div class="inv"><span class="chip ${x.booked?'g':'a'}">${x.booked?'BOOKED':'QUOTE'}</span> ${invoiceLink(x.invoice)} <b>${esc(money(x.total))}</b>${it?` · ${esc(it)}`:''}${x.date?` · ${esc(fmtDay(x.date))}`:''}</div>`};
const booked=inv.filter(x=>x.booked), quotes=inv.filter(x=>!x.booked);
if(booked.length) out+=`<div class="drow"><b>Orders (${booked.length})</b>${booked.slice(0,15).map(invRow).join('')}</div>`;
if(quotes.length) out+=`<div class="drow"><b>Quotes · no order (${quotes.length})</b>${quotes.slice(0,15).map(invRow).join('')}</div>`;
}
return out;
}
function panelContextHTML(list,r){
let html='';
if(list!=='list3') html+=`<div class="drow followup"><div class="fu-head">🧭 <b>Follow up?</b> <span class="fu-badge pend" data-fubadge>analyzing…</span> <button class="fu-re" data-fu-re title="Re-analyze the thread" hidden>↻</button><span class="fu-src">from email thread · local AI</span></div><div class="fu-notes" data-funotes></div></div>`;
html+=discoBanner(r)+postSampleBlock(r);
if(r.project) html+=`<div class="drow">🏠 <b>Current project:</b> ${esc(lbl(r.project))}</div>`;
if(list!=='list3') html+=merchBlock(r);
html+=samplesBlock(r);
html+=`<div class="drow lastcorr" data-lastcorr>📨 Last corresponded: <span data-lc>${(list==='list3')?'—':'click to load…'}</span></div>`;
if(list!=='list3') html+=`<div class="drow emblock"><div class="emhdr" data-emtoggle>📧 All emails with this client <span class="emcaret">▸</span></div><div class="emails" data-emails hidden></div></div>`;
return html;
}
function openPanel(){document.getElementById('panel').classList.add('open');document.body.classList.add('panel-open')}
function closePanel(){document.getElementById('panel').classList.remove('open');document.body.classList.remove('panel-open');document.querySelectorAll('.card.active').forEach(c=>c.classList.remove('active'));active=null}
function openPanelFor(card,{generate=false}={}){
const r=card._row,list=card._list;
document.body.classList.remove('log-mode');
document.querySelectorAll('.card.active').forEach(c=>c.classList.remove('active')); card.classList.add('active');
active={card,row:r,list,seed:1};
const nm=list==='list3'?r.vendor:r.company;
document.getElementById('pName').textContent=nm||('Account '+r.account);
document.getElementById('pSub').textContent=(r.email?r.email+' · ':'')+'acct '+r.account+(list==='list3'?` · re: ${r.company||''}`:'');
document.getElementById('pCtx').innerHTML=panelContextHTML(list,r);
// reset letter area
document.getElementById('pSubj').textContent=''; document.getElementById('pNote').textContent=''; document.getElementById('pNote').className='ctxnote';
document.getElementById('pBody').value=''; document.getElementById('pVariant').textContent=''; const pStat=document.getElementById('pSendStat'); pStat.textContent=''; pStat.style.color='var(--mut)';
// reset every action button so state never sticks from the previous client
const pSend=document.getElementById('pSend'); pSend.disabled=false; pSend.textContent='✉ Send now (info@)';
const pCopy=document.getElementById('pCopy'); pCopy.textContent='Copy';
const pRegen=document.getElementById('pRegen'); pRegen.disabled=false; pRegen.textContent='↻ Regenerate';
// if already emailed, show WHEN in the panel
const prevSent=LS('sent:'+list+':'+rowKey(list,r),null);
if(prevSent){ pStat.style.color='var(--green)'; pStat.textContent=`✓ emailed ${fmtWhen(new Date(prevSent.at).toISOString())}`; }
const reorder=needsReorder(r); const pGen=document.getElementById('pGen'); pGen.disabled=reorder;
if(reorder){ document.getElementById('pSubj').innerHTML='<span class="reorder-note">Reorder Samples?? — nothing has shipped yet, so there’s nothing to follow up on.</span>'; }
openPanel();
renderLetterGallery(r);
if(list!=='list3'){ loadLastCorr(r); loadFollowup(r); } // emails load on demand when the collapsed section is expanded
if(generate && !reorder) composeLetter({});
}
// Small product strip under the letter: thumbnail + link to the DW product page for each
// product the letter references (the client's sampled items carry image + url from dw_unified).
function renderLetterGallery(r){
const el=document.getElementById('pGallery'); if(!el)return;
const items=(Array.isArray(r.samples_sent)?r.samples_sent:[]).filter(s=>s&&(s.image||s.url));
if(!items.length){el.innerHTML='';return;}
el.innerHTML=`<div class="pg-h">Products referenced — thumbnail + DW product page</div>`+
items.slice(0,12).map(s=>{
const nm=esc(lbl(s.label||s.sku||'sample'));
const img=s.image?`<img src="${esc(s.image)}" alt="" loading="lazy">`:`<span class="pg-ph"></span>`;
const name=s.url?`<a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${nm} ↗</a>`:`<span class="pg-name">${nm}</span>`;
return `<div class="pg-item">${img}${name}</div>`;
}).join('');
}
async function loadLastCorr(r){
const wrap=document.querySelector('#pCtx [data-lastcorr]'); const span=document.querySelector('#pCtx [data-lc]');
if(!span)return; const email=(r.email||'').trim(); if(!email){span.textContent='—';return}
const ck='lastcorr:'+email; const c=LS(ck,undefined);
if(c!==undefined){paintLastCorr(wrap,span,c);return}
span.textContent='checking Gmail…';
try{const res=await fetch(API('/api/lastcorr?email='+encodeURIComponent(email)));const j=await res.json();const val=j.date?{date:j.date,subject:j.subject||'',account:j.account||''}:null;setLS(ck,val);paintLastCorr(wrap,span,val)}
catch(e){span.textContent='(lookup failed)'}
}
function paintLastCorr(wrap,span,val){
if(!val){span.textContent='no prior email found';return}
wrap.classList.add('found');
span.innerHTML=`${esc(fmtWhen(val.date))}${val.subject?` · "${esc(val.subject.slice(0,60))}"`:''}${val.account?` <span class="meta">(${esc(val.account)})</span>`:''}`;
}
// Auto follow-up triage: reads the client's email thread → Yes/No + notes (local AI, $0).
// Cached per client for 6h so re-opening is instant; ↻ forces a fresh read.
async function loadFollowup(r,force){
const badge=document.querySelector('#pCtx [data-fubadge]'), notes=document.querySelector('#pCtx [data-funotes]'), reb=document.querySelector('#pCtx [data-fu-re]');
if(!badge)return; const email=(r.email||'').trim();
if(!email){badge.textContent='no email on file';badge.className='fu-badge';return}
const ck='followup:'+email;
if(!force){const c=LS(ck,null); if(c&&c.v&&(Date.now()-(c.at||0)<6*3600e3)){paintFollowup(badge,notes,reb,c.v);return}}
badge.textContent='analyzing thread…'; badge.className='fu-badge pend'; if(notes)notes.innerHTML=''; if(reb)reb.hidden=true;
const q=(Array.isArray(r.merch_invoices)?r.merch_invoices:[]).find(i=>!i.booked);
const quote=q&&q.invoice?('#'+q.invoice):'';
try{
const v=await (await fetch(API('/api/followup-analysis?email='+encodeURIComponent(email)+'&company='+encodeURIComponent(r.company||'')+'"e='+encodeURIComponent(quote)+(force?'&force=1':'')))).json();
setLS(ck,{at:Date.now(),v}); paintFollowup(badge,notes,reb,v);
// sync the board cache + light up this client's card badge immediately
if(v&&v.followUp){ (window.FU_CACHE=window.FU_CACHE||{})[email.toLowerCase()]={followUp:v.followUp,reason:v.reason,deadline:v.deadline||'',at:Date.now()}; refreshCardFor(email); }
}catch(e){ badge.textContent='analysis failed'; badge.className='fu-badge'; if(reb)reb.hidden=false; }
}
// Re-render the on-board card(s) for a client so a new follow-up verdict shows without a full reload.
function refreshCardFor(email){
const lc=String(email).toLowerCase();
document.querySelectorAll('.card').forEach(card=>{
const r=card._row; if(!r||String(r.email||'').toLowerCase()!==lc)return;
const tmp=document.createElement('div'); tmp.innerHTML=cardHTML(card._list,r); const fresh=tmp.firstElementChild;
fresh._row=r; fresh._list=card._list; if(card.classList.contains('active'))fresh.classList.add('active');
card.replaceWith(fresh); if(active&&active.card===card)active.card=fresh;
});
}
// Triage a whole column: analyze each not-yet-cached client's thread (batched, background), badges fill in.
async function triageColumn(list,btn){
const rows=(filterSort(list)||[]).map(x=>x.r).filter(r=>r.email && !(window.FU_CACHE||{})[String(r.email).toLowerCase()]);
if(!rows.length){ btn.textContent='✓ all triaged'; setTimeout(()=>btn.textContent='⚡ Triage',1800); return; }
const total=rows.length; let done=0; btn.disabled=true;
for(let i=0;i<rows.length;i+=25){
const chunk=rows.slice(i,i+25).map(r=>{const q=(r.merch_invoices||[]).find(x=>!x.booked);return {email:r.email,company:r.company||'',quote:q&&q.invoice?('#'+q.invoice):''}});
btn.textContent=`triaging ${done}/${total}…`;
try{
const j=await (await fetch(API('/api/followup-batch'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clients:chunk})})).json();
Object.assign((window.FU_CACHE=window.FU_CACHE||{}), j.map||{});
}catch(e){ /* keep going */ }
done+=chunk.length; rerenderContainer(list,filterSort(list));
}
btn.disabled=false; btn.textContent='✓ done'; setTimeout(()=>btn.textContent='⚡ Triage',2500);
}
function paintFollowup(badge,notes,reb,v){
const d=v.followUp||'unknown';
badge.textContent = d==='yes'?'YES — follow up':(d==='no'?'NO — hold':'—');
badge.className='fu-badge '+(d==='yes'?'yes':(d==='no'?'no':''));
let html='';
if(v.count===0){ html='<div class="fu-reason">No email history with this client to analyze.</div>'; }
else{
if(v.reason) html+=`<div class="fu-reason">${esc(v.reason)}</div>`;
if(v.deadline) html+=`<div class="fu-dl">⏰ deadline ${esc(v.deadline)}</div>`;
if(Array.isArray(v.notes)&&v.notes.length) html+='<ul class="fu-ul">'+v.notes.map(n=>`<li>${esc(n)}</li>`).join('')+'</ul>';
}
if(notes)notes.innerHTML=html; if(reb)reb.hidden=false;
}
// Full email thread history with this client (both directions), loaded when the drawer opens.
async function loadEmails(r){
const el=document.querySelector('#pCtx [data-emails]'); if(!el||el.dataset.loaded)return;
el.dataset.loaded='1';
const email=(r.email||'').trim(); if(!email){el.textContent='no email on file for this client';return}
el.textContent='loading emails…';
try{
const j=await (await fetch(API('/api/emails?email='+encodeURIComponent(email)+'&limit=40'))).json();
const ems=j.emails||[];
if(!ems.length){el.textContent='no emails found with this client';return}
el.innerHTML=`<div class="emcount">${ems.length} email${ems.length===1?'':'s'} (newest first) · click one to read it</div>`+ems.map(m=>{
const inb=m.dir==='in';
return `<div class="em" data-emid="${esc(m.id)}" data-mbx="${esc(m.mailbox||'info')}">`
+`<div class="emrow"><span class="chip ${inb?'g':'a'}" title="${inb?'from client':'we sent'}">${inb?'← in':'→ out'}</span> <span class="emdate">${esc(fmtWhen(m.date))}</span>${m.subject?` · <b>${esc(m.subject.slice(0,80))}</b>`:''} <span class="emcaret">▸</span></div>`
+`${m.snippet?`<div class="emsnip">${esc(m.snippet)}</div>`:''}`
+`<div class="embody" hidden></div>`
+`</div>`;
}).join('');
}catch(e){el.textContent='(email lookup failed)'}
}
async function composeLetter(opts={}){
if(!active)return; const {row:r,list}=active;
if(needsReorder(r)){ document.getElementById('pSubj').innerHTML='<span class="reorder-note">Reorder Samples?? — nothing shipped yet.</span>'; return; }
const subj=document.getElementById('pSubj'),note=document.getElementById('pNote'),ta=document.getElementById('pBody'),vlabel=document.getElementById('pVariant'),rbtn=document.getElementById('pRegen');
const regen=!!opts.regenerate; const mfr=showMfr()?1:0;
const seed=regen?active.seed+1:active.seed; active.seed=seed;
const cacheK=`letter:${list}:${rowKey(list,r)}:m${mfr}:s${seed}`;
if(!regen){const c=LS(cacheK,null);if(c){subj.textContent='Subject: '+c.subject;ta.value=c.body;note.textContent=c.ctx;note.className='ctxnote '+(c.found?'found':'');vlabel.textContent=c.vmeta||'';return}}
subj.textContent=regen?'regenerating…':'composing…';ta.value='';vlabel.textContent='';
if(rbtn){rbtn.disabled=true;rbtn.textContent='…'}
try{
const res=await fetch(API('/api/letter'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({list,row:r,seed,showMfr:showMfr()})});
const j=await res.json(); if(j.error)throw new Error(j.error);
subj.textContent='Subject: '+j.subject; ta.value=j.body;
const ctx=j.context?.found?`✓ grounded in last Gmail thread (${j.context.account}${j.context.subject?': "'+j.context.subject+'"':''})`:'composed from project data (no prior thread)';
note.textContent=ctx; note.className='ctxnote '+(j.context?.found?'found':'');
vlabel.textContent=`variant ${j.variant||seed} · ${j.engine||'local'}${j.engineNote?' · '+j.engineNote:''}`;
setLS(cacheK,{subject:j.subject,body:j.body,ctx,found:!!j.context?.found,vmeta:vlabel.textContent});
setLS('lastletter:'+list+':'+rowKey(list,r),{subject:j.subject,body:j.body});
}catch(e){subj.textContent='error';note.textContent=e.message}
finally{if(rbtn){rbtn.disabled=false;rbtn.textContent='↻ Regenerate'}}
}
// After a send, repaint every LIVE card for this recipient — found by data-k in the DOM,
// not via active.card, which is a DETACHED node whenever the board re-rendered (sort/filter/
// scroll) while the panel was open; replacing a detached node made the ✓ emailed chip look
// like it never appeared. Also covers the same client sitting on cards in other lists.
function repaintSentCards(to){
const lc=String(to||'').trim().toLowerCase(); if(!lc)return;
document.querySelectorAll('.card').forEach(c=>{
const row=c._row; if(!row||String(row.email||'').trim().toLowerCase()!==lc)return;
const tmp=document.createElement('div'); tmp.innerHTML=cardHTML(c._list,row);
const fresh=tmp.firstElementChild; fresh._row=row; fresh._list=c._list;
if(c.classList.contains('active'))fresh.classList.add('active');
c.replaceWith(fresh);
loadNoteChips([fresh]); // keep the invoice/notes line painted (cached — no refetch)
if(active&&active.card===c)active.card=fresh;
});
}
async function sendActive(btn){
if(!active)return; const {row:r,list}=active; const k=rowKey(list,r);
const to=(r.email||'').trim();
const subj=document.getElementById('pSubj').textContent.replace(/^Subject:\s*/,'').trim();
const body=document.getElementById('pBody').value.trim();
const stat=document.getElementById('pSendStat');
if(!to){stat.style.color='var(--red)';stat.textContent=list==='list3'?'no vendor email on file':'no client email on file';return}
if(!subj||!body){stat.style.color='var(--amber)';stat.textContent='generate a letter first';return}
if(!confirm(`Send this letter NOW?\n\nFROM: info@designerwallcoverings.com\nTO: ${to}\nSUBJ: ${subj}\n\nThis emails the recipient immediately.`)){stat.style.color='var(--amber)';stat.textContent='send cancelled';return}
const old=btn.textContent; btn.disabled=true; btn.textContent='sending…'; stat.style.color='var(--mut)'; stat.textContent='';
try{
const payloadBase={to,subject:subj,body,samples:Array.isArray(r.samples_sent)?r.samples_sent:[],list,account:r.account,company:(list==='list3'?r.vendor:r.company)};
let res=await fetch(API('/api/send'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payloadBase)});
let j=await res.json();
if(j.alreadySent){ // double-email guard tripped — let Steve override
btn.disabled=false; if(!confirm(`This client was already emailed ${fmtWhen(new Date(j.lastSent).toISOString())} (within 30 days).\n\nSend AGAIN anyway?`)){ btn.textContent=old; stat.style.color='var(--amber)'; stat.textContent='skipped — already emailed recently'; return; }
btn.disabled=true; btn.textContent='sending…';
res=await fetch(API('/api/send'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...payloadBase,force:true})}); j=await res.json();
}
if(j.error)throw new Error(j.blocked?`blocked by send-guard: ${j.error}`:j.error);
const when=j.sentAt||new Date().toISOString();
btn.textContent='✓ Sent'; stat.style.color='var(--green)'; stat.textContent=`✓ emailed ${fmtWhen(when)}${j.inThread?' · in-thread':''}`;
setLS('sent:'+list+':'+k,{at:Date.parse(when)||Date.now(),messageId:j.messageId||''});
SENT_IDX[to.toLowerCase()]=when; // so cards for this client in OTHER lists show the chip now too
repaintSentCards(to);
}catch(e){btn.disabled=false;btn.textContent=old;stat.style.color='var(--red)';stat.textContent='send failed: '+e.message}
}
function selectedRowsFor(list){const set=selSet(list);return (DATA[list]||[]).filter(r=>set.has(rowKey(list,r)))}
function selectedAsText(){
const blocks=[];
for(const list of LISTS){for(const r of selectedRowsFor(list)){
const L=LS('lastletter:'+list+':'+rowKey(list,r),null);
const who=list==='list3'?`${r.vendor} (re: ${r.company||r.account}, ${r.sku})`:`${r.company} <${r.email||'no-email'}>`;
const hdr=`=== [${META[list].title}] ${who} ===`;
blocks.push(L?`${hdr}\nSubject: ${L.subject}\n\n${L.body}\n`:`${hdr}\n(no letter generated)\n`);
}}
return blocks.join('\n----------------------------------------\n\n');
}
// ---------- containers ----------
function buildBoard(){
const board=document.getElementById('board');
if(layout==='board'){
board.innerHTML=LISTS.map(list=>{
const saved=LS('sort:'+list,SORTS[list][0][1]);
const opts=SORTS[list].map(([t,v])=>`<option value="${v}" ${v===saved?'selected':''}>${t}</option>`).join('');
const wide=LS('wide:'+list,false);
return `<section class="col${wide?' wide':''}" data-col="${list}"><div class="col-head"><div class="col-title">${META[list].title}<span class="n" id="n-${list}">0</span><button class="col-tri" data-tri="${list}" title="Triage this column — decide Follow up? for each client from their email thread">⚡ Triage</button><button class="col-exp" data-exp="${list}" title="Expand / shrink this column">${wide?'⤡':'⤢'}</button></div><div class="col-defn">${META[list].defn}</div><select class="col-sort" data-list="${list}">${opts}</select></div><div class="col-body" id="body-${list}"></div></section>`;
}).join('');
// grid tracks match the number of visible (non-parked) columns so there's no empty gap
board.style.gridTemplateColumns='repeat('+LISTS.length+',minmax(0,1fr))';
LISTS.forEach(list=>{const body=document.getElementById('body-'+list);body.addEventListener('scroll',()=>{if(body.scrollTop+body.clientHeight>=body.scrollHeight-300)renderChunk(list)})});
} else {
const fsaved=LS('sort:flat','lifetime_sales:desc');
const fopts=FLAT_SORTS.map(([t,v])=>`<option value="${v}" ${v===fsaved?'selected':''}>${t}</option>`).join('');
board.innerHTML=`<section class="col"><div class="col-head"><div class="col-title">All correspondence<span class="n" id="n-flat">0</span></div><select class="col-sort" data-list="flat">${fopts}</select></div><div class="col-body" id="body-flat"></div></section>`;
const body=document.getElementById('body-flat'); body.addEventListener('scroll',()=>{if(body.scrollTop+body.clientHeight>=body.scrollHeight-300)renderChunk('flat')});
}
}
function applyLayout(){document.body.classList.toggle('flat',layout==='flat');[...document.querySelectorAll('#layoutSeg button')].forEach(b=>b.classList.toggle('on',b.dataset.layout===layout));buildBoard();rerenderAll()}
// ---------- wiring ----------
document.getElementById('layoutSeg').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;layout=b.dataset.layout;setLS('layout',layout);closePanel();applyLayout()});
document.getElementById('bucket').addEventListener('change',e=>{setLS('bucket',e.target.value);rerenderAll()});
document.getElementById('q').addEventListener('input',rerenderAll);
document.getElementById('board').addEventListener('change',e=>{
if(e.target.matches('.col-sort')){const l=e.target.dataset.list;setLS('sort:'+l,e.target.value);if(l==='flat')rerenderContainer('flat',flatRows());else rerenderContainer(l,filterSort(l));return}
if(e.target.matches('[data-sel]')){const card=e.target.closest('.card');const list=card.dataset.list;const k=card.dataset.k;const set=selSet(list);if(e.target.checked){set.add(k);card.classList.add('sel')}else{set.delete(k);card.classList.remove('sel')}saveSel(list,set)}
});
document.getElementById('board').addEventListener('click',e=>{
// column-level buttons (not inside a card) — handle before the card early-return
const tri=e.target.closest('[data-tri]');
if(tri){ triageColumn(tri.dataset.tri,tri); return; }
const exp=e.target.closest('[data-exp]');
if(exp){const list=exp.dataset.exp;const col=exp.closest('.col');const now=!col.classList.contains('wide');col.classList.toggle('wide',now);setLS('wide:'+list,now);exp.textContent=now?'⤡':'⤢';return}
const card=e.target.closest('.card'); if(!card)return;
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) ----------
// Per-card chip: invoice # + the current note, painted from a batched (and
// server-cached) FM read as each render chunk lands. NOTES_CHIP mirrors the
// server cache so scrolling/rerenders don't refetch.
const NOTES_CHIP={}; // account -> {invoice, notes}
function paintNoteline(card){
const el=card.querySelector('[data-noteline]'); const r=card._row;
if(!el||!r||!r.account) return;
const d=NOTES_CHIP[String(r.account)]; if(!d){el.setAttribute('hidden','');return}
const inv=d.invoice?`🧾 ${invoiceLink(d.invoice,'inv #'+d.invoice)}`:'🧾 inv —';
const first=(d.notes||'').split('\n').find(l=>l.trim())||'';
const noteTxt=first?esc(first.length>110?first.slice(0,110)+'…':first):'<i>no internal notes</i>';
el.innerHTML=`${inv} · 🗒 ${noteTxt}`;
el.title=d.notes||'No internal notes yet — click 🗒 Notes to add one';
el.removeAttribute('hidden');
}
async function loadNoteChips(cards){
const els=cards.filter(c=>c._row&&c._row.account);
els.forEach(c=>{ if(NOTES_CHIP[String(c._row.account)]) paintNoteline(c); });
const need=[...new Set(els.map(c=>String(c._row.account)).filter(a=>!NOTES_CHIP[a]))];
if(!need.length) return;
try{
const res=await fetch(API('/api/notes-batch'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({accounts:need})});
if(!res.ok) return; // chip is decorative — fail silent, the 🗒 button still works
const j=await res.json();
for(const a of need) NOTES_CHIP[a]=j[a]||{invoice:null,notes:''};
els.forEach(paintNoteline);
}catch{}
}
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);
NOTES_CHIP[String(acct)]={invoice:j.invoice,notes:j.notes}; paintNoteline(card);
}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?` · ${invoiceLink(j.invoice,'invoice #'+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='';
NOTES_CHIP[String(acct)]={invoice:j.invoice,notes:j.notes}; paintNoteline(card);
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'));
document.body.classList.add('log-mode');
document.getElementById('pName').textContent='Sent emails';
document.getElementById('pSub').textContent='live — follow-up letters from Gmail Sent + this tool (newest first)';
document.getElementById('pCtx').innerHTML='<div class="drow">loading…</div>';
openPanel();
try{
const j=await (await fetch(API('/api/sent'))).json();
const rows=(j.sent||[]).map(s=>`<div class="drow" style="border-bottom:1px solid var(--line);padding:6px 0">
<div><b>${esc(fmtWhen(s.ts))}</b> → ${esc(s.to)}${s.inThread?' <span class="chip g">in-thread</span>':''}</div>
<div style="color:var(--mut);font-size:12px">${esc(s.company||'')}${s.company?' · ':''}${esc(s.subject||'')}</div></div>`).join('');
document.getElementById('pCtx').innerHTML=`<div class="drow"><b>${j.count||0} emails sent</b> (newest first)</div>`+(rows||'<div class="drow">None sent yet.</div>');
}catch(e){ document.getElementById('pCtx').innerHTML='<div class="drow">Could not load: '+esc(e.message)+'</div>'; }
}
document.getElementById('sentBtn').addEventListener('click',openSentLog);
// panel buttons
document.getElementById('pClose').addEventListener('click',()=>{document.body.classList.remove('log-mode');closePanel();});
// Expand/collapse the client's email history — lazy-load on first expand (collapsed by default).
document.getElementById('pCtx').addEventListener('click',e=>{
// 0) re-run the follow-up analysis
if(e.target.closest('[data-fu-re]')){ if(active&&active.row)loadFollowup(active.row,true); return; }
// 1) expand/collapse the whole email-history section
const t=e.target.closest('[data-emtoggle]');
if(t){
const box=t.parentElement.querySelector('[data-emails]'); if(!box)return;
const open=box.hasAttribute('hidden');
if(open){ box.removeAttribute('hidden'); t.querySelector('.emcaret').textContent='▾'; if(active&&active.row)loadEmails(active.row); }
else { box.setAttribute('hidden',''); t.querySelector('.emcaret').textContent='▸'; }
return;
}
// 2) expand/collapse an individual email → lazy-load its full body
const row=e.target.closest('.emrow');
if(row){
const em=row.closest('.em'), body=em.querySelector('.embody'), caret=row.querySelector('.emcaret');
if(body.hasAttribute('hidden')){
body.removeAttribute('hidden'); if(caret)caret.textContent='▾';
if(!body.dataset.loaded){ body.dataset.loaded='1'; body.textContent='loading…';
fetch(API('/api/email?id='+encodeURIComponent(em.dataset.emid)+'&account='+encodeURIComponent(em.dataset.mbx||'info')))
.then(r=>r.json()).then(j=>{ body.textContent=(j.text&&j.text.trim())?j.text:'(no readable body)'; })
.catch(()=>{ body.textContent='(failed to load email)'; body.dataset.loaded=''; });
}
} else { body.setAttribute('hidden',''); if(caret)caret.textContent='▸'; }
}
});
document.getElementById('pGen').addEventListener('click',()=>composeLetter({}));
document.getElementById('pRegen').addEventListener('click',()=>composeLetter({regenerate:true}));
document.getElementById('pCopy').addEventListener('click',e=>{const ta=document.getElementById('pBody');navigator.clipboard.writeText(`Subject: ${document.getElementById('pSubj').textContent.replace(/^Subject: /,'')}\n\n${ta.value}`);e.target.textContent='Copied ✓';setTimeout(()=>e.target.textContent='Copy',1200)});
document.getElementById('pSend').addEventListener('click',e=>sendActive(e.target));
document.addEventListener('keydown',e=>{if(e.key==='Escape')closePanel()});
const dens=document.getElementById('density');
dens.value=LS('density',3);
const applyScale=v=>document.documentElement.style.setProperty('--scale',(0.78+(+v)*0.09).toFixed(2));
applyScale(dens.value);
dens.addEventListener('input',e=>{setLS('density',+e.target.value);applyScale(e.target.value)});
const mfrToggle=document.getElementById('showMfr'); mfrToggle.checked=LS('showMfr',false);
mfrToggle.addEventListener('change',e=>{setLS('showMfr',e.target.checked);rerenderAll()});
document.getElementById('bucket').value=LS('bucket','all');
document.getElementById('copyBtn').addEventListener('click',()=>{const t=selectedAsText();if(!t){alert('Nothing selected.');return}navigator.clipboard.writeText(t);const b=document.getElementById('copyBtn');b.textContent='Copied ✓';setTimeout(()=>b.textContent='Copy selected',1400)});
document.getElementById('exportBtn').addEventListener('click',()=>{if(!totalSel()){alert('Nothing selected.');return}const md=`# DW Follow-Up — selected correspondence\n_${totalSel()} selected · ${new Date().toLocaleString()}_\n\n`+selectedAsText();const blob=new Blob([md],{type:'text/markdown'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`dw-followup-${Date.now()}.md`;a.click()});
layout=LS('layout','board');
// Load the real "who have we emailed" index first (best-effort) so every card can show a
// live emailed-date chip, then render the lists. A slow/failed index never blocks the board.
window.SENT_IDX={}; window.FU_CACHE={};
// Refresh the sent-index when the tab regains focus (a tab left open on another
// machine/browser otherwise NEVER learns about sends made elsewhere), then paint
// the ✓ emailed chip onto any visible card that gained a send. Cheap: one GET,
// server caches 60s, repaints only cards that were missing the chip.
let _idxBusy=false;
async function refreshSentIdx(){
if(_idxBusy)return; _idxBusy=true;
try{
const j=await fetch(API('/api/sent-index')).then(r=>r.json());
window.SENT_IDX=j.idx||window.SENT_IDX;
const toRepaint=new Set();
document.querySelectorAll('.card').forEach(c=>{
const row=c._row; if(!row||!row.email||c.querySelector('.chip.sent'))return;
const lc=String(row.email).trim().toLowerCase();
if(SENT_IDX[lc])toRepaint.add(lc);
});
toRepaint.forEach(repaintSentCards);
}catch{/* best-effort — next focus retries */}finally{_idxBusy=false;}
}
document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible')refreshSentIdx()});
Promise.allSettled([
fetch(API('/api/sent-index')).then(r=>r.json()).then(j=>{window.SENT_IDX=j.idx||{}}),
fetch(API('/api/followup-cache')).then(r=>r.json()).then(j=>{window.FU_CACHE=j.map||{}}),
]).finally(()=>{
fetch(API('/api/lists')).then(r=>r.json()).then(j=>{
DATA=j; document.getElementById('built').textContent='built '+new Date(j.generatedAt).toLocaleString();
paintSelCount(); applyLayout();
}).catch(e=>{document.getElementById('board').innerHTML='<div class="col-empty">Could not load lists: '+esc(e.message)+'<br>Run <code>npm run build</code>.</div>'});
});
</script>
</body>
</html>