← back to Mfr Review Viewer Corruption
public/index.html
898 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MFR Review — broken DW# == Mfr SKU</title>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
<link rel="stylesheet" href="/kv-list.css"><!-- kv-list -->
<style>
:root {
--bg:#0e1114; --panel:#171b20; --card:#1c2127; --line:#2a3038;
--ink:#e7ebef; --muted:#8b95a1; --teal:#14b8a6; --red:#ef4444;
--amber:#f59e0b; --green:#22c55e; --cols:4;
}
* { box-sizing:border-box; }
body { margin:0; font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
background:var(--bg); color:var(--ink); }
a { color:var(--teal); }
.layout { display:grid; grid-template-columns:280px 1fr; min-height:100vh; }
/* ---- Left panel ---- */
aside { background:var(--panel); border-right:1px solid var(--line); padding:14px;
position:sticky; top:0; align-self:start; height:100vh; overflow:auto; }
aside h1 { font-size:15px; margin:0 0 2px; }
aside .sub { color:var(--muted); font-size:12px; margin:0 0 12px; }
.search { width:100%; padding:8px 10px; background:var(--card); border:1px solid var(--line);
border-radius:8px; color:var(--ink); }
.facet { margin-top:14px; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
.facet > summary { cursor:pointer; padding:8px 10px; background:var(--card); font-weight:600;
list-style:none; display:flex; justify-content:space-between; }
.facet > summary::-webkit-details-marker { display:none; }
.facet .vals { max-height:240px; overflow:auto; }
.fv { display:flex; justify-content:space-between; gap:6px; padding:5px 10px; cursor:pointer;
border-top:1px solid var(--line); font-size:13px; }
.fv:hover { background:var(--card); }
.fv.active { background:rgba(20,184,166,.14); color:var(--teal); }
.fv .c { color:var(--muted); font-variant-numeric:tabular-nums; }
.fv.active .c { color:var(--teal); }
.clearall { margin-top:10px; font-size:12px; color:var(--muted); cursor:pointer; }
.toggles { margin-top:14px; font-size:12px; color:var(--muted); }
.toggles label { display:block; padding:3px 0; cursor:pointer; }
/* ---- Main ---- */
main { padding:14px 16px 120px; }
.bar { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-bottom:12px; }
.bar select, .bar button { padding:7px 10px; background:var(--card); color:var(--ink);
border:1px solid var(--line); border-radius:8px; }
.bar button { cursor:pointer; }
.bar .range { display:flex; align-items:center; gap:6px; color:var(--muted); font-size:12px; }
.pill { padding:3px 8px; border-radius:999px; font-size:11px; border:1px solid var(--line); }
.count { color:var(--muted); font-size:13px; }
.filters-active { color:var(--teal); font-size:12px; }
.grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:10px; }
.card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:10px 11px;
display:flex; flex-direction:column; gap:6px; position:relative; }
.card.sel { border-color:var(--teal); box-shadow:0 0 0 1px var(--teal) inset; }
.card .top { display:flex; align-items:flex-start; gap:8px; }
.card .dw { font-weight:700; font-size:14px; word-break:break-all; }
.card .chk { transform:scale(1.15); margin-top:2px; cursor:pointer; }
/* ---- KV List mode (one row per record) — the default record renderer ---- */
/* Feed kv-list.css the host theme vars so it inherits this dark palette. */
:root { --kv-ink:var(--ink); --kv-mut:var(--muted); --kv-line:var(--line);
--kv-hover:var(--card); --kv-acc:var(--teal); }
#list { display:flex; flex-direction:column; }
.lrow { border-bottom:1px solid var(--line); }
.lrow.sel .lrow-head { background:rgba(20,184,166,.08); }
.lrow-head { display:flex; align-items:baseline; gap:12px; flex-wrap:wrap; padding:9px 14px; }
.lrow-head:hover { background:var(--card); }
/* fixed identity block, left */
.lident { display:flex; align-items:baseline; gap:8px; flex-wrap:wrap; min-width:300px; flex:0 0 auto; }
.lident .chk { transform:scale(1.15); align-self:center; cursor:pointer; }
.lident .dw { font-weight:700; font-size:14px; color:var(--ink); word-break:break-all; }
.lident .mfr-transition { font-family:ui-monospace,monospace; font-size:12px; white-space:nowrap; }
.lident .mfr-transition .from { color:var(--red); text-decoration:line-through; }
.lident .mfr-transition .arrow { color:var(--muted); margin:0 4px; }
.lident .mfr-transition .to { color:var(--teal); font-weight:700; }
.lident .status-badge { font-size:10px; padding:1px 6px; border-radius:8px; background:#123024; color:#7fe0b0; font-weight:600; }
/* flowing field chips, right (uppercase micro-label + value) */
.lmeta { display:grid; grid-template-columns:repeat(auto-fill,minmax(var(--lmeta-min,150px),1fr)); gap:5px 14px;
flex:1 1 320px; min-width:0; align-content:start; }
.lm { font-size:12px; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lmk { color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:.5px; margin-right:5px; }
.lm .badmfr-chip { color:var(--red); text-decoration:line-through; }
.lm .real-chip { color:var(--teal); font-weight:600; }
.lm .guess-in { display:inline-block; width:auto; min-width:120px; }
.lrow .cand-toggle { padding:0 14px 8px; }
.lrow .cand-panel { padding:0 14px 10px; }
@media (max-width:900px){ .lident{ min-width:100%; } }
.field { display:flex; gap:6px; font-size:12px; }
.field .k { color:var(--muted); min-width:52px; }
.badmfr { color:var(--red); text-decoration:line-through; font-variant-numeric:tabular-nums; }
.vendor { color:var(--ink); }
.pattern { color:var(--muted); }
.sweep-vendor-btn { font-size:10px; padding:1px 6px; border:1px solid var(--line); background:#12161b;
color:var(--teal); border-radius:5px; cursor:pointer; white-space:nowrap; }
.sweep-vendor-btn:hover { border-color:var(--teal); }
.sweep-overlay { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; z-index:50;
align-items:center; justify-content:center; }
.sweep-overlay.on { display:flex; }
.sweep-modal { background:var(--card); border:1px solid var(--line); border-radius:12px; width:min(560px,92vw);
max-height:88vh; overflow:auto; padding:18px 20px; }
.sweep-modal h3 { margin:0 0 6px; }
.sweep-modal .counts { display:grid; grid-template-columns:1fr auto; gap:2px 14px; font-size:13px; margin:10px 0; }
.sweep-modal .counts .v { text-align:right; font-variant-numeric:tabular-nums; font-weight:700; }
.sweep-modal .actions { display:flex; gap:10px; margin-top:14px; flex-wrap:wrap; }
.btn-primary { background:var(--teal); color:#04120f; border:none; padding:9px 14px; border-radius:8px;
font-weight:700; cursor:pointer; }
.btn-danger { background:#1a1113; color:var(--red); border:1px solid var(--red); padding:9px 14px;
border-radius:8px; font-weight:700; cursor:pointer; }
.btn-ghost { background:transparent; color:var(--muted); border:1px solid var(--line); padding:9px 14px;
border-radius:8px; cursor:pointer; }
.sweep-modal .confirm-in { width:100%; padding:7px 9px; margin-top:6px; background:#12161b;
border:1px solid var(--red); border-radius:6px; color:var(--red); font-family:ui-monospace,monospace; }
.sweep-modal .warn { color:var(--red); font-size:12px; margin-top:8px; }
.sweep-modal .sample-skus { font-size:11px; color:var(--muted); font-family:ui-monospace,monospace;
max-height:120px; overflow:auto; margin-top:8px; border-top:1px solid var(--line); padding-top:8px; }
.guess-in { width:100%; padding:5px 7px; background:#12161b; border:1px solid var(--line);
border-radius:6px; color:var(--teal); font-weight:600; font-family:ui-monospace,monospace; }
.badges { display:flex; flex-wrap:wrap; gap:5px; }
.b { font-size:10px; padding:2px 6px; border-radius:6px; border:1px solid var(--line); }
.b.src { color:#a5b4fc; }
.b.conf-high { color:var(--green); border-color:var(--green); }
.b.conf-needs-review { color:var(--amber); border-color:var(--amber); }
.b.conf-exclude { color:var(--muted); }
.b.conf-na { color:var(--muted); }
.b.tier-TIER-2 { color:var(--green); }
.b.tier-TIER-3 { color:var(--amber); }
.b.tier-TIER-4 { color:var(--red); border-color:var(--red); }
.b.tier-LIKELY-LEGIT { color:var(--muted); }
.when { color:var(--muted); font-size:11px; }
/* ---- Candidates drill-down ---- */
.cand-toggle { align-self:flex-start; margin-top:2px; font-size:11px; color:var(--teal);
background:none; border:none; padding:2px 0; cursor:pointer; }
.cand-toggle:hover { text-decoration:underline; }
.cand-panel { margin-top:4px; border-top:1px dashed var(--line); padding-top:6px;
display:flex; flex-direction:column; gap:8px; }
.cand-group h4 { margin:0 0 4px; font-size:11px; color:var(--muted); text-transform:uppercase;
letter-spacing:.04em; }
.cand-row { display:flex; gap:6px; align-items:flex-start; padding:4px 5px; font-size:11px;
border:1px solid var(--line); border-radius:6px; background:#12161b; cursor:pointer; }
.cand-row:hover { border-color:var(--teal); }
.cand-row.picked { border-color:var(--teal); box-shadow:0 0 0 1px var(--teal) inset; }
.cand-row input[type=radio] { margin-top:2px; }
.cand-body { display:flex; flex-direction:column; gap:2px; min-width:0; }
.cand-line { display:flex; flex-wrap:wrap; gap:6px; }
.cand-rid { color:#a5b4fc; font-family:ui-monospace,monospace; }
.cand-note { color:var(--green); font-weight:700; font-family:ui-monospace,monospace;
background:rgba(34,197,94,.12); padding:1px 5px; border-radius:4px; }
.cand-struct { font-variant-numeric:tabular-nums; }
.cand-struct.isdw { color:var(--red); text-decoration:line-through; }
.cand-meta { color:var(--muted); }
.cand-realmfr { color:var(--teal); font-family:ui-monospace,monospace; font-weight:600; }
.cand-spin { color:var(--muted); font-size:11px; padding:4px 0; }
.cand-err { color:var(--amber); font-size:11px; }
/* ---- Delete-marking (duplicate-master pruning) ---- */
.cand-group-head { display:flex; align-items:center; justify-content:space-between; gap:6px;
margin:0 0 4px; }
.cand-group-head h4 { margin:0; }
.keep-del-btn { font-size:10px; padding:2px 7px; border-radius:6px; cursor:pointer;
background:#2a1417; color:#fca5a5; border:1px solid var(--red); white-space:nowrap; }
.keep-del-btn:hover { background:#3a1a1f; }
.keep-del-btn:disabled { opacity:.4; cursor:not-allowed; }
.remove-sug-btn { font-size:10px; padding:2px 8px; border-radius:6px; cursor:pointer; font-weight:700;
background:#3a1a1f; color:#ff9aa6; border:1px solid #6b2b33; margin-right:6px; }
.remove-sug-btn:hover { background:#5a2028; }
.mfr-suggest { margin:4px 0 6px; padding:6px 9px; background:rgba(34,197,94,.10); border:1px solid var(--green);
border-radius:7px; font-size:12px; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.mfr-suggest-code { color:var(--green); font-weight:700; font-family:ui-monospace,monospace; font-size:13px; }
.approve-mfr-btn { margin-left:auto; font-size:11px; padding:3px 10px; border-radius:6px; cursor:pointer;
background:var(--green); color:#04120f; border:none; font-weight:700; }
.approve-mfr-btn:hover { filter:brightness(1.1); }
.cand-row.foreign { opacity:.5; background:#141414; cursor:default; }
.cand-row.foreign:hover { border-color:var(--line); }
.foreign-badge { font-size:10px; padding:1px 6px; border-radius:6px; background:#2a2320; color:#d9b48a; font-weight:600; }
.del-wrap.del-disabled { color:var(--muted); opacity:.4; align-self:center; }
.cand-row .del-wrap { display:flex; align-items:center; gap:3px; margin-left:auto; }
.cand-row .del-chk { transform:scale(1.05); cursor:pointer; accent-color:var(--red); }
.cand-row .del-lbl { font-size:9px; color:var(--red); text-transform:uppercase; letter-spacing:.03em; }
.cand-row.marked-del { border-color:var(--red); background:#1a1113;
box-shadow:0 0 0 1px var(--red) inset; }
.cand-row.marked-del .cand-rid,
.cand-row.marked-del .cand-struct,
.cand-row.marked-del .cand-meta,
.cand-row.marked-del .cand-realmfr { text-decoration:line-through; opacity:.65; }
.del-tag { font-size:9px; padding:1px 5px; border-radius:4px; background:var(--red); color:#2a0a0a;
font-weight:700; text-transform:uppercase; letter-spacing:.03em; }
.prune-summary { font-size:11px; color:var(--muted); margin-top:2px; }
.prune-summary .n-del { color:var(--red); font-weight:700; }
.prune-summary .n-keep { color:var(--teal); font-weight:700; }
.samp-badge { display:inline-block; font-size:10px; padding:1px 6px; border-radius:8px; margin-right:5px; font-weight:600; }
.samp-yes { background:#123024; color:#7fe0b0; }
.samp-no { background:#2a2320; color:#d9b48a; }
.samp-sug { background:#3a1a1f; color:#ff9aa6; border:1px solid #6b2b33; }
/* ---- Sticky action bar ---- */
.action { position:fixed; left:280px; right:0; bottom:0; background:var(--panel);
border-top:1px solid var(--line); padding:12px 16px; display:flex; align-items:center;
gap:14px; z-index:20; }
.action .selcount { font-weight:700; }
.stage { padding:9px 16px; background:var(--teal); color:#04211d; border:none; border-radius:8px;
font-weight:700; cursor:pointer; }
.stage:disabled { opacity:.4; cursor:not-allowed; }
.msg { color:var(--muted); font-size:12px; }
.hidden { display:none !important; }
</style>
</head>
<!-- data-nav-sidebar="off": keep the domain <aside> (search + facets + field-toggles +
⚙ Sweep) OPEN on load. nav-agent still mounts its OWN additive panel + grid chrome
on #list — this is the true 2-into-1 (additive), not a collapse of the domain toolset. -->
<body data-nav-sidebar="off">
<div class="layout">
<aside>
<h1>MFR Review</h1>
<p class="sub">Broken "DW# == Mfr SKU" corruption · read-only</p>
<input class="search" id="search" placeholder="Search DW SKU / vendor / pattern / mfr…" autocomplete="off">
<div id="facets"></div>
<div class="clearall" id="clearFilters">✕ Clear all filters</div>
<div class="toggles" id="toggles">
<strong style="color:var(--ink)">Card fields</strong>
<label><input type="checkbox" data-f="badmfr" checked> Bad mfr</label>
<label><input type="checkbox" data-f="vendor" checked> Vendor</label>
<label><input type="checkbox" data-f="pattern" checked> Pattern</label>
<label><input type="checkbox" data-f="guess" checked> Best guess (editable)</label>
<label><input type="checkbox" data-f="badges" checked> Badges</label>
<label><input type="checkbox" data-f="when" checked> Created date</label>
</div>
</aside>
<main>
<div class="bar">
<label class="range">Sort
<select id="sort">
<option value="newest">Newest</option>
<option value="vendor">Vendor</option>
<option value="dwsku">DW SKU A→Z</option>
<option value="confidence">Confidence</option>
<option value="tier">Tier</option>
</select>
</label>
<label class="range">Density
<input type="range" id="density" min="2" max="8" step="1" value="4">
</label>
<button id="selAll">Select all in filter</button>
<button id="rebuild">↻ Rebuild queue</button>
<span class="count" id="count"></span>
<span class="filters-active" id="activeFilters"></span>
</div>
<div id="list" data-nav-grid></div>
</main>
</div>
<div class="action">
<span class="selcount"><span id="selN">0</span> selected · <span id="delN" style="color:var(--red)">0</span> to delete</span>
<button class="stage" id="stageBtn" disabled>Stage selected for approval</button>
<span class="msg" id="stageMsg"></span>
</div>
<!-- Per-vendor sweep modal (READ-ONLY analyze → Stage / Execute-now) -->
<div class="sweep-overlay" id="sweepOverlay">
<div class="sweep-modal" id="sweepModal">
<h3 id="sweepTitle">Sweep vendor</h3>
<div id="sweepBody" style="font-size:13px;color:var(--muted)">Running analyzer…</div>
<div class="actions" id="sweepActions" style="display:none">
<button class="btn-primary" id="sweepStageBtn" title="Draft ONE gated pending-approval memo (SAFE — nothing deleted)">Stage sweep for approval</button>
<button class="btn-danger" id="sweepExecBtn" title="DESTRUCTIVE — requires typing the APPROVE token">Execute now…</button>
<button class="btn-ghost" id="sweepCloseBtn">Close</button>
</div>
<div id="sweepExecPanel" style="display:none;margin-top:12px">
<div class="warn">⚠ DESTRUCTIVE: this DELETES the broken no-sample masters from FileMaker (WALLPAPER) and writes the real mfr onto the kept master. Each delete is snapshot-first + reversible + ledgered. To proceed, type the exact token:</div>
<input class="confirm-in" id="sweepConfirm" placeholder="APPROVE <vendor>" autocomplete="off">
<div class="actions">
<button class="btn-danger" id="sweepExecGo" disabled>Run executor --apply</button>
<button class="btn-ghost" id="sweepExecCancel">Cancel</button>
</div>
</div>
<div class="msg" id="sweepMsg" style="margin-top:10px"></div>
</div>
</div>
<script>
// --- creds-in-URL fetch guard (fleet pattern) ------------------------------
// When opened as http://user:pass@host/ the embedded creds poison document.baseURI,
// so a relative fetch('api/…') throws "Request cannot be constructed from a URL that
// includes credentials". Resolve relative URLs against a credential-free origin.
(function(){
const _fetch = window.fetch.bind(window);
const clean = new URL(location.pathname, location.origin).href; // no user:pass
window.fetch = (input, init)=>{
try {
if (typeof input === 'string' && !/^https?:|^\/\//i.test(input)) {
input = new URL(input, clean).href;
}
} catch(_) {}
return _fetch(input, init);
};
})();
const state = {
rows: [], facets: {}, filters: {}, search: '',
sel: new Set(), // dw_sku set
edits: {}, // dw_sku -> edited guess
cand: {}, // dw_sku -> {filemaker,unified,fmError} (cached per SKU)
candPick: {}, // dw_sku -> chosen candidate key (for radio state)
keepRid: {}, // dw_sku -> FmPro recordId marked "correct/keep"
delRids: {}, // dw_sku -> Set of FmPro recordIds marked for deletion
fields: JSON.parse(localStorage.getItem('mfr.fields')||'{}'),
sort: localStorage.getItem('mfr.sort')||'newest',
density: +(localStorage.getItem('mfr.density')||4),
showLegit: false,
};
const $ = (s)=>document.querySelector(s);
const list = $('#list');
function fmtWhen(iso){
if(!iso) return '🕓 no date';
try {
const d = new Date(iso);
return '🕓 ' + d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
} catch { return '🕓 no date'; }
}
async function load(){
const r = await fetch('api/queue'); const j = await r.json();
state.rows = j.rows; state.facets = j.facets;
renderFacets(); apply();
if(!window._suggPoll){ window._suggPoll=setInterval(pollSuggestions, 15000); pollSuggestions(); }
}
// Poll the queue for freshly-enriched suggested mfrs (the enrich-suggestions batch
// writes them into data/queue.jsonl progressively) and fill the on-screen cells in
// place — no disruptive re-render, never clobbering a manual edit.
async function pollSuggestions(){
try{
const r=await fetch('api/queue'); const j=await r.json();
const map=new Map((j.rows||[]).map(x=>[x.dw_sku,x]));
let covered=0;
for(const x of (j.rows||[])) if(x.suggested_real_mfr) covered++;
for(const row of state.rows){ const u=map.get(row.dw_sku); if(u){ row.suggested_real_mfr=u.suggested_real_mfr; row.suggested_src=u.suggested_src; row.suggested_keep_rid=u.suggested_keep_rid; } }
for(const el of document.querySelectorAll('.lrow')){
const sku=el.querySelector('.dw')?.textContent; if(!sku || state.edits[sku]!==undefined) continue;
const u=map.get(sku); const val=(u&&u.suggested_real_mfr)||''; if(!val) continue;
const to=el.querySelector('.mfr-transition .to'); if(to && to.textContent!==val){ to.textContent=val; to.style.color='var(--teal)'; }
const gi=el.querySelector('.guess-in'); if(gi && !gi.value) gi.value=val;
}
let banner=document.getElementById('suggStatus');
if(!banner){ banner=document.createElement('div'); banner.id='suggStatus'; banner.style.cssText='font-size:12px;color:var(--muted);margin:0 0 8px'; const m=document.querySelector('main'); if(m) m.insertBefore(banner,m.firstChild); }
const tot=state.rows.length||1, pct=Math.round(covered/tot*100);
banner.textContent = covered>=tot ? `✅ suggested real mfrs loaded (${covered}/${tot})` : `🔄 loading suggested real mfrs from FileMaker… ${covered}/${tot} (${pct}%)`;
if(covered>=tot && window._suggPoll){ clearInterval(window._suggPoll); window._suggPoll=null; }
}catch(e){}
}
function renderFacets(){
const wrap = $('#facets'); wrap.innerHTML='';
const order = ['vendor','tier','source','confidence'];
const label = {vendor:'Vendor',tier:'Tier',source:'Source',confidence:'Confidence'};
for(const key of order){
const vals = state.facets[key]||{};
const entries = Object.entries(vals).sort((a,b)=>b[1]-a[1]);
const det = document.createElement('details'); det.className='facet';
if(key==='vendor') det.open = true;
det.innerHTML = `<summary>${label[key]} <span class="c">${entries.length}</span></summary>`;
const box = document.createElement('div'); box.className='vals';
for(const [v,c] of entries){
const active = (state.filters[key]||new Set()).has(v);
const el = document.createElement('div');
el.className='fv'+(active?' active':'');
// Vendor rows get a "⚙ Sweep this vendor" control (opens the read-only plan modal).
if(key==='vendor' && v && v!=='(none)'){
el.innerHTML = `<span>${v}</span><span style="display:flex;gap:6px;align-items:center"><button class="sweep-vendor-btn" title="Run the READ-ONLY DW#==mfr sweep analyzer for this vendor">⚙ Sweep</button><span class="c">${c}</span></span>`;
el.querySelector('.sweep-vendor-btn').onclick = (ev)=>{ ev.stopPropagation(); openVendorSweep(v); };
} else {
el.innerHTML = `<span>${v}</span><span class="c">${c}</span>`;
}
el.onclick = ()=>toggleFilter(key,v);
box.appendChild(el);
}
det.appendChild(box); wrap.appendChild(det);
}
}
function toggleFilter(key,v){
state.filters[key] = state.filters[key]||new Set();
const s = state.filters[key];
s.has(v) ? s.delete(v) : s.add(v);
if(!s.size) delete state.filters[key];
renderFacets(); apply();
}
function filteredRows(){
const q = state.search.trim().toLowerCase();
return state.rows.filter(r=>{
// Default hides likely-legit (Schumacher) unless a tier filter explicitly includes it.
const tierFilter = state.filters.tier;
if(r.tier==='LIKELY-LEGIT' && !state.showLegit && !(tierFilter && tierFilter.has('LIKELY-LEGIT'))) return false;
for(const [key,set] of Object.entries(state.filters)){
const val = (r[key]===''||r[key]==null)?'(none)':r[key];
if(!set.has(val)) return false;
}
if(q){
const hay = `${r.dw_sku} ${r.vendor} ${r.pattern_name} ${r.bad_mfr} ${r.best_guess_real_mfr}`.toLowerCase();
if(!hay.includes(q)) return false;
}
return true;
});
}
function sortRows(rows){
const confRank = {high:0,'needs-review':1,'n/a':2,exclude:3};
const tierRank = {'TIER-2':0,'TIER-3':1,'TIER-4':2,'LIKELY-LEGIT':3};
const s = state.sort;
return [...rows].sort((a,b)=>{
if(s==='newest') return (b.created_at||'').localeCompare(a.created_at||'');
if(s==='vendor') return (a.vendor||'').localeCompare(b.vendor||'')||a.dw_sku.localeCompare(b.dw_sku);
if(s==='dwsku') return a.dw_sku.localeCompare(b.dw_sku);
if(s==='confidence') return (confRank[a.confidence]??9)-(confRank[b.confidence]??9);
if(s==='tier') return (tierRank[a.tier]??9)-(tierRank[b.tier]??9);
return 0;
});
}
function fieldOn(f){ return state.fields[f]!==false; }
function apply(){
// Density slider repurposed for the list: controls the field-chip min column width.
document.documentElement.style.setProperty('--cols', state.density);
const chipMin = Math.round(240 - state.density*13); // denser = narrower chip cols
document.documentElement.style.setProperty('--lmeta-min', chipMin+'px');
const rows = sortRows(filteredRows());
list.innerHTML='';
const frag = document.createDocumentFragment();
for(const r of rows){
const row = document.createElement('div');
row.className='lrow'+(state.sel.has(r.dw_sku)?' sel':'');
// prefer the FileMaker-derived real code (from the SKU's own master) over the
// weak dw_unified best-guess; either is overridden by a manual edit.
const suggested = r.suggested_real_mfr || r.best_guess_real_mfr || '';
const guessVal = (state.edits[r.dw_sku]!==undefined)?state.edits[r.dw_sku]:suggested;
const confClass = 'conf-'+String(r.confidence||'na').replace(/[^a-z]/g,'-');
const sugMfr = (guessVal||'').trim();
// ---- identity block (left): checkbox + DW SKU + bad→suggested mfr + status badge ----
let ident = `<span class="lident">
<input type="checkbox" class="chk" ${state.sel.has(r.dw_sku)?'checked':''} title="select for staging">
<span class="dw">${esc(r.dw_sku)}</span>
<span class="mfr-transition" title="bad DW#-placeholder mfr → suggested real mfr">
<span class="from">${esc(r.bad_mfr)}</span><span class="arrow">→</span><span class="to">${sugMfr?esc(sugMfr):'?'}</span>
</span>
<span class="status-badge">ACTIVE</span>
</span>`;
// ---- field chips (right): uppercase micro-label + value, honoring field toggles ----
let chips = '';
if(fieldOn('vendor')) chips += `<span class="lm" data-field="Vendor"><span class="lmk">Vendor</span>${esc(r.vendor||'—')}</span>`;
if(fieldOn('badmfr')) chips += `<span class="lm" data-field="Bad Mfr"><span class="lmk">Bad Mfr</span><span class="badmfr-chip">${esc(r.bad_mfr)}</span></span>`;
if(fieldOn('pattern')) chips += `<span class="lm" data-field="Pattern"><span class="lmk">Pattern</span>${esc(r.pattern_name||'—')}</span>`;
if(fieldOn('guess')) chips += `<span class="lm" data-field="Suggested Mfr"><span class="lmk">Suggested Mfr</span><input class="guess-in" value="${esc(guessVal)}" placeholder="enter real mfr…"></span>`;
if(fieldOn('badges')){
chips += `<span class="lm" data-field="Source"><span class="lmk">Source</span><span class="b src" style="border:none;padding:0">${esc(r.source)}</span></span>`;
chips += `<span class="lm" data-field="Tier"><span class="lmk">Tier</span><span class="b tier-${esc(r.tier)}" style="border:none;padding:0">${esc(r.tier)}</span></span>`;
chips += `<span class="lm" data-field="Confidence"><span class="lmk">Confidence</span><span class="b ${confClass}" style="border:none;padding:0">${esc(r.confidence)}</span></span>`;
if(r.peer) chips += `<span class="lm" data-field="Peer"><span class="lmk">Peer</span><span style="color:#f0abfc">✓</span></span>`;
}
if(fieldOn('when')) chips += `<span class="lm" data-field="Created" title="${esc(r.created_at||'')}"><span class="lmk">Created</span>${esc(fmtWhen(r.created_at).replace(/^🕓 /,''))}</span>`;
row.innerHTML = `<div class="lrow-head">${ident}<div class="lmeta">${chips}</div></div>
<button class="cand-toggle" data-sku="${esc(r.dw_sku)}">▸ candidates</button>
<div class="cand-panel hidden"></div>`;
const chk = row.querySelector('.chk');
chk.onchange = ()=>{ chk.checked?state.sel.add(r.dw_sku):state.sel.delete(r.dw_sku);
row.classList.toggle('sel',chk.checked); updateSel(); };
const gi = row.querySelector('.guess-in');
if(gi){
gi.oninput = ()=>{ state.edits[r.dw_sku]=gi.value;
// keep the identity-block "→ suggested" preview in sync as they type
const to = row.querySelector('.mfr-transition .to');
if(to) to.textContent = gi.value.trim() || '?'; };
gi.onclick = (e)=>e.stopPropagation(); // don't toggle candidates when editing
}
const tog = row.querySelector('.cand-toggle');
tog.onclick = ()=>toggleCandidates(r.dw_sku, tog, row.querySelector('.cand-panel'));
frag.appendChild(row);
}
list.appendChild(frag);
$('#count').textContent = `${rows.length} of ${state.rows.length} rows`;
const af = Object.entries(state.filters).map(([k,s])=>`${k}:${[...s].join(',')}`).join(' · ');
$('#activeFilters').textContent = af?('▸ '+af):'';
state._visible = rows;
updateSel();
}
function totalDeleteCount(){
let n = 0;
for(const s of Object.values(state.delRids)) n += (s ? s.size : 0);
return n;
}
function updateSel(){
const dels = totalDeleteCount();
$('#selN').textContent = state.sel.size;
$('#delN').textContent = dels;
$('#stageBtn').disabled = (state.sel.size===0 && dels===0);
}
// ---- Candidates drill-down (lazy per chip) ----
const esc = (s)=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'})[c]);
async function toggleCandidates(sku, tog, panel){
const open = !panel.classList.contains('hidden');
if(open){ panel.classList.add('hidden'); tog.textContent='▸ candidates'; return; }
panel.classList.remove('hidden'); tog.textContent='▾ candidates';
if(state.cand[sku]){ renderCandidates(sku, panel); return; }
panel.innerHTML = '<div class="cand-spin">Loading candidates…</div>';
try {
const r = await fetch('api/candidates?dw_sku='+encodeURIComponent(sku));
const j = await r.json();
state.cand[sku] = j;
applySuggestion(sku, j); // auto-apply Steve's keep/delete rule (once per SKU)
renderCandidates(sku, panel);
} catch(e){
panel.innerHTML = '<div class="cand-err">Failed to load candidates: '+esc(e.message)+'</div>';
}
}
// Auto-apply the backend's keep/delete suggestion ONCE per SKU. Steve's rule
// (2026-08-27): DELETE the broken no-sample "masters", KEEP the real sample-order
// records. Pre-marks the suggested deletes, remembers the keep, and pre-fills the
// real mfr — the reviewer can still override any of it before staging.
function applySuggestion(sku, j){
if(!state.applied) state.applied = {};
if(state.applied[sku]) return; // never clobber a prior manual choice
state.applied[sku] = true;
const sug = j && j.suggestion; if(!sug) return;
if(sug.deleteRids && sug.deleteRids.length){
const set = state.delRids[sku] || new Set();
for(const rid of sug.deleteRids) set.add(String(rid));
state.delRids[sku] = set;
}
if(sug.keepRid) state.keepRid[sku] = String(sug.keepRid);
if(sug.realMfr) chooseMfr(sku, sug.realMfr); // pre-fill the confirmed real mfr
}
// Write a chosen real mfr into this row's confirmed-mfr input + edits state, re-render card.
function chooseMfr(sku, value){
state.edits[sku] = value;
state.candPick[sku] = value; // remember pick (may be '' for none)
// Update the live input in the row WITHOUT a full re-render (keeps the panel open).
for(const rowEl of document.querySelectorAll('.lrow')){
const t = rowEl.querySelector('.cand-toggle');
if(t && t.dataset.sku===sku){
const gi = rowEl.querySelector('.guess-in');
if(gi) gi.value = value;
const to = rowEl.querySelector('.mfr-transition .to');
if(to) to.textContent = (value||'').trim() || '?';
break;
}
}
}
function candKey(c){ return 'fm:'+c.recordId; }
function renderCandidates(sku, panel){
const j = state.cand[sku] || {filemaker:[],unified:[]};
const fm = j.filemaker||[], un = j.unified||[];
const pick = state.candPick[sku];
const norm = (s)=>String(s||'').toUpperCase().replace(/[^A-Z0-9]/g,'');
const skuTail = (sku.match(/(\d+)\s*$/)||[])[1]||'';
let h = '';
// ---- FmPro group ----
const keepRid = state.keepRid[sku] || '';
const delSet = state.delRids[sku] || new Set();
const otherCount = fm.filter(c=>String(c.recordId)!==String(keepRid)).length;
h += `<div class="cand-group">`;
h += `<div class="cand-group-head"><h4>FmPro records (${fm.length})</h4>`;
const sugRids = fm.filter(c=>c.deleteSuggested).map(c=>String(c.recordId));
if(sugRids.length){
h += `<button class="remove-sug-btn" data-removesug="${esc(sku)}" title="One-click: mark the ${sugRids.length} auto-suggested broken no-sample master(s) for deletion — keeps every real sample record">🗑 Remove suggested (${sugRids.length})</button>`;
}
if(fm.length>1){
h += `<button class="keep-del-btn" data-keepdel="${esc(sku)}" ${keepRid?'':'disabled'} title="Mark every OTHER FmPro record of this SKU for deletion (keeps the one you chose)">🗑 Keep selected · delete the other ${otherCount}</button>`;
}
h += `</div>`;
const sug = j.suggestion||{};
if(sug.realMfr){
h += `<div class="mfr-suggest" title="The correct real mfr per FileMaker's OWN record for this SKU (not a foreign record that merely shares the number)">`+
`✅ Suggested real mfr: <span class="mfr-suggest-code">${esc(sug.realMfr)}</span>`+
(sug.keepRid?` <span class="cand-meta">(from FmPro #${esc(sug.keepRid)})</span>`:'')+
` <button class="approve-mfr-btn" data-approvemfr="${esc(sku)}" data-mfr="${esc(sug.realMfr)}">Approve into field ↑</button></div>`;
}
if(!fm.length){
h += `<div class="cand-meta">no FileMaker master found`+(j.fmError?` — <span class="cand-err">${esc(j.fmError)}</span>`:'')+`</div>`;
} else {
for(const c of fm){
// struck-red only when the STRUCTURED Mfr Pattern is just the DW# placeholder
// (equals the dw_sku's numeric tail / normalizes to the dw_sku) rather than a real code.
const structNorm = norm(c.mfrPattern);
const isDw = c.mfrPattern && (structNorm===norm(sku) || (skuTail && structNorm===skuTail) || /^\d+$/.test(c.mfrPattern) && c.mfrPattern===skuTail);
const real = c.noteMfr || c.mfrPattern || '';
const rid = String(c.recordId);
const isKeep = keepRid && rid===String(keepRid);
const isDel = delSet.has(rid);
const foreign = !c.skuMatch; // belongs to a DIFFERENT SKU (shares a number) — view-only
const picked = pick!=null && pick===real && real!=='';
h += `<label class="cand-row${picked?' picked':''}${isDel?' marked-del':''}${foreign?' foreign':''}">
<input type="radio" name="cand-${esc(sku)}" ${picked?'checked':''} ${foreign?'disabled':''} data-real="${esc(real)}" data-rid="${esc(rid)}" data-keepmfr="${esc(real)}" data-keepnote="${esc(c.mfrNote||'')}">
<span class="cand-body">
<span class="cand-line"><span class="cand-rid">#${esc(c.recordId)}</span>`+
(c.series||c.jsPattern?`<span class="cand-meta">${esc(c.series)}|${esc(c.jsPattern)}</span>`:'')+
(c.mfrPattern?`<span class="cand-struct${isDw?' isdw':''}" title="Mfr Pattern (structured)">${esc(c.mfrPattern)}</span>`:'')+
(foreign?`<span class="foreign-badge" title="This record belongs to a DIFFERENT SKU (${esc(c.series)}|${esc(c.jsPattern)}) that merely shares a number — NOT this SKU. Cannot be picked or deleted here.">⚠ other SKU</span>`:'')+
(isDel?`<span class="del-tag">will be staged for deletion</span>`:'')+
`</span>`+
(c.mfrNote && c.mfrNote!==c.mfrPattern?`<span class="cand-line"><span class="cand-note" title="mfr note (real code lives here)">${esc(c.mfrNote)}</span></span>`:'')+
(c.mfrNote===c.mfrPattern && /#|net|\$/i.test(c.mfrNote||'')?`<span class="cand-line"><span class="cand-note" title="real code">${esc(c.mfrNote)}</span></span>`:'')+
`<span class="cand-line cand-meta">`+
(c.name?`${esc(c.name)} · `:'')+(c.color?`${esc(c.color)} · `:'')+(c.vid?`vid ${esc(c.vid)}`:'')+
`</span>`+
(foreign?'':`<span class="cand-line">`+
(c.sampleOrdered
? `<span class="samp-badge samp-yes" title="a client sample WAS ordered against this record — KEEP">🧾 sampled${c.sampleDate?' '+esc(c.sampleDate):''}</span>`
: `<span class="samp-badge samp-no" title="no client sample ever ordered — a 'master' template (deletable when its mfr is just the DW# placeholder)">⛔ never sampled</span>`)+
(c.deleteSuggested?`<span class="samp-badge samp-sug" title="auto-suggested for deletion by the rule (no sample + placeholder mfr, a keeper survives)">auto-delete</span>`:'')+
`</span>`)+
(real&&!foreign?`<span class="cand-line">→ real mfr: <span class="cand-realmfr">${esc(real)}</span></span>`:'')+
`</span>`+
(foreign
? `<span class="del-wrap del-disabled" title="Foreign record — belongs to another SKU, cannot be deleted from here">—</span>`
: `<span class="del-wrap" title="Mark THIS master for deletion (keep the rest)">
<input type="checkbox" class="del-chk" data-delsku="${esc(sku)}" data-delrid="${esc(rid)}" ${isDel?'checked':''}>
<span class="del-lbl">🗑 delete<br>(keep rest)</span>
</span>`)+`</label>`;
}
// per-SKU running summary
h += `<div class="prune-summary" data-prune="${esc(sku)}">${pruneSummaryText(sku)}</div>`;
}
h += `</div>`;
// ---- Unified group ----
h += `<div class="cand-group"><h4>Unified DB rows (${un.length})</h4>`;
if(!un.length){
h += `<div class="cand-meta">no dw_unified rows matched</div>`;
} else {
for(let i=0;i<un.length;i++){
const u = un[i];
const real = u.manufacturer_sku || u.mfr_sku || '';
const picked = pick!=null && pick===real && real!=='';
h += `<label class="cand-row${picked?' picked':''}">
<input type="radio" name="cand-${esc(sku)}" ${picked?'checked':''} data-real="${esc(real)}">
<span class="cand-body">
<span class="cand-line"><span class="cand-rid">${esc(u.source_table)}</span>`+
(u.dw_sku?`<span class="cand-meta">${esc(u.dw_sku)}</span>`:'')+
(u.status?`<span class="cand-meta">${esc(u.status)}</span>`:'')+`</span>`+
`<span class="cand-line cand-meta">`+
(u.mfr_sku?`mfr_sku ${esc(u.mfr_sku)} · `:'')+
(u.manufacturer_sku?`mfg-metafield ${esc(u.manufacturer_sku)} · `:'')+
(u.vendor?`${esc(u.vendor)} · `:'')+(u.pattern_name?`${esc(u.pattern_name)}`:'')+
`</span>`+
(real?`<span class="cand-line">→ real mfr: <span class="cand-realmfr">${esc(real)}</span></span>`:'')+
`</span></label>`;
}
}
h += `</div>`;
// ---- none / keep manual ----
const noneChecked = (pick==='' );
h += `<label class="cand-row${noneChecked?' picked':''}">
<input type="radio" name="cand-${esc(sku)}" ${noneChecked?'checked':''} data-real="__none__">
<span class="cand-body"><span class="cand-line cand-meta">none — keep manual entry</span></span></label>`;
panel.innerHTML = h;
// wire radios (choose real mfr + remember which FmPro record is the KEEP)
for(const rb of panel.querySelectorAll('input[type=radio]')){
rb.onchange = ()=>{
const real = rb.dataset.real;
if(real==='__none__'){ state.candPick[sku]=''; /* leave edits as-is (manual) */ }
else chooseMfr(sku, real);
// If this radio is a FmPro record (has data-rid), it is the "correct/keep" record.
if(rb.dataset.rid){
state.keepRid[sku] = rb.dataset.rid;
// A record can't be both keep and delete — un-mark it from delete if needed.
if(state.delRids[sku]) state.delRids[sku].delete(rb.dataset.rid);
renderCandidates(sku, panel); // re-render so keep/del/button reflect the pick
return;
}
// update picked outline (non-FmPro radios: unified / none)
for(const row of panel.querySelectorAll('.cand-row')) row.classList.remove('picked');
rb.closest('.cand-row').classList.add('picked');
};
}
// wire the "Approve into field ↑" button (writes the suggested real mfr into the confirmed-mfr input)
const amBtn = panel.querySelector('.approve-mfr-btn');
if(amBtn){
amBtn.onclick = (ev)=>{ ev.preventDefault(); chooseMfr(sku, amBtn.dataset.mfr||''); amBtn.textContent='✓ approved'; };
}
// wire per-record "🗑 delete (keep rest)" checkboxes
for(const cb of panel.querySelectorAll('.del-chk')){
cb.onchange = ()=>{
const rid = cb.dataset.delrid;
if(!state.delRids[sku]) state.delRids[sku] = new Set();
if(cb.checked){
state.delRids[sku].add(rid);
// marking a record for delete clears it as the keep (mutually exclusive)
if(String(state.keepRid[sku])===String(rid)) state.keepRid[sku]='';
} else {
state.delRids[sku].delete(rid);
}
renderCandidates(sku, panel);
updateSel();
};
}
// wire the group-header "Keep selected · delete the other N" convenience button
const kdBtn = panel.querySelector('.keep-del-btn');
if(kdBtn){
kdBtn.onclick = (ev)=>{
ev.preventDefault();
const keep = state.keepRid[sku];
if(!keep) return; // button is disabled until a keep is chosen
const set = new Set();
for(const c of (state.cand[sku]?.filemaker||[])){
if(String(c.recordId)!==String(keep)) set.add(String(c.recordId));
}
state.delRids[sku] = set;
renderCandidates(sku, panel);
updateSel();
};
}
// wire the red "Remove suggested (N)" button — one-click marks the auto-suggested
// broken no-sample masters for deletion (keeps every real sample record).
const rsBtn = panel.querySelector('.remove-sug-btn');
if(rsBtn){
rsBtn.onclick = (ev)=>{
ev.preventDefault();
const sug = (state.cand[sku]?.suggestion?.deleteRids)||[];
if(!state.delRids[sku]) state.delRids[sku] = new Set();
for(const rid of sug) state.delRids[sku].add(String(rid));
// ensure a keep is set (the suggested keeper) so staging isn't blocked
const kr = state.cand[sku]?.suggestion?.keepRid;
if(!state.keepRid[sku] && kr) state.keepRid[sku]=String(kr);
renderCandidates(sku, panel);
updateSel();
};
}
}
// Per-SKU running "N to delete, 1 to keep" summary line.
function pruneSummaryText(sku){
const del = (state.delRids[sku]||new Set()).size;
const keep = state.keepRid[sku] ? 1 : 0;
if(!del && !keep) return 'no keep/delete marks yet — pick a keep, then mark or bulk-delete the rest';
return `<span class="n-del">${del} to delete</span>, <span class="n-keep">${keep} to keep</span>` +
(del && !keep ? ' — ⚠ mark a keep before staging' : '') +
' · staged for approval only, not deleted';
}
$('#search').oninput = (e)=>{ state.search=e.target.value; apply(); };
$('#clearFilters').onclick = ()=>{ state.filters={}; state.search=''; $('#search').value=''; renderFacets(); apply(); };
$('#sort').onchange = (e)=>{ state.sort=e.target.value; localStorage.setItem('mfr.sort',state.sort); apply(); };
$('#density').oninput = (e)=>{ state.density=+e.target.value; localStorage.setItem('mfr.density',state.density); apply(); };
$('#selAll').onclick = ()=>{ for(const r of (state._visible||[])) state.sel.add(r.dw_sku); apply(); };
$('#toggles').addEventListener('change',(e)=>{
if(e.target.dataset.f){ state.fields[e.target.dataset.f]=e.target.checked;
localStorage.setItem('mfr.fields',JSON.stringify(state.fields)); apply(); }
});
$('#rebuild').onclick = async ()=>{
$('#rebuild').textContent='Rebuilding…'; $('#rebuild').disabled=true;
try { const r = await fetch('api/rebuild',{method:'POST'}); const j = await r.json();
if(j.ok){ await load(); $('#stageMsg').textContent=`Rebuilt: ${j.total} rows.`; }
else $('#stageMsg').textContent='Rebuild failed: '+j.error;
} finally { $('#rebuild').textContent='↻ Rebuild queue'; $('#rebuild').disabled=false; }
};
$('#stageBtn').onclick = async ()=>{
const byId = Object.fromEntries(state.rows.map(r=>[r.dw_sku,r]));
const selected = [...state.sel].map(id=>{
const r = byId[id];
const confirmed = (state.edits[id]!==undefined)?state.edits[id]:(r.best_guess_real_mfr||'');
return { dw_sku:r.dw_sku, bad_mfr:r.bad_mfr, confirmed_real_mfr:confirmed, vendor:r.vendor, source:r.source };
});
// Build the per-SKU duplicate-master deletion proposals (keep + delete ids).
const deletions = [];
for(const [sku, set] of Object.entries(state.delRids)){
if(!set || !set.size) continue;
const keepRid = state.keepRid[sku] || '';
// pull keep's mfr/note from the cached FmPro candidates so the memo can cite them
let keepMfr='', keepNote='';
const fm = (state.cand[sku]?.filemaker)||[];
const keepRec = fm.find(c=>String(c.recordId)===String(keepRid));
if(keepRec){ keepMfr = keepRec.noteMfr || keepRec.mfrPattern || ''; keepNote = keepRec.mfrNote || ''; }
deletions.push({ dw_sku:sku, keepRecordId:keepRid, keepMfr, keepNote,
deleteRecordIds:[...set] });
}
$('#stageBtn').disabled=true; $('#stageMsg').textContent='Staging (snapshotting delete candidates)…';
const r = await fetch('api/stage',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({selected, deletions})});
const j = await r.json();
if(j.ok){
let msg = `✅ Staged for approval → ${j.memoName} (gated only — NOT applied, NOTHING deleted).`;
if(j.deletions) msg = `✅ Staged ${j.staged} repair(s) + ${j.deletions} deletion(s) across ${j.skuWithDeletes} SKU(s) → ${j.memoName}. Restore snapshots: ${j.snapshotsOk} saved${j.snapshotsFailed?`, ⚠ ${j.snapshotsFailed} FAILED`:''}. Gated only — NOTHING deleted.`;
$('#stageMsg').textContent = msg;
}
else $('#stageMsg').textContent = '❌ '+j.error;
$('#stageBtn').disabled=false;
};
// restore field toggles + sort + density from localStorage
for(const cb of document.querySelectorAll('#toggles input')){
if(state.fields[cb.dataset.f]===false) cb.checked=false;
}
$('#sort').value = state.sort;
$('#density').value = state.density;
// ===================== Per-vendor Sweep modal =====================
let _sweepState = { vendor:'', planFile:null, counts:{} };
function openVendorSweep(vendor){
_sweepState = { vendor, planFile:null, counts:{} };
$('#sweepTitle').textContent = 'Sweep vendor — '+vendor;
$('#sweepBody').innerHTML = 'Running READ-ONLY analyzer for <b>'+esc(vendor)+'</b>… (enumerates DW#==mfr placeholder SKUs, resolves FileMaker masters). This can take a minute.';
$('#sweepActions').style.display='none';
$('#sweepExecPanel').style.display='none';
$('#sweepMsg').textContent='';
$('#sweepOverlay').classList.add('on');
fetch('api/vendor-plan?vendor='+encodeURIComponent(vendor)).then(r=>r.json()).then(j=>{
if(!j.ok){ $('#sweepBody').innerHTML = '<span style="color:var(--red)">Analyzer error: '+esc(j.error||'unknown')+'</span>'; return; }
_sweepState.planFile = j.planFile; _sweepState.counts = j.counts||{};
const c = j.counts||{};
const row = (k,v)=>`<div>${k}</div><div class="v">${v==null?'—':v}</div>`;
let html = '<div class="counts">'+
row('DW#==mfr placeholder SKUs', c.skus_found_total)+
row('SKUs with deletes', c.skus_with_deletes)+
row('Records to delete', c.records_to_delete)+
row('Masters to fix (mfr write)', c.masters_to_fix)+
row('SKUs skipped by guard', c.skus_skipped_by_guard)+
row('SKUs with no FM match', c.skus_no_fm_match)+
'</div>';
if((c.records_to_delete||0) > 500) html += '<div class="warn">⚠ '+c.records_to_delete+' deletes exceeds the 500 blast cap — Execute-now will require a raised --max.</div>';
const samples = (j.plan||[]).slice(0,6).map(e=>`${esc(e.dw_sku)} keep=${esc(e.keepRecordId||'—')} mfr=${esc(e.realMfr||'—')} del=[${(e.deleteRecordIds||[]).map(esc).join(', ')}]`).join('<br>');
if(samples) html += '<div class="sample-skus">'+samples+'</div>';
$('#sweepBody').innerHTML = html;
$('#sweepActions').style.display = (c.records_to_delete||0) ? 'flex' : 'none';
if(!(c.records_to_delete||0)) $('#sweepBody').innerHTML += '<div style="margin-top:8px;color:var(--muted)">Nothing to sweep for this vendor.</div>';
}).catch(e=>{ $('#sweepBody').innerHTML = '<span style="color:var(--red)">'+esc(e.message)+'</span>'; });
}
$('#sweepCloseBtn').onclick = ()=> $('#sweepOverlay').classList.remove('on');
$('#sweepOverlay').onclick = (e)=>{ if(e.target.id==='sweepOverlay') $('#sweepOverlay').classList.remove('on'); };
// SAFE DEFAULT — Stage sweep for approval (writes the gated memo, no delete).
$('#sweepStageBtn').onclick = ()=>{
if(!_sweepState.planFile) return;
$('#sweepMsg').textContent='Staging gated memo (+ snapshots)…';
fetch('api/vendor-stage',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({planFile:_sweepState.planFile})}).then(r=>r.json()).then(j=>{
$('#sweepMsg').innerHTML = j.ok
? '<span style="color:var(--teal)">✓ Staged — memo: '+esc(j.memoName||'')+' ('+(j.deletions||0)+' deletes, '+(j.snapshotsOk||0)+' snapshots ok'+(j.snapshotsFailed?', '+j.snapshotsFailed+' FAILED':'')+'). APPROVE it in the pending-approval queue.</span>'
: '<span style="color:var(--red)">'+esc(j.error||'stage failed')+'</span>';
}).catch(e=>{ $('#sweepMsg').innerHTML='<span style="color:var(--red)">'+esc(e.message)+'</span>'; });
};
// GATED — Execute now (requires typed APPROVE token).
$('#sweepExecBtn').onclick = ()=>{ $('#sweepExecPanel').style.display='block'; $('#sweepConfirm').focus(); };
$('#sweepExecCancel').onclick = ()=>{ $('#sweepExecPanel').style.display='none'; $('#sweepConfirm').value=''; $('#sweepExecGo').disabled=true; };
$('#sweepConfirm').oninput = ()=>{ $('#sweepExecGo').disabled = ($('#sweepConfirm').value.trim() !== ('APPROVE '+_sweepState.vendor)); };
$('#sweepExecGo').onclick = ()=>{
const confirm = $('#sweepConfirm').value.trim();
if(confirm !== ('APPROVE '+_sweepState.vendor)) return;
const nDel = _sweepState.counts.records_to_delete||0;
const max = nDel > 500 ? nDel : 500;
$('#sweepExecGo').disabled=true; $('#sweepMsg').textContent='Running executor --apply (LIVE destructive)…';
fetch('api/vendor-execute',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({vendor:_sweepState.vendor, planFile:_sweepState.planFile, confirm, max})}).then(r=>r.json()).then(j=>{
if(j.ok){ $('#sweepMsg').innerHTML='<span style="color:var(--teal)">✓ Executed. Last log lines:</span><div class="sample-skus">'+(j.log||[]).map(esc).join('<br>')+'</div>'; }
else { $('#sweepMsg').innerHTML='<span style="color:var(--red)">'+esc(j.error||'execute failed')+'</span>'+(j.log?('<div class="sample-skus">'+(j.log||[]).map(esc).join('<br>')+'</div>'):''); $('#sweepExecGo').disabled=false; }
}).catch(e=>{ $('#sweepMsg').innerHTML='<span style="color:var(--red)">'+esc(e.message)+'</span>'; $('#sweepExecGo').disabled=false; });
};
load();
</script>
<!-- kv-list (reference component; the record renderer uses the same .kv-* row model inline) -->
<script src="/kv-list.js" defer></script><!-- kv-list -->
<!-- nav-agent: full grid-controls nav bar (field-toggle / sort / group-by / density / movable panel).
Mounts on #list; collapses the host <aside> on load (2-into-1) and drops a ☰ toggle to reveal it. -->
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>