← 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&param=${encodeURIComponent(inv)}` : `fmp://${FM_HOST}/invoice`;
const fmpClient  = acct => USE_FM_SCRIPT ? `fmp://${FM_HOST}/Clients?script=GotoClient&param=${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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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||'')+'&quote='+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>