← 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 &lt;vendor&gt;" 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[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>