[object Object]

← back to Dw Photo Capture

Check Sample In: incoming-queue panel — shows vendor-ordered+not-sent samples (recent 120d) with spec info, cached server-side + 8-min background refresh (FM find is a ~30s whole-file scan, so cache→instant 0.15s). Scan flags the matching incoming card. Images best-effort from active catalog

427db5106f8d93b265173e28e2cdd7e3088187d2 · 2026-07-07 16:59:21 -0700 · Steve Abrams

Files touched

Diff

commit 427db5106f8d93b265173e28e2cdd7e3088187d2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 7 16:59:21 2026 -0700

    Check Sample In: incoming-queue panel — shows vendor-ordered+not-sent samples (recent 120d) with spec info, cached server-side + 8-min background refresh (FM find is a ~30s whole-file scan, so cache→instant 0.15s). Scan flags the matching incoming card. Images best-effort from active catalog
---
 public/index.html | 28 +++++++++++++++++++++++++---
 server.js         | 51 ++++++++++++++++++++++++++++-----------------------
 2 files changed, 53 insertions(+), 26 deletions(-)

diff --git a/public/index.html b/public/index.html
index 67e1716..939fe32 100644
--- a/public/index.html
+++ b/public/index.html
@@ -150,6 +150,14 @@
   .fb-tile{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;background:#0f0e0b;border:1px dashed var(--line);border-radius:14px;padding:12px 6px;color:#b7ad98;font-size:13px;cursor:pointer}
   .fb-tile.set{border-style:solid;border-color:#2b7a3f;color:#fff}
   .fb-tile.need{border-style:solid;border-color:#b8902f;color:#fff;box-shadow:0 0 0 2px rgba(184,144,47,.35);animation:micpulse 1.1s infinite}
+  #fbIncoming:empty{display:none}
+  .inc-hd{font-size:12px;color:#b7ad98;margin-bottom:7px}.inc-hd b{color:#e7ddc7}
+  .inc-strip{display:flex;gap:8px;overflow-x:auto;padding-bottom:6px;margin-bottom:12px;-webkit-overflow-scrolling:touch}
+  .inc-card{flex:none;width:104px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#0f0e0b;cursor:pointer}
+  .inc-card.hit{border-color:#2bd06a;box-shadow:0 0 0 2px rgba(43,208,106,.4)}
+  .inc-thumb{width:104px;height:82px;background:#000 center/cover no-repeat;display:flex;align-items:center;justify-content:center;font-size:22px;color:#5a544a}
+  .inc-meta{padding:5px 6px}.inc-pat{font-size:11px;color:#e7ddc7;line-height:1.25;max-height:28px;overflow:hidden}
+  .inc-sub{font-size:10px;color:#9a917d;margin-top:2px}
   .fb-thumb{width:100%;aspect-ratio:1/1;border-radius:10px;background:#000 center/cover no-repeat;display:flex;align-items:center;justify-content:center;font-size:30px}
   #fbResult{margin-top:12px}
   .fb-hit{display:flex;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:12px;margin-top:8px}
@@ -406,6 +414,7 @@
     <button class="samp-x" id="fbClose">✕</button>
     <div class="samp-pat" style="margin-bottom:4px">Identify a sample</div>
     <div class="samp-meta" style="margin-bottom:12px">Photograph the BACK label, the FRONT pattern, or both — best ID uses both.</div>
+    <div id="fbIncoming"></div>
     <div class="fb-tiles">
       <button class="fb-tile" id="fbBackTile"><div class="fb-thumb" id="fbBackThumb">📄</div><span>Back · label</span></button>
       <button class="fb-tile" id="fbFrontTile"><div class="fb-thumb" id="fbFrontThumb">🎨</div><span>Front · pattern</span></button>
@@ -1467,11 +1476,24 @@ async function fbIdentify(){
     const via=r.source==='qr'?'🔗 QR':r.source==='code'?'📄 label code':'🎨 visual';
     $('#fbNote').innerHTML=`Source: ${via}${r.corroborated?' · ✓ front+back agree':''}`;
     $('#fbResult').innerHTML=`<div class="fb-hit">${p.image?`<img src="${p.image}">`:''}<div><b>${p.pattern||p.mfr_sku||p.dw_sku||'—'}</b><span class="fb-conf ${conf}">${r.confidence}</span><br><small>${[p.vendor,p.dw_sku,p.mfr_code].filter(Boolean).join(' · ')}</small></div></div>`+fbVisualHtml(r.visual);
-    if(!_fbLookup) checkSampleRequest(p.dw_sku||p.mfr_code, p.mfr_code);   // Check-Sample-In only → print popup; pure Look-Up SKU skips it
+    if(!_fbLookup){ matchIncoming(p); checkSampleRequest(p.dw_sku||p.mfr_code, p.mfr_code); }   // Check-Sample-In: flag the incoming match + print popup; pure Look-Up SKU skips both
   }catch(e){ $('#fbNote').textContent='Failed: '+e.message; }
   $('#fbGo').disabled=false;
 }
-function openFbModal(){ _fbBack=_fbFront=_fbQR=null; _fbLookup=false; $('#fbBackThumb').style.backgroundImage=''; $('#fbBackThumb').textContent='📄'; $('#fbFrontThumb').style.backgroundImage=''; $('#fbFrontThumb').textContent='🎨'; $('#fbBackTile').classList.remove('set','need'); $('#fbFrontTile').classList.remove('set','need'); $('#fbGo').disabled=true; $('#fbNote').textContent=''; $('#fbResult').innerHTML='';
+// INCOMING SAMPLES (Check Sample In): show the ordered-not-received queue with image+spec; on a scan, flag the match.
+let _incoming=[];
+async function loadIncoming(){ const box=$('#fbIncoming'); if(!box) return; box.innerHTML='<div class="inc-hd">Loading samples coming in…</div>';
+  let r; try{ r=await(await fetch('/api/incoming-samples')).json(); }catch(e){ box.innerHTML=''; return; }
+  _incoming=(r&&r.samples)||[];
+  if(!_incoming.length){ box.innerHTML=r&&r.ok?'<div class="inc-hd">No samples currently marked ordered-not-received.</div>':''; return; }
+  box.innerHTML='<div class="inc-hd"><b>📦 '+_incoming.length+' coming in</b> — ordered, not yet received. Scan an arrival to check it in.</div>'
+    +'<div class="inc-strip">'+_incoming.map((s,i)=>`<div class="inc-card" data-i="${i}" data-sku="${(s.dw_sku||'').toUpperCase()}" data-mfr="${(s.mfr_sku||s.mfr_pattern||'').toUpperCase().replace(/[^A-Z0-9]/g,'')}"><div class="inc-thumb"${s.image?` style="background-image:url('${s.image}')"`:''}>${s.image?'':'📦'}</div><div class="inc-meta"><div class="inc-pat">${(s.pattern||s.mfr_pattern||s.combo_sku||'—')}</div><div class="inc-sub">${[s.vendor,s.vendor_ordered].filter(Boolean).join(' · ')}</div></div></div>`).join('')+'</div>';
+  box.querySelectorAll('.inc-card').forEach(c=>c.addEventListener('click',()=>{ const s=_incoming[+c.dataset.i]; $('#fbNote').innerHTML=`📦 <b>${s.pattern||s.mfr_pattern||s.combo_sku}</b> — ${[s.vendor,s.vendor_ordered&&('ordered '+s.vendor_ordered),s.client&&('for '+s.client)].filter(Boolean).join(' · ')}`; }));
+}
+function matchIncoming(p){ const pk=(p.dw_sku||'').toUpperCase(), pm=(p.mfr_sku||p.mfr_code||'').toUpperCase().replace(/[^A-Z0-9]/g,''); let hit=false;
+  document.querySelectorAll('#fbIncoming .inc-card').forEach(c=>{ const m=(pk&&c.dataset.sku===pk)||(pm&&c.dataset.mfr&&(c.dataset.mfr===pm||c.dataset.mfr.startsWith(pm))); c.classList.toggle('hit',!!m); if(m){ hit=true; c.scrollIntoView({inline:'center',block:'nearest',behavior:'smooth'}); } });
+  if(hit) $('#fbNote').innerHTML+=' · <b style="color:#2bd06a">✓ this is an incoming sample</b>'; }
+function openFbModal(){ _fbBack=_fbFront=_fbQR=null; _fbLookup=false; $('#fbBackThumb').style.backgroundImage=''; $('#fbBackThumb').textContent='📄'; $('#fbFrontThumb').style.backgroundImage=''; $('#fbFrontThumb').textContent='🎨'; $('#fbBackTile').classList.remove('set','need'); $('#fbFrontTile').classList.remove('set','need'); $('#fbGo').disabled=true; $('#fbNote').textContent=''; $('#fbResult').innerHTML=''; $('#fbIncoming').innerHTML='';
   $('#fbModal').querySelector('.samp-pat').textContent='Identify a sample';
   $('#fbModal').querySelector('.samp-meta').textContent='Photograph the BACK label, the FRONT pattern, or both — best ID uses both.';
   $('#fbModal').hidden=false; document.body.style.overflow='hidden'; }
@@ -1757,7 +1779,7 @@ function routeHome(act){ $('#homeScreen').hidden=true;
   if(act==='add') openAddModal(null,{mode:'add',camera:true});
   else if(act==='update') openAddModal(null,{mode:'update',camera:true});
   else if(act==='lookup') openLookup();          // camera-first front→back → identify
-  else if(act==='check') openFbModal();
+  else if(act==='check'){ openFbModal(); loadIncoming(); }   // show the incoming (ordered-not-received) queue first
   // 'skip' → just show the catalog
 }
 document.querySelectorAll('#homeScreen .home-pill, #homeScreen .home-skip').forEach(b=>b.addEventListener('click',()=>routeHome(b.dataset.act)));
diff --git a/server.js b/server.js
index 08b8e88..f5f63e9 100644
--- a/server.js
+++ b/server.js
@@ -27,6 +27,31 @@ const FM_ENABLED = () => !!(FM && process.env.FM_CLARIS_EMAIL && process.env.FM_
 function todayMDY() { return new Date().toLocaleDateString('en-US', { timeZone: process.env.FM_TZ || 'America/Los_Angeles', year: 'numeric', month: '2-digit', day: '2-digit' }); }
 const VSEARCH_URL = process.env.VSEARCH_URL || 'http://127.0.0.1:9914';   // CLIP visual-search service
 
+// Incoming-samples cache: the FileMaker find on this layout takes ~30s (whole-file scan, can't index
+// via API), so refresh the queue in the BACKGROUND and serve it instantly. ≤8-min staleness is fine
+// for a "samples coming in" queue. Refreshed at boot + every 8 min + on a stale read.
+let _incomingCache = { at: 0, samples: [] }, _incomingLoading = false;
+async function refreshIncoming() {
+  if (_incomingLoading || !FM_ENABLED()) return;
+  _incomingLoading = true;
+  try {
+    const floor = new Date(Date.now() - 120 * 864e5).toLocaleDateString('en-US', { timeZone: process.env.FM_TZ || 'America/Los_Angeles', year: 'numeric', month: '2-digit', day: '2-digit' });
+    const r = await FM.fmFind(FM_DB, FM_LAYOUT, [{ 'Date Sample Request printed for vendor': '>=' + floor, 'Date WP Sample Sent': '=' }], { limit: 40, portal: [] });
+    const recs = (r.records || []);
+    _incomingCache = { at: Date.now(), samples: recs.map(rec => { const f = rec.fieldData;
+      const nk = nmfr(f['Mfr Pattern'] || f['combo sku'] || '');
+      const hit = nk ? CATALOG.find(x => x.mfr && nmfr(x.mfr) === nk) : null;
+      return { recordId: rec.recordId, mfr_pattern: f['Mfr Pattern'] || null, combo_sku: f['combo sku'] || null,
+        series: f['Series'] || null, vid: f['vid'] || null, client: f['company for client fileS'] || null,
+        vendor_ordered: f['Date Sample Request printed for vendor'] || null, client_ordered: f['today for client'] || null,
+        image: (hit && hit.image) || null, pattern: (hit && hit.title) || null,
+        dw_sku: (hit && hit.dw_sku) || null, mfr_sku: (hit && hit.mfr) || null }; }) };
+  } catch (e) { /* keep last good cache */ }
+  _incomingLoading = false;
+}
+setTimeout(refreshIncoming, 9000);                 // warm the cache shortly after boot (once CATALOG is loaded)
+setInterval(refreshIncoming, 8 * 60 * 1000);       // keep it fresh
+
 const ROOT = __dirname;
 const DATA = path.join(ROOT, 'data');
 const PHOTOS = path.join(ROOT, 'photos');
@@ -1331,29 +1356,9 @@ const appHandler = (req, res) => {
   // Enriched with each sample's image + spec from the unified catalog so we know what's coming in.
   if (u.pathname === '/api/incoming-samples' && req.method === 'GET') {
     if (!FM_ENABLED()) return send(res, 200, { ok: false, err: 'FileMaker not configured', samples: [] });
-    (async () => {
-      const query = [{ 'Date Sample Request printed for vendor': '*', 'Date WP Sample Sent': '=' }];
-      let r;
-      try { r = await FM.fmFind(FM_DB, FM_LAYOUT, query, { limit: 40, portal: [], sort: [{ fieldName: 'Date Sample Request printed for vendor', sortOrder: 'descend' }] }); }
-      catch (e) { return send(res, 200, { ok: false, err: e.message, samples: [] }); }
-      const recs = (r.records || []);
-      const base = recs.map(rec => { const f = rec.fieldData; return {
-        recordId: rec.recordId, mfr_pattern: f['Mfr Pattern'] || null, combo_sku: f['combo sku'] || null,
-        series: f['Series'] || null, vid: f['vid'] || null, client: f['company for client fileS'] || null,
-        vendor_ordered: f['Date Sample Request printed for vendor'] || null, client_ordered: f['today for client'] || null }; });
-      // enrich with image + spec from the catalog, capped concurrency (identifyUnified shells to psql)
-      const out = [];
-      for (let i = 0; i < base.length; i += 6) {
-        const chunk = await Promise.all(base.slice(i, i + 6).map(async s => {
-          const key = (s.combo_sku || s.mfr_pattern || '').toString().trim();
-          const id = key ? await identifyUnified(key).catch(() => null) : null;
-          return Object.assign(s, { image: (id && id.image) || null, pattern: (id && id.pattern) || null,
-            vendor: (id && id.vendor) || null, dw_sku: (id && id.internal_sku) || null, mfr_sku: (id && id.mfr_sku) || null }); }));
-        out.push(...chunk);
-      }
-      send(res, 200, { ok: true, count: out.length, samples: out });
-    })();
-    return;
+    if (Date.now() - _incomingCache.at > 8 * 60 * 1000) refreshIncoming();   // stale → refresh in background
+    return send(res, 200, { ok: true, count: _incomingCache.samples.length, samples: _incomingCache.samples,
+      cached_at: _incomingCache.at || null, loading: _incomingCache.at === 0 });
   }
 
   // Print-sticker "send": STAMP the sample as sent (Date WP Sample Sent = today — Steve's "field to

← 2d627ac Check Sample In → incoming queue: /api/incoming-samples find  ·  back to Dw Photo Capture  ·  Check Sample In: 'Received' button stamps Date WP Sample Sen 999db25 →