[object Object]

← back to Dw Pitch Followup

pitch: redefine "Invoiced · No Order" list as real quotes never booked (totals + details)

a59c63bca566f987ced6afd058db7ca56c4070ad · 2026-07-14 10:46:56 -0700 · Steve

- buildList2 candidate pool flipped from sample-only (maxTotal<=$50) to real-dollar invoices
- main() keeps only accounts with an open merch quote (quoted_total>0) and NO booked order (lifetime_sales==0), deriving quote_total / quote_count / quote_items
- cards show "$X quoted · no order" chip + quoted item details; sorts now quote-based
- drawer reads "No orders booked · $X in open quotes" for these never-booked prospects
- list2: 57 accounts (e.g. $92,250 Briseyda Garcia quote, never ordered)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit a59c63bca566f987ced6afd058db7ca56c4070ad
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 14 10:46:56 2026 -0700

    pitch: redefine "Invoiced · No Order" list as real quotes never booked (totals + details)
    
    - buildList2 candidate pool flipped from sample-only (maxTotal<=$50) to real-dollar invoices
    - main() keeps only accounts with an open merch quote (quoted_total>0) and NO booked order (lifetime_sales==0), deriving quote_total / quote_count / quote_items
    - cards show "$X quoted · no order" chip + quoted item details; sorts now quote-based
    - drawer reads "No orders booked · $X in open quotes" for these never-booked prospects
    - list2: 57 accounts (e.g. $92,250 Briseyda Garcia quote, never ordered)
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 public/index.html      | 14 +++++++++-----
 scripts/build-lists.js | 33 +++++++++++++++++++++++++++++----
 2 files changed, 38 insertions(+), 9 deletions(-)

diff --git a/public/index.html b/public/index.html
index c858ee4..7a39192 100644
--- a/public/index.html
+++ b/public/index.html
@@ -159,12 +159,12 @@ const USE_FM_SCRIPT = false; // true once a "GotoInvoice" FM script exists (jump
 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 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).'},
+  list2:{title:'Invoiced · No Order', tag:'Inv · No Order', defn:'Got a real quote in the last ~4 months (with totals + details) but never booked an order.'},
   list3:{title:'Vendor Memos Overdue', tag:'Vendor Memo', defn:'Sample memos WE requested 8–180 days ago, not yet arrived.'},
 };
 const SORTS = {
   list1:[['Lifetime sales ↓','lifetime_sales:desc'],['Lifetime sales ↑','lifetime_sales:asc'],['Newest sent','created_at:desc'],['Oldest sent','created_at:asc'],['Company A→Z','company:asc'],['Email A→Z','email:asc'],['Account #','account:asc'],['Most samples ordered','ordered:desc'],['Most sent','sent:desc'],['Most missing','missing:desc'],['% sent ↓','ratio:desc']],
-  list2:[['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']],
+  list2:[['Quote $ ↓','quote_total:desc'],['Quote $ ↑','quote_total:asc'],['Newest quoted','created_at:desc'],['Oldest quoted','created_at:asc'],['Company A→Z','company:asc'],['Email A→Z','email:asc'],['Account #','account:asc'],['Most quotes','quote_count:desc']],
   list3:[['Most overdue','days_overdue:desc'],['Least overdue','days_overdue:asc'],['Vendor A→Z','vendor:asc'],['Client A→Z','company:asc'],['SKU A→Z','sku:asc'],['Pattern A→Z','pattern:asc'],['Account #','account:asc']],
 };
 const FLAT_SORTS=[['Lifetime sales ↓','lifetime_sales:desc'],['Lifetime sales ↑','lifetime_sales:asc'],['Newest','created_at:desc'],['Oldest','created_at:asc'],['Company A→Z','company:asc'],['Account #','account:asc']];
@@ -249,6 +249,7 @@ function cardHTML(list,r){
   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 quoteItems=(list==='list2'&&Array.isArray(r.quote_items)&&r.quote_items.length)?`<div class="proj">🧾 ${esc(r.quote_items.map(lbl).filter(Boolean).join(' · '))}</div>`:'';
   const btag=layout==='flat'?`<span class="btag">${esc(META[list].tag)}</span>`:'';
   let head,facts='';
   if(list==='list3'){
@@ -256,14 +257,15 @@ function cardHTML(list,r){
     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>${lifeChip}${dateChip(r)}</div>`;
+    const qCountChip=(r.quote_count>1)?` <span class="chip a">${r.quote_count} quotes</span>`:'';
+    facts=`<div class="chips"><span class="chip a">💬 ${esc(money(r.quote_total))} quoted · no order</span>${qCountChip}${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>`:''}${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}${projLine}${postFlag(r)}${discoBanner(r)}
+    ${facts}${projLine}${quoteItems}${postFlag(r)}${discoBanner(r)}
     <div class="acts">${needsReorder(r)
       ? `<button class="btn reorder" data-view>Reorder Samples??</button>`
       : `<button class="btn gold" data-gen>Generate letter</button><button class="btn" data-view>Details ▸</button>`}</div>
@@ -309,7 +311,9 @@ 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>`;
+  let out=(!life && quoted)
+    ? `<div class="drow"><span class="lifebig">No orders booked</span> <span class="chip a">${esc(money(quoted))} in open quotes</span></div>`
+    : `<div class="drow"><span class="lifebig">Lifetime sales: ${esc(money(life))}</span>${quoted?` <span class="chip a">+ ${esc(money(quoted))} open quotes</span>`:''}</div>`;
   if(inv.length){
     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>';
   }
diff --git a/scripts/build-lists.js b/scripts/build-lists.js
index fa91853..12de834 100644
--- a/scripts/build-lists.js
+++ b/scripts/build-lists.js
@@ -2,7 +2,9 @@
 //
 // Three follow-up "pitch" populations for Designer Wallcoverings:
 //   List 1  Samples-sent clients   — >=90% of ordered samples have shipped (follow up to convert).
-//   List 2  Stale invoices (4mo)   — invoiced in the last 4 months, NO firm order (all sample-only).
+//   List 2  Quoted · No Order (4mo) — got a REAL quote in the last ~4 months (unbooked merch
+//                                     invoice with a real $ total + line details) but NEVER
+//                                     booked an order. "Invoiced · No Order" = quoted, didn't buy.
 //   List 3  Overdue vendor memos   — samples WE requested >8 days ago, not yet received (chase vendor).
 //
 // No writes anywhere (lib/fm.js forces FM_READONLY=1). No email. Pure read → JSON.
@@ -118,7 +120,10 @@ async function buildList1() {
 }
 
 // ====================================================================
-// LIST 2 — invoiced in last ~4 months, NO firm order (all sample-only)
+// LIST 2 — "Quoted · No Order": got a real quote in the last ~4 months but never booked.
+// This function only produces the CANDIDATE pool — accounts with a real-dollar invoice in
+// the window (a quote OR a booked order). enrichMerch() then reads Date Order Booked to split
+// booked vs quote, and main() keeps only accounts carrying an open quote with NO booked order.
 // ====================================================================
 async function buildList2() {
   const rows = await findAll(
@@ -144,8 +149,9 @@ async function buildList2() {
     if (detail) g.details.push(detail.slice(0, 80));
     byAcct.set(acct, g);
   }
-  // Keep accounts with NO firm order (every invoice in window <= FIRM_ORDER_MIN).
-  const kept = [...byAcct.values()].filter((g) => g.maxTotal <= FIRM_ORDER_MIN);
+  // Candidate pool = accounts with a real-dollar invoice in the window (a quote OR a booked
+  // order — sample-only accounts are dropped). enrichMerch()+main() decide quote-vs-booked.
+  const kept = [...byAcct.values()].filter((g) => g.maxTotal > FIRM_ORDER_MIN);
   const parents = await findProjectsByAccounts(kept.map((g) => g.account));
 
   const out = kept.map((g) => {
@@ -366,6 +372,25 @@ async function main() {
       withMerch++;
     }
   }
+  // LIST 2 is "Quoted · No Order": keep ONLY accounts carrying an open (unbooked) merch quote
+  // with NO booked order ever. quoted_total / lifetime_sales come from enrichMerch (merch only,
+  // samples excluded). lifetime_sales is summed all-time BEFORE the 15-invoice display slice, so
+  // it's the authoritative "did they ever book" signal; merch_invoices is display best-effort.
+  const list2Quoted = [];
+  for (const r of list2) {
+    if (!(r.quoted_total > 0)) continue;   // must carry a real open quote
+    if (r.lifetime_sales > 0) continue;    // and have never booked a merch order
+    const quotes = (Array.isArray(r.merch_invoices) ? r.merch_invoices : []).filter((i) => !i.booked);
+    r.quote_total = r.quoted_total;
+    r.quote_count = quotes.length || undefined;
+    r.quote_items = [...new Set(quotes.map((i) => i.item).filter(Boolean))].slice(0, 6);
+    const lastQ = quotes.map((i) => i.date).filter(Boolean).sort().pop() || r.when_iso;
+    if (lastQ) { r.when_iso = lastQ; r.created_at = lastQ; }
+    r.when_label = 'last quoted';
+    list2Quoted.push(r);
+  }
+  list2.length = 0; list2.push(...list2Quoted);
+
   // FLAG any invoice dated on/after the client started sampling — a possible conversion.
   let withPost = 0;
   for (const r of [...list1, ...list2]) {

← 395d98e chore: lint (esc XSS autofix + enrich-disco psql guard), ref  ·  back to Dw Pitch Followup  ·  auto-save: 2026-07-14T11:00:40 (1 files) — public/index.html d4a8d30 →