[object Object]

← back to Dw Pitch Followup

Right-panel detail view (context+last-corresponded on click+letter); quote/booked per invoice; lifetime sales (booked-only) on card+panel; current project from invoice sidemark; lifetime-sales sort

a350beadc6e2781c7ed4b12dda00a426e368c5c6 · 2026-07-07 14:58:22 -0700 · Steve Abrams

Files touched

Diff

commit a350beadc6e2781c7ed4b12dda00a426e368c5c6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 7 14:58:22 2026 -0700

    Right-panel detail view (context+last-corresponded on click+letter); quote/booked per invoice; lifetime sales (booked-only) on card+panel; current project from invoice sidemark; lifetime-sales sort
---
 public/index.html      | 519 +++++++++++++++++++++++--------------------------
 scripts/build-lists.js |  23 ++-
 2 files changed, 263 insertions(+), 279 deletions(-)

diff --git a/public/index.html b/public/index.html
index 22de530..9fd05fd 100644
--- a/public/index.html
+++ b/public/index.html
@@ -23,6 +23,8 @@
   .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:#33280f;color:var(--gold);border-color:var(--gold)}
+  .btn.send:hover{background:var(--gold);color:#1a1611}
   .spacer{flex:1}
   .selcount{color:var(--gold);font-size:13px}
   .seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
@@ -31,13 +33,12 @@
   #bucketWrap{display:none}
   body.flat #bucketWrap{display:inline}
 
-  main{padding:14px 24px 60px}
-  /* BOARD = 3 columns; FLAT = single column */
+  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}
-  body.flat .col{max-height:calc(100vh - 150px)}
   .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}
@@ -47,9 +48,9 @@
   .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}
 
-  /* COMPACT cards by default */
   .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))}
@@ -59,57 +60,53 @@
   .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)}
-  .pats{font-size:calc(11px*var(--scale));color:var(--mut)}
+  .chip.life{color:var(--green);border-color:var(--green);font-weight:600}
+  .proj{font-size:calc(11px*var(--scale));color:var(--gold)}
+  .acts{display:flex;gap:7px;margin-top:2px;flex-wrap:wrap}
 
-  /* expandable detail — collapsed by default so cards stay small */
-  .more{align-self:flex-start;background:none;border:none;color:var(--gold);font-size:calc(11px*var(--scale));cursor:pointer;padding:0}
-  .details{display:none;border-top:1px dashed var(--line);padding-top:6px;margin-top:1px;font-size:calc(11.5px*var(--scale))}
-  .details.show{display:block}
-  .drow{margin:3px 0;color:var(--mut)}
+  /* 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:2px 0 0;padding-left:0;list-style:none}
+  .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 a{color:var(--gold)}
+  .inv{margin:2px 0} .inv a{color:var(--gold)}
   .lastcorr{color:var(--mut)} .lastcorr.found{color:var(--green)}
-
-  .acts{display:flex;gap:7px;margin-top:2px;flex-wrap:wrap}
-  .letter{margin-top:5px;border-top:1px dashed var(--line);padding-top:7px;display:none}
-  .letter.show{display:block}
-  .letter .subj{font-size:12px;color:var(--gold);margin-bottom:5px}
-  textarea{width:100%;min-height:160px;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}
+  .lifebig{font-size:15px;color:var(--green);font-weight:700}
+  .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)}
-  .btn.send{background:#33280f;color:var(--gold);border-color:var(--gold)}
-  .btn.send:hover{background:var(--gold);color:#1a1611}
-  .sendstat{font-size:11.5px;margin-left:2px}
+  .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 · nothing sends without a confirm</small></h1>
+  <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 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>
-      <option value="list3">Vendor Memos Overdue</option>
+      <option value="all">All types</option><option value="list1">Samples Sent ≥90%</option>
+      <option value="list2">Invoiced · No Order</option><option value="list3">Vendor Memos Overdue</option>
     </select>
   </span>
   <span><label>Size</label><input type="range" id="density" min="1" max="5" step="1" /></span>
-  <span><label title="OFF (default) hides manufacturer numbers from view AND from client letters"><input type="checkbox" id="showMfr" /> mfr #</label></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="copyBtn">Copy selected</button>
@@ -117,327 +114,301 @@
   <span class="meta" id="built"></span>
 </div>
 
-<main>
-  <div class="board" id="board"></div>
-</main>
+<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 style="margin-top:6px"><span class="sendstat" id="pSendStat"></span><span class="meta" id="pVariant"></span></div>
+  </div>
+</aside>
 
 <script>
 const LISTS = ['list1','list2','list3'];
 const API = p => `${location.protocol}//${location.host}${p}`;
-const FM_HOST = 'designerwallcoverings.account.filemaker-cloud.com'; // for fmp:// invoice links
+const FM_HOST = 'designerwallcoverings.account.filemaker-cloud.com';
+const USE_FM_SCRIPT = false; // true once a "GotoInvoice" FM script exists (jumps to record)
+const fmpInvoice = inv => USE_FM_SCRIPT ? `fmp://${FM_HOST}/invoice?script=GotoInvoice&param=${encodeURIComponent(inv)}` : `fmp://${FM_HOST}/invoice`;
 const META = {
-  list1:{title:'Samples Sent ≥90%', tag:'Samples ≥90%', defn:'≥90% of the samples the client ordered have shipped — follow up to convert.'},
-  list2:{title:'Invoiced · No Order', tag:'Inv · No Order', defn:'Invoiced in the last ~4 months, all sample-only (no order over $50).'},
-  list3:{title:'Vendor Memos Overdue', tag:'Vendor Memo', defn:'Sample memos WE requested 8–180 days ago that still have not arrived.'},
+  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:'Invoiced in last ~4 months, all sample-only (no order over $50).'},
+  list3:{title:'Vendor Memos Overdue', tag:'Vendor Memo', defn:'Sample memos WE requested 8–180 days ago, not yet arrived.'},
 };
 const SORTS = {
-  list1:[['Invoice total ↓','merch_total:desc'],['Invoice total ↑','merch_total: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:[['Invoice total ↓','merch_total:desc'],['Invoice total ↑','merch_total:asc'],['Newest invoiced','created_at:desc'],['Oldest invoiced','created_at:asc'],['Company A→Z','company:asc'],['Email A→Z','email:asc'],['Account #','account:asc'],['Most invoices','invoice_count:desc'],['Max invoice $ ↓','max_total:desc']],
+  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:[['Lifetime sales ↓','lifetime_sales:desc'],['Lifetime sales ↑','lifetime_sales:asc'],['Newest invoiced','created_at:desc'],['Oldest invoiced','created_at:asc'],['Company A→Z','company:asc'],['Email A→Z','email:asc'],['Account #','account:asc'],['Most invoices','invoice_count:desc'],['Max invoice $ ↓','max_total: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']],
 };
-// Flat (One list) view sorts across ALL merged rows — includes invoice total.
-const FLAT_SORTS=[['Invoice total ↓','merch_total:desc'],['Invoice total ↑','merch_total:asc'],['Newest','created_at:desc'],['Oldest','created_at:asc'],['Company A→Z','company: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 = {};      // rows per container id
-const RENDERED = {};  // count rendered per container id
-const CHUNK = 30;
-let layout = 'board'; // 'board' | 'flat'
+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));
-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'}); };
-const fmtDay = iso => { if(!iso) return ''; const d=new Date(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>` : '';
-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);
-const money = n => '$'+(Number(n)||0).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
-// fmp:// link to the invoice file. Opens FileMaker to the invoice DB reliably today.
-// To JUMP straight to the record, add a 1-line FM script "GotoInvoice" (Enter Find Mode →
-// Set Field[Invoice; Get(ScriptParameter)] → Perform Find) then set USE_FM_SCRIPT=true.
-const USE_FM_SCRIPT = false;
-const fmpInvoice = inv => USE_FM_SCRIPT
-  ? `fmp://${FM_HOST}/invoice?script=GotoInvoice&param=${encodeURIComponent(inv)}`
-  : `fmp://${FM_HOST}/invoice`;
+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));
+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'})};
+const fmtDay=iso=>{if(!iso)return'';const d=new Date(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>`:'';
+const money=n=>'$'+(Number(n)||0).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
+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);
 
-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 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;
-  const [f,dir] = (LS('sort:'+list, SORTS[list][0][1])).split(':');
-  v.sort((a,b)=>{ let x=a[f],y=b[f];
-    if(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); });
+  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;
+  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}));
 }
-// flat mode: merge buckets (respecting the bucket filter), sort by recency
 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]||[])) all.push({r,list}); }
-  if(q) all = all.filter(x=>JSON.stringify(x.r).toLowerCase().includes(q));
-  const [f,dir]=(LS('sort:flat','merch_total:desc')).split(':');
-  all.sort((a,b)=>{ let x=a.r[f],y=b.r[f];
-    if(f==='merch_total'||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); });
+  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]||[]))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 samplesBlock(r){
-  const sent = Array.isArray(r.samples_sent)?r.samples_sent:[];
-  const pend = Array.isArray(r.samples_pending)?r.samples_pending:[];
-  if(!sent.length && !pend.length) return '';
-  const li = (arr,cls,suffix)=>arr.slice(0,12).map(s=>{
-    const name = typeof s==='string'?s:(s.label||s.sku||'sample');
-    const when = (s&&s.date)?` · ${esc(fmtDay(s.date))}`:'';
-    return `<li><span class="dot ${cls}"></span>${esc(name)}${suffix?when:''}</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:[];
-  if(!inv.length) return '';
-  const total = r.merch_total!=null ? r.merch_total : inv.reduce((s,x)=>s+(Number(x.total)||0),0);
-  const rows = inv.slice(0,8).map(x=>`<span class="inv"><a href="${fmpInvoice(x.invoice)}" title="Open invoice ${esc(x.invoice)} in FileMaker">#${esc(x.invoice)}</a> ${esc(money(x.total))}${x.date?` · ${esc(fmtDay(x.date))}`:''}</span>`).join('<br>');
-  return `<div class="drow"><b>Merchandise orders — ${esc(money(total))}</b><div>${rows}</div></div>`;
-}
-
 function cardHTML(list,r){
-  const k = rowKey(list,r), sel = selSet(list).has(k);
-  const sentRec = LS('sent:'+list+':'+k, null);
-  const sentChip = sentRec ? ` <span class="chip sent" title="${esc(new Date(sentRec.at).toISOString())}">✓ sent ${esc(fmtDay(new Date(sentRec.at).toISOString()))}</span>` : '';
-  const btag = layout==='flat' ? `<span class="btag">${esc(META[list].tag)}</span>` : '';
-  let head, facts='';
+  const k=rowKey(list,r),sel=selSet(list).has(k);
+  const sentRec=LS('sent:'+list+':'+k,null);
+  const sentChip=sentRec?` <span class="chip sent" title="${esc(new Date(sentRec.at).toISOString())}">✓ emailed ${esc(fmtDay(new Date(sentRec.at).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 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>`;
+    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')} · acct ${esc(r.account)}</div></div>`;
-    facts = `<div class="chips"><span class="chip a">${r.invoice_count} sample inv</span><span class="chip">max $${r.max_total}</span>${dateChip(r)}</div>`;
+    head=`<div><div class="co">${btag}${esc(r.company)}${sentChip}</div><div class="sub">${esc(r.email||'no email')} · acct ${esc(r.account)}</div></div>`;
+    facts=`<div class="chips"><span class="chip a">${r.invoice_count} sample inv</span>${lifeChip}${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')} · acct ${esc(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>`:''}${dateChip(r)}</div>`;
+    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')} · acct ${esc(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}${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}
-    <button class="more" data-more>▸ details</button>
-    <div class="details">
-      ${samplesBlock(r)}${merchBlock(r)}
-      <div class="drow lastcorr" data-lastcorr>Last corresponded: <span data-lc>…</span></div>
-    </div>
-    <div class="acts"><button class="btn gold" data-gen>Generate letter</button></div>
-    <div class="letter"><div class="subj"></div><div class="ctxnote"></div><textarea spellcheck="false"></textarea>
-      <div class="acts"><button class="btn" data-regen>↻ Regenerate</button><button class="btn" data-copy>Copy</button><button class="btn send" data-send>✉ Send now (info@)</button><span class="sendstat"></span><span class="meta" data-variant></span></div></div>
+    ${facts}${projLine}
+    <div class="acts"><button class="btn gold" data-gen>Generate letter</button><button class="btn" data-view>Details ▸</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('');
-  [...frag.children].forEach((el,i)=>{ el._row = slice[i].r; el._list = slice[i].list; body.appendChild(el); });
-  RENDERED[cid] += slice.length;
+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('');
+  [...frag.children].forEach((el,i)=>{el._row=slice[i].r;el._list=slice[i].list;body.appendChild(el)});
+  RENDERED[cid]+=slice.length;
 }
-function rerenderContainer(cid, rows, list){
+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, list);
+  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), l)); }
-  else { rerenderContainer('flat', flatRows(), null); }
+  if(layout==='board'){LISTS.forEach(l=>rerenderContainer(l,filterSort(l)))}
+  else{rerenderContainer('flat',flatRows())}
 }
 
-// --- last-corresponded: lazy, only when a card's details is opened ---
-async function loadLastCorr(card){
-  const r=card._row; const email=(r.email||'').trim(); const span=card.querySelector('[data-lc]'); const wrap=card.querySelector('[data-lastcorr]');
-  if(!span) return;
-  if(!email){ span.textContent='—'; return; }
+// ---------- 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))}`:'';return `<li><span class="dot ${cls}"></span>${esc(nm)}${w}</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=`<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){
+    out+='<div class="drow">'+inv.slice(0,15).map(x=>`<div class="inv"><span class="chip ${x.booked?'g':'a'}">${x.booked?'BOOKED':'QUOTE'}</span> <a href="${fmpInvoice(x.invoice)}" title="Open invoice ${esc(x.invoice)} in FileMaker">#${esc(x.invoice)}</a> ${esc(money(x.total))}${x.date?` · ${esc(fmtDay(x.date))}`:''}</div>`).join('')+'</div>';
+  }
+  return out;
+}
+function panelContextHTML(list,r){
+  let html='';
+  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>`;
+  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.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=''; document.getElementById('pSendStat').textContent='';
+  openPanel();
+  if(list!=='list3') loadLastCorr(r);
+  if(generate) composeLetter({});
+}
+
+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…';
-  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)'; }
+  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; }
+  if(!val){span.textContent='no prior email found';return}
   wrap.classList.add('found');
-  span.textContent = `${fmtWhen(val.date)}${val.subject?` · "${val.subject.slice(0,50)}"`:''}${val.account?` (${val.account})`:''}`;
+  span.innerHTML=`${esc(fmtWhen(val.date))}${val.subject?` · "${esc(val.subject.slice(0,60))}"`:''}${val.account?` <span class="meta">(${esc(val.account)})</span>`:''}`;
 }
 
-async function genLetter(card, opts={}){
-  const r = card._row, list = card._list;
-  const box = card.querySelector('.letter');
-  const ta = card.querySelector('textarea'); const subj=card.querySelector('.subj'); const note=card.querySelector('.ctxnote');
-  const vlabel = card.querySelector('[data-variant]'); const rbtn = card.querySelector('[data-regen]');
-  const regen = !!opts.regenerate;
-  if(!regen){ if(box.classList.contains('show')){ box.classList.remove('show'); return; } box.classList.add('show'); }
-  else { box.classList.add('show'); }
-  const mfr = showMfr()?1:0;
-  const seed = regen ? (card._seed||1)+1 : (card._seed||1);
-  card._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='…'; }
+async function composeLetter(opts={}){
+  if(!active)return; const {row:r,list}=active;
+  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);
+    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)';
+    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'; } }
+  }catch(e){subj.textContent='error';note.textContent=e.message}
+  finally{if(rbtn){rbtn.disabled=false;rbtn.textContent='↻ Regenerate'}}
 }
 
-async function sendLetter(card, btn){
-  const r = card._row, list = card._list, k = rowKey(list,r);
-  const to = (r.email||'').trim();
-  const subj = card.querySelector('.subj').textContent.replace(/^Subject:\s*/,'').trim();
-  const body = card.querySelector('textarea').value.trim();
-  const stat = card.querySelector('.sendstat');
-  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.`)) return;
-  const old = btn.textContent; btn.disabled=true; btn.textContent='sending…'; stat.style.color='var(--mut)'; stat.textContent='';
+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.`))return;
+  const old=btn.textContent; btn.disabled=true; btn.textContent='sending…'; stat.style.color='var(--mut)'; stat.textContent='';
   try{
-    const res = await fetch(API('/api/send'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to,subject:subj,body})});
-    const j = await res.json();
-    if(j.error) throw new Error(j.blocked ? `blocked by send-guard: ${j.error}` : j.error);
+    const res=await fetch(API('/api/send'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to,subject:subj,body})});
+    const j=await res.json(); if(j.error)throw new Error(j.blocked?`blocked by send-guard: ${j.error}`:j.error);
     btn.textContent='✓ Sent'; stat.style.color='var(--green)'; stat.textContent=`sent via info@${j.inThread?' · in-thread':''}`;
-    setLS('sent:'+list+':'+k, {at:Date.now(), messageId:j.messageId||''});
-  }catch(e){ btn.disabled=false; btn.textContent=old; stat.style.color='var(--red)'; stat.textContent='send failed: '+e.message; }
+    setLS('sent:'+list+':'+k,{at:Date.now(),messageId:j.messageId||''});
+    if(active.card){ const nc=cardHTML(list,r); const tmp=document.createElement('div'); tmp.innerHTML=nc; const fresh=tmp.firstElementChild; fresh._row=r; fresh._list=list; fresh.classList.add('active'); active.card.replaceWith(fresh); active.card=fresh; }
+  }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 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`);
-    }
-  }
+  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');
 }
 
-// --- build containers for the current layout ---
+// ---------- 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('');
-      return `<section class="col">
-        <div class="col-head">
-          <div class="col-title">${META[list].title}<span class="n" id="n-${list}">0</span></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>`;
+    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('');
+      return `<section class="col"><div class="col-head"><div class="col-title">${META[list].title}<span class="n" id="n-${list}">0</span></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('');
-    LISTS.forEach(list=>{ const body=document.getElementById('body-'+list);
-      body.addEventListener('scroll',()=>{ if(body.scrollTop+body.clientHeight >= body.scrollHeight-300) renderChunk(list,list); }); });
+    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','merch_total: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',null); });
+    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()}
 
-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); applyLayout(); });
-document.getElementById('bucket').addEventListener('change',e=>{ setLS('bucket',e.target.value); 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(), null);
-    else rerenderContainer(l, filterSort(l), 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);
-  }
+  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=>{
-  const card=e.target.closest('.card'); if(!card) return;
-  if(e.target.matches('[data-more]')){ const d=card.querySelector('.details'); const open=d.classList.toggle('show'); e.target.textContent=(open?'▾':'▸')+' details'; if(open) loadLastCorr(card); return; }
-  if(e.target.matches('[data-gen]')) return genLetter(card);
-  if(e.target.matches('[data-regen]')) return genLetter(card,{regenerate:true});
-  if(e.target.matches('[data-send]')) return sendLetter(card, e.target);
-  if(e.target.matches('[data-copy]')){ const ta=card.querySelector('textarea'); navigator.clipboard.writeText(`Subject: ${card.querySelector('.subj').textContent.replace(/^Subject: /,'')}\n\n${ta.value}`); e.target.textContent='Copied ✓'; setTimeout(()=>e.target.textContent='Copy',1200); }
+  const card=e.target.closest('.card'); if(!card)return;
+  if(e.target.matches('[data-gen]')) return openPanelFor(card,{generate:true});
+  if(e.target.matches('[data-view]')) return openPanelFor(card,{generate:false});
 });
+// panel buttons
+document.getElementById('pClose').addEventListener('click',closePanel);
+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));
+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();
-});
+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');
+layout=LS('layout','board');
 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: '+e.message+'<br>Run <code>npm run build</code>.</div>'; });
+  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: '+e.message+'<br>Run <code>npm run build</code>.</div>'});
 </script>
 </body>
 </html>
diff --git a/scripts/build-lists.js b/scripts/build-lists.js
index c8310d6..f01c62f 100644
--- a/scripts/build-lists.js
+++ b/scripts/build-lists.js
@@ -244,6 +244,9 @@ async function enrichSamples(accounts) {
 }
 
 // Per account → MERCHANDISE invoices (real orders > MERCH_MIN; sample invoices excluded).
+// Each invoice flagged BOOKED (Date Order Booked present = a real sale) vs QUOTE (unbooked).
+// lifetime = sum of BOOKED merch (real sales, all time); quoted = sum of open quotes.
+// project = the most-recent order's line detail (room/project), best-available "current project".
 async function enrichMerch(accounts) {
   const recs = await findByAccounts('invoice', 'Order Detail', 'Account #', accounts, { chunk: 25, per: 40 });
   const map = new Map();
@@ -255,15 +258,19 @@ async function enrichMerch(accounts) {
     const total = nval(fd['merch tot2'] || fd['MERCHANDISE TOTAL(1)'] || fd['TOTAL 1(1)'] || 0);
     if (total <= MERCH_MIN) continue; // real order vs sample invoice
     const key = acct + '|' + inv; if (seen.has(key)) continue; seen.add(key);
+    const booked = !!fmToISO(fd['Date Order Booked']);
     const dISO = fmToISO(fd['Date Order Booked'] || fd['Date']);
-    const g = map.get(acct) || { invoices: [], total: 0 };
-    g.invoices.push({ invoice: inv, date: dISO, total });
-    g.total += total;
+    // project/room descriptor for this order (Sidemark first, else the line detail)
+    const proj = clean(fd['Sidemark 1(1)'] || '') || clean(fd['DETAIL 1(1)'] || '');
+    const g = map.get(acct) || { invoices: [], lifetime: 0, quoted: 0, project: '', projectISO: '' };
+    g.invoices.push({ invoice: inv, date: dISO, total, booked });
+    if (booked) g.lifetime += total; else g.quoted += total;
+    if (dISO && dISO > (g.projectISO || '') && proj) { g.project = proj; g.projectISO = dISO; }
     map.set(acct, g);
   }
   for (const g of map.values()) {
     g.invoices.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
-    g.invoices = g.invoices.slice(0, 12);
+    g.invoices = g.invoices.slice(0, 15);
   }
   return map;
 }
@@ -283,7 +290,13 @@ async function main() {
     const s = samplesMap.get(String(r.account));
     if (s && (s.sent.length || s.pending.length)) { r.samples_sent = s.sent; r.samples_pending = s.pending; withSamples++; }
     const m = merchMap.get(String(r.account));
-    if (m && m.invoices.length) { r.merch_invoices = m.invoices; r.merch_total = m.total; withMerch++; }
+    if (m && m.invoices.length) {
+      r.merch_invoices = m.invoices;
+      r.lifetime_sales = m.lifetime;   // BOOKED merch only (real sales)
+      r.quoted_total = m.quoted;       // open quotes (not yet booked)
+      if (m.project) r.project = m.project;
+      withMerch++;
+    }
   }
   console.log(`[build-lists] enriched: ${withSamples} rows w/ samples, ${withMerch} rows w/ merch orders`);
 

← 5305246 Expand sort to all fields incl Invoice total (merch_total);  ·  back to Dw Pitch Followup  ·  Rewrite client letter to spec: ~3 sentences, client-type awa 2246a8e →