[object Object]

← back to Vendor Price Requests

VPR: drafts first-class — save-draft persistence, draft/sent status badges, filter tabs (All/To draft/Drafts/Sent), saved draft pre-loads

75f5291e9b2355d183a30dc2dc2a01091920b299 · 2026-06-17 13:17:48 -0700 · Steve Abrams

Files touched

Diff

commit 75f5291e9b2355d183a30dc2dc2a01091920b299
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 17 13:17:48 2026 -0700

    VPR: drafts first-class — save-draft persistence, draft/sent status badges, filter tabs (All/To draft/Drafts/Sent), saved draft pre-loads
---
 public/index.html | 25 ++++++++++++++++++++-----
 server.js         | 27 +++++++++++++++++++++------
 2 files changed, 41 insertions(+), 11 deletions(-)

diff --git a/public/index.html b/public/index.html
index 168cede..7dd1ce8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -26,24 +26,37 @@ button{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(-
 .toast{position:fixed;bottom:18px;right:18px;background:var(--ink);color:var(--cream);padding:11px 16px;border-radius:8px;opacity:0;transition:.2s}
 .toast.show{opacity:1}
 .warn{background:#f6ecd6;border:1px solid #e3d2a0;border-radius:8px;padding:8px 12px;font-size:12.5px;color:#7a5b1e;margin-bottom:12px}
+.fbtn{font-size:12px;padding:6px 12px}.fbtn.active{background:var(--ink);color:var(--cream);border-color:var(--ink)}
+.badge-d{color:var(--gold);font-size:11px;font-weight:700}
+.save{background:var(--paper);border:1px solid var(--gold);color:var(--gold);font-weight:600}
 </style></head><body>
 <h1>Vendor Price Requests</h1>
 <div class="sub">Per-vendor letters asking for current trade pricing on showroom SKUs we have no cost for. Review &middot; set recipient &middot; <b>Send is gated — nothing goes out until you click Send.</b></div>
 <div id="stats"></div>
+<div class="row" style="margin-top:10px;gap:6px" id="filters">
+  <button class="fbtn active" data-f="all">All</button>
+  <button class="fbtn" data-f="todo">To draft</button>
+  <button class="fbtn" data-f="drafted">✎ Drafts</button>
+  <button class="fbtn" data-f="sent">✓ Sent</button>
+</div>
 <div class="layout" style="margin-top:14px">
   <div class="vlist" id="vlist"></div>
   <div class="panel" id="panel"><div class="empty">Select a vendor to review its letter.</div></div>
 </div>
 <div class="toast" id="toast"></div>
 <script>
-const $=s=>document.querySelector(s); let VEND=[], cur=null;
+const $=s=>document.querySelector(s); let VEND=[], cur=null, FILTER="all";
 function toast(m){const t=$("#toast");t.textContent=m;t.classList.add("show");setTimeout(()=>t.classList.remove("show"),2600);}
+function statusOf(v){ if(v.letter_status==='sent')return 'sent'; if(v.has_draft||v.letter_status==='drafted')return 'drafted'; return 'todo'; }
 async function load(){
   const d=await (await fetch("/api/vendors")).json(); VEND=d.vendors||[];
-  const totalSkus=VEND.reduce((a,v)=>a+v.gap_count,0), sent=VEND.filter(v=>v.letter_status==="sent").length;
-  $("#stats").innerHTML=`<span class="stat"><b>${VEND.length}</b> vendors</span><span class="stat"><b>${totalSkus.toLocaleString()}</b> SKUs needing price</span><span class="stat"><b>${sent}</b> letters sent</span>`;
-  $("#vlist").innerHTML=VEND.map(v=>`<div class="v ${cur===v.vendor?'active':''}" onclick="pick('${v.vendor.replace(/'/g,"\\'")}')"><span class="n">${esc(v.vendor)}</span><span class="c">${v.letter_status==='sent'?'<span class=sent>✓ sent</span>':v.gap_count+' skus'}</span></div>`).join("");
+  const totalSkus=VEND.reduce((a,v)=>a+v.gap_count,0), sent=VEND.filter(v=>statusOf(v)==='sent').length, drafted=VEND.filter(v=>statusOf(v)==='drafted').length;
+  $("#stats").innerHTML=`<span class="stat"><b>${VEND.length}</b> vendors</span><span class="stat"><b>${totalSkus.toLocaleString()}</b> SKUs needing price</span><span class="stat"><b>${drafted}</b> drafts</span><span class="stat"><b>${sent}</b> sent</span>`;
+  const rows=VEND.filter(v=>FILTER==='all'||statusOf(v)===FILTER);
+  $("#vlist").innerHTML=rows.length?rows.map(v=>{const st=statusOf(v);const tag=st==='sent'?'<span class=sent>✓ sent</span>':st==='drafted'?'<span class=badge-d>✎ draft</span>':v.gap_count+' skus';
+    return `<div class="v ${cur===v.vendor?'active':''}" onclick="pick('${v.vendor.replace(/'/g,"\\'")}')"><span class="n">${esc(v.vendor)}</span><span class="c">${tag}</span></div>`;}).join(""):'<div class="empty" style="padding:30px">none</div>';
 }
+document.querySelectorAll(".fbtn").forEach(b=>b.onclick=()=>{FILTER=b.dataset.f;document.querySelectorAll(".fbtn").forEach(x=>x.classList.toggle("active",x===b));load();});
 function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
 async function pick(v){
   cur=v; load();
@@ -55,12 +68,14 @@ async function pick(v){
     ${!d.email?'<div class="warn">No saved recipient email for this vendor — enter one below before sending.</div>':''}
     <div class="row"><div style="flex:1"><label>Recipient email</label><input id="to" value="${esc(d.email||'')}" placeholder="purchasing@vendor.com"></div></div>
     <div class="row" style="flex-direction:column;align-items:stretch"><label>Subject</label><input id="subj" value="${esc(d.subject)}"></div>
-    <div class="row" style="flex-direction:column;align-items:stretch"><label>Letter (editable)</label><textarea id="body">${esc(d.body)}</textarea></div>
+    <div class="row" style="flex-direction:column;align-items:stretch"><label>Letter (editable)${d.hasDraft?' <span class="badge-d">• saved draft'+(d.draftSavedAt?' '+new Date(d.draftSavedAt).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'')+'</span>':''}</label><textarea id="body">${esc(d.body)}</textarea></div>
     <div class="row"><button onclick="saveEmail()">Save email</button>
+      <button class="save" onclick="saveDraft()">✎ Save draft</button>
       <button class="send" id="sendBtn" onclick="send()">✉ Approve &amp; Send to vendor</button>
       <span style="font-size:12px;color:var(--muted)">Sends from info@designerwallcoverings.com via George</span></div>`;
 }
 async function saveEmail(){ await fetch("/api/contact/"+encodeURIComponent(cur),{method:"POST",headers:{'Content-Type':'application/json'},body:JSON.stringify({email:$("#to").value})}); toast("Email saved"); load(); }
+async function saveDraft(){ await fetch("/api/draft/"+encodeURIComponent(cur),{method:"POST",headers:{'Content-Type':'application/json'},body:JSON.stringify({subject:$("#subj").value,body:$("#body").value,email:$("#to").value})}); toast("Draft saved"); load(); }
 async function send(){
   const to=$("#to").value.trim(); if(!/.+@.+\..+/.test(to)){toast("Enter a valid recipient email");return;}
   if(!confirm("Send this pricing request to "+to+" ?")) return;
diff --git a/server.js b/server.js
index 958597b..6bf351a 100644
--- a/server.js
+++ b/server.js
@@ -32,26 +32,41 @@ function letterBody(vendor, rows) {
   return `Hello,\n\nDesigner Wallcoverings is preparing to feature the following ${vendor} patterns in our showroom and online catalog, and we'd like to confirm current trade (wholesale) pricing and any applicable dealer discount so we can list them correctly.\n\nCould you please provide current per-unit trade pricing (and unit of measure — per roll / per yard / per panel) for the following items:\n\n${lines}\n\nA current price list covering these SKUs works perfectly if easier on your end. Thank you — we appreciate it and look forward to showcasing the line.\n\nBest regards,\nDesigner Wallcoverings\ninfo@designerwallcoverings.com`;
 }
 
-// per-vendor summary of gaps
+// per-vendor summary of gaps (+ draft flag)
 app.get('/api/vendors', async (req, res) => {
   try {
     const q = await pool.query(`
       SELECT ps.vendor, count(*)::int AS gap_count,
              max(vc.email) AS email,
-             max(ps.letter_status) AS letter_status
+             max(ps.letter_status) AS letter_status,
+             bool_or(vc.draft_body IS NOT NULL AND vc.draft_body<>'') AS has_draft
       FROM price_sourcing ps LEFT JOIN vendor_contacts vc ON vc.vendor=ps.vendor
       WHERE ps.crawl_status <> 'found' AND COALESCE(ps.has_price,false)=false AND COALESCE(ps.letter_status,'none') <> 'skip-own-brand'
-      GROUP BY ps.vendor ORDER BY count(*) DESC`);
+      GROUP BY ps.vendor ORDER BY (max(ps.letter_status)='sent') ASC, count(*) DESC`);
     res.json({ vendors: q.rows });
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
-// draft letter + SKU list for a vendor
+// draft letter + SKU list for a vendor — returns SAVED draft if one exists, else generates
 app.get('/api/letter/:vendor', async (req, res) => {
   try {
     const v = req.params.vendor;
     const rows = (await pool.query(`SELECT dw_sku, mfr_sku, pattern, color FROM price_sourcing WHERE vendor=$1 AND crawl_status<>'found' AND COALESCE(has_price,false)=false AND COALESCE(letter_status,'none')<>'skip-own-brand' ORDER BY pattern, mfr_sku`, [v])).rows;
-    const email = (await pool.query(`SELECT email FROM vendor_contacts WHERE vendor=$1`, [v])).rows[0]?.email || '';
-    res.json({ vendor: v, count: rows.length, email, subject: `Trade pricing request — ${v} (${rows.length} patterns)`, body: letterBody(v, rows), skus: rows.slice(0, 500) });
+    const vc = (await pool.query(`SELECT email, draft_subject, draft_body, draft_saved_at FROM vendor_contacts WHERE vendor=$1`, [v])).rows[0] || {};
+    const hasDraft = !!(vc.draft_body && vc.draft_body.trim());
+    res.json({ vendor: v, count: rows.length, email: vc.email || '',
+      subject: hasDraft ? vc.draft_subject : `Trade pricing request — ${v} (${rows.length} patterns)`,
+      body: hasDraft ? vc.draft_body : letterBody(v, rows),
+      hasDraft, draftSavedAt: vc.draft_saved_at || null, skus: rows.slice(0, 500) });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+// save a draft (persist edited letter without sending) → marks vendor 'drafted'
+app.post('/api/draft/:vendor', async (req, res) => {
+  try {
+    const { subject, body, email } = req.body || {};
+    await pool.query(`INSERT INTO vendor_contacts(vendor,email,draft_subject,draft_body,draft_saved_at) VALUES($1,$2,$3,$4,now())
+      ON CONFLICT(vendor) DO UPDATE SET draft_subject=$3, draft_body=$4, draft_saved_at=now(), email=COALESCE(NULLIF($2,''),vendor_contacts.email)`, [req.params.vendor, (email || '').trim(), subject || '', body || '']);
+    await pool.query(`UPDATE price_sourcing SET letter_status='drafted', updated_at=now() WHERE vendor=$1 AND COALESCE(letter_status,'none') NOT IN ('sent','skip-own-brand')`, [req.params.vendor]);
+    res.json({ ok: true });
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 // save/update recipient email

← 075fd33 Add state-sync: detect showroom SKUs that got a roll/price →  ·  back to Vendor Price Requests  ·  VPR: per-vendor fillable Google Sheet — make-sheet.js (CSV→S cc4422b →