← 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
M public/index.htmlM server.js
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 →