← 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
M public/index.htmlM scripts/build-lists.js
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¶m=${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=>({'&':'&','<':'<','>':'>','"':'"'}[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¶m=${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=>({'&':'&','<':'<','>':'>','"':'"'}[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 →