← 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
M public/index.htmlM scripts/build-lists.js
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¶m=${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 →