[object Object]

← back to Gracie Internal

gracie-internal: add Memo/Stock/Price chip actions (shared vendor-requests module)

69f55eb593ace994bdf1d3f8088b8b34545416cf · 2026-07-15 15:01:21 -0700 · Steve Abrams

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

Files touched

Diff

commit 69f55eb593ace994bdf1d3f8088b8b34545416cf
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 15 15:01:21 2026 -0700

    gracie-internal: add Memo/Stock/Price chip actions (shared vendor-requests module)
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 data/inquiries.jsonl |  1 +
 public/index.html    | 40 ++++++++++++++++++++++++++++++++++++++++
 server.js            | 12 ++++++++++++
 3 files changed, 53 insertions(+)

diff --git a/data/inquiries.jsonl b/data/inquiries.jsonl
new file mode 100644
index 0000000..2eda301
--- /dev/null
+++ b/data/inquiries.jsonl
@@ -0,0 +1 @@
+{"at":"2026-07-15T22:01:13.987Z","req_no":"REQ-260715-7","type":"memo","status":"open","sku":"TEST","qty":"","note":""}
diff --git a/public/index.html b/public/index.html
index 0a96f7b..567d1b9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -74,6 +74,18 @@
   body.hide-tags .card .tags { display:none; }
   body.hide-when .card .when { display:none; }
   body.hide-ptn .card .ptn { display:none; }
+  /* purchasing actions on each card: Memo / Stock / Price → /api/request */
+  .card .acts { display:flex; gap:4px; padding:0 10px 10px; }
+  .card .acts .act { flex:1; font:inherit; font-size:10.5px; color:var(--mut); background:transparent; border:1px solid var(--stroke); border-radius:6px; padding:5px 2px; cursor:pointer; }
+  .card .acts .act:hover { border-color:var(--accent); color:var(--accent); }
+  .card .acts .act:disabled { opacity:.5; cursor:default; }
+  body.imgonly .card .acts { display:none; }
+  #toast { position:fixed; left:50%; bottom:20px; transform:translateX(-50%) translateY(20px); max-width:min(560px,92vw);
+    background:#fff; color:var(--ink); border:1px solid var(--stroke); border-radius:9px; padding:11px 17px; font-size:13px;
+    box-shadow:0 10px 34px rgba(28,26,23,.18); opacity:0; pointer-events:none; transition:.25s; z-index:60; }
+  #toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
+  #toast.ok { border-color:var(--accent); }
+  #toast.err { border-color:#c0392b; }
   /* image-only mode = collapse the entire card body → pure texture wall */
   body.imgonly .card .body { display:none; }
   body.imgonly .grid { gap:6px; }
@@ -333,6 +345,9 @@ function render() {
   const frag = document.createDocumentFragment();
   for (const p of rows) {
     const card = document.createElement('div'); card.className = 'card';
+    card.dataset.sku = p.dw_sku || '';
+    card.dataset.mfr = p.mfr_sku || '';
+    card.dataset.title = p.pattern_name || p.mfr_sku || '';
     const img = p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}?w=500&q=70" alt="${esc(p.pattern_name)}">` : '';
     const flags = [];
     if (p.settlement_flag && p.settlement_flag !== 'OK') flags.push(`<span class="flag gate">gate: ${esc(p.settlement_flag)}</span>`);
@@ -347,12 +362,37 @@ function render() {
         <div class="flags">${flags.join('')}</div>
         <div class="tags">${tags}</div>
         <div class="when" title="${esc(p.created_at)}">🕓 ${fmtDate(p.created_at)}</div>
+      </div>
+      <div class="acts">
+        <button class="act" data-act="memo"  title="Log a memo-sample request">Memo</button>
+        <button class="act" data-act="stock" title="Email the vendor a stock check">Stock</button>
+        <button class="act" data-act="price" title="Email the vendor for current price">Price</button>
       </div>`;
     frag.appendChild(card);
   }
   grid.appendChild(frag);
 }
 
+/* ── Purchasing actions on each card: Memo / Stock / Price → /api/request ── */
+grid.addEventListener('click', async (e) => {
+  const btn = e.target.closest('.act'); if (!btn) return;
+  const card = btn.closest('.card'); const type = btn.dataset.act;
+  const payload = { type, dw_sku: card.dataset.sku, mfr_sku: card.dataset.mfr, title: card.dataset.title };
+  const old = btn.textContent; btn.disabled = true; btn.textContent = '…';
+  try {
+    const r = await fetch(location.origin + '/api/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).then((r) => r.json());
+    toast(r.ok ? (r.message || ('Logged ' + (r.req_no || ''))) : (r.error || 'request failed'), !!r.ok);
+  } catch (err) { toast('request failed: ' + err.message, false); }
+  btn.disabled = false; btn.textContent = old;
+});
+let toastT = null;
+function toast(msg, ok) {
+  let t = document.getElementById('toast');
+  if (!t) { t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); }
+  t.textContent = msg; t.className = 'show ' + (ok ? 'ok' : 'err');
+  clearTimeout(toastT); toastT = setTimeout(() => t.className = '', 6000);
+}
+
 /* ── top-bar controls (search + sort + density + image-only, all persisted) ── */
 document.getElementById('sort').onchange = (e) => { localStorage.gracieSort = e.target.value; render(); };
 document.getElementById('density').oninput = (e) => { document.documentElement.style.setProperty('--cardmin', e.target.value + 'px'); localStorage.gracieDensity = e.target.value; };
diff --git a/server.js b/server.js
index 9ea6cd8..c6c2a7e 100644
--- a/server.js
+++ b/server.js
@@ -191,6 +191,18 @@ app.get('/api/facets', (_req, res) => res.json(facets()));
 
 app.use(express.static(path.join(__dirname, 'public')));
 
+// ── Internal purchasing requests: Request Memo / Check Stock / Get Price ──────────
+// Shared module every internal vendor viewer uses (astek reference). Logs a REQ to
+// dw_unified.vendor_requests; stock/price auto-email the vendor when an email is on
+// file. Behind Basic Auth (the click is the approval). Gracie is a quote-only line
+// with no vendor email on file, so requests log + prompt to call.
+app.use(express.json());
+require('./lib/vendor-requests').mountVendorRequests(app, {
+  vendorCode: 'gracie',
+  getVendor: () => ({ name: 'Gracie' }),
+  dataDir: __dirname + '/data',
+});
+
 load().then(() => {
   app.listen(PORT, () => console.log(`Gracie internal viewer → http://127.0.0.1:${PORT} (source=${USE_JSONL ? 'jsonl' : 'pg'})`));
   // hot-refresh every 10 min so new scrapes/enrichment appear without restart —

← 5866b3a auto-save: 2026-07-15T13:06:41 (1 files) — lib/  ·  back to Gracie Internal  ·  (newest)