← back to Settlement Review

public/index.html

252 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Settlement Review</title>
<style>
:root{--bg:#f6f4ef;--card:#fff;--ink:#1d1c1a;--mut:#6d6a64;--line:#e2ded5;--acc:#1f3a34;--ok:#1e7a46;--bad:#b3261e;--unsure:#a86b00;--cols:5}
@media (prefers-color-scheme:dark){:root{--bg:#141413;--card:#1f1e1c;--ink:#ece9e2;--mut:#a09c94;--line:#34322e;--acc:#8fc7b8}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
nav{position:sticky;top:0;z-index:10;background:var(--acc);color:#fff;display:flex;flex-wrap:wrap;align-items:center;gap:4px;padding:8px 16px}
nav .brand{font-weight:700;margin-right:12px;letter-spacing:.02em}
nav a{color:#fff;text-decoration:none;padding:6px 10px;border-radius:6px;opacity:.85;white-space:nowrap}
nav a:hover{opacity:1;background:rgba(255,255,255,.12)}
nav a.on{opacity:1;background:rgba(255,255,255,.22);font-weight:600}
nav .ct{display:inline-block;margin-left:6px;padding:0 7px;border-radius:9px;background:rgba(0,0,0,.25);font-size:12px}
nav .exp{margin-left:auto;font-size:12px}
@media (prefers-color-scheme:dark){nav{background:#1f3a34}}
.bar{display:flex;flex-wrap:wrap;gap:10px 16px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line)}
.bar h1{font-size:18px;margin:0 12px 0 0}
.bar label{color:var(--mut);font-size:13px;display:flex;align-items:center;gap:6px}
.bar input[type=search]{padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--ink);min-width:200px}
.bar select{padding:5px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--ink)}
.sum{color:var(--mut);font-size:13px}
.intro{padding:8px 16px 0;color:var(--mut);font-size:13px;max-width:1100px}
.grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:12px;padding:16px}
@media (max-width:700px){.grid{grid-template-columns:repeat(min(var(--cols),2),minmax(0,1fr))}}
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;overflow:hidden;display:flex;flex-direction:column}
.card.d-APPROVE{border-color:var(--ok);box-shadow:inset 0 0 0 2px var(--ok)}
.card.d-EXCLUDE{border-color:var(--bad);box-shadow:inset 0 0 0 2px var(--bad)}
.card.d-UNSURE{border-color:var(--unsure);box-shadow:inset 0 0 0 2px var(--unsure)}
.img{aspect-ratio:1/1;background:#ddd center/cover no-repeat;cursor:zoom-in;position:relative}
.img.none{display:flex;align-items:center;justify-content:center;color:#777;cursor:default;font-size:12px}
.badge{position:absolute;top:6px;left:6px;font-size:11px;padding:2px 7px;border-radius:9px;background:rgba(0,0,0,.65);color:#fff}
.body{padding:8px 10px;display:flex;flex-direction:column;gap:4px;flex:1}
.t{font-weight:600;font-size:13px}
.meta{font-size:12px;color:var(--mut);word-break:break-word}
.st{font-size:12px;font-weight:600}
.when{font-size:11px;color:var(--mut)}
.reason{font-size:12px;color:var(--mut);max-height:4.2em;overflow:auto}
.flags{font-size:11px;color:var(--mut)}
.links a{font-size:12px;color:var(--acc);margin-right:10px}
.btns{display:flex;gap:4px;margin-top:6px}
.btns button{flex:1;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:6px;padding:6px 2px;font-size:12px;font-weight:600;cursor:pointer}
.btns button.APPROVE.on{background:var(--ok);color:#fff;border-color:var(--ok)}
.btns button.EXCLUDE.on{background:var(--bad);color:#fff;border-color:var(--bad)}
.btns button.UNSURE.on{background:var(--unsure);color:#fff;border-color:var(--unsure)}
.note{width:100%;border:1px solid var(--line);border-radius:6px;padding:5px;font:12px inherit;background:var(--card);color:var(--ink);resize:vertical;min-height:30px}
.dec{font-size:11px;color:var(--mut)}
.clear{background:none;border:none;color:var(--mut);font-size:11px;text-decoration:underline;cursor:pointer;padding:0}
.over{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:12px;padding:16px}
.tile{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:14px;text-decoration:none;color:var(--ink)}
.tile b{font-size:26px;display:block}
.lb{position:fixed;inset:0;background:rgba(0,0,0,.85);display:none;align-items:center;justify-content:center;z-index:50;padding:20px;flex-direction:column;gap:10px}
.lb.on{display:flex}
.lb img{max-width:95vw;max-height:85vh;object-fit:contain;background:#fff}
.lb div{color:#fff;font-size:14px;text-align:center}
.empty{padding:40px 16px;color:var(--mut)}
</style>
</head>
<body>
<nav id="nav"></nav>
<div class="bar" id="bar"></div>
<div class="intro" id="intro"></div>
<div id="main"></div>
<div class="lb" id="lb"><img id="lbimg" alt=""><div id="lbcap"></div></div>
<script>
'use strict';
const ORIGIN = location.origin; // never a relative fetch — user:pass@ URLs poison baseURI
const QUEUES = [
  { id: 'jstevens', label: 'JStevens hold', desc: 'TK-10484 — 45 Jeffrey Stevens peel-and-stick items held on a title-keyword settlement flag. No vision review yet; nothing is cleared for activation until decided here.' },
  { id: 'review', label: 'Needs Review', desc: 'Catalog settlement audit (May 2026, Gemini vision vs the binding text, first image only). NEEDS_REVIEW = the model could not decide. These are currently live-permitted until a human answers.' },
  { id: 'errors', label: 'Errors', desc: 'Catalog audit rows where the image fetch failed — never screened. Check the product page image yourself.' },
  { id: 'prohibited', label: 'Prohibited', desc: 'Catalog audit PROHIBITED (Part A + Part B, no carve-out). Confirm: EXCLUDE = agree it must come off; APPROVE = audit was wrong, it is safe.' },
  { id: 'decided', label: 'Decided', desc: 'Everything with a recorded verdict, across all queues. Change or clear any decision here.' },
];
const LS = { get(k, d) { try { const v = localStorage.getItem(k); return v === null ? d : v; } catch (_) { return d; } }, set(k, v) { try { localStorage.setItem(k, v); } catch (_) {} } };
let DATA = null, DEC = {};
const page = location.pathname.replace(/\/+$/, '').slice(1) || '';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
function parseDate(s) { if (!s) return null; let t = String(s); if (!/[zZ]|[+-]\d\d:?\d\d$/.test(t)) t = t.replace(' ', 'T') + 'Z'; const d = new Date(t); return isNaN(d) ? null : d; }
const fmt = (d) => d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });

async function api(p, opts = {}) {
  const r = await fetch(ORIGIN + p, { credentials: 'include', ...opts });
  if (!r.ok) throw new Error(p + ' → ' + r.status);
  return r.json();
}

function renderNav() {
  const all = DATA.items;
  const nDec = Object.keys(DEC).filter((k) => all.some((i) => i.key === k)).length;
  const html = ['<span class="brand">⚖ Settlement Review</span>', `<a href="/" class="${page === '' ? 'on' : ''}">Overview</a>`];
  for (const q of QUEUES) {
    let ct;
    if (q.id === 'decided') ct = `${nDec}/${all.length}`;
    else { const its = all.filter((i) => i.queue === q.id); ct = `${its.filter((i) => !DEC[i.key]).length}/${its.length}`; }
    html.push(`<a href="/${q.id}" class="${page === q.id ? 'on' : ''}" title="undecided / total">${esc(q.label)}<span class="ct">${ct}</span></a>`);
  }
  html.push(`<a class="exp" href="/api/decisions.json" target="_blank" rel="noopener noreferrer">decisions.json ↗</a>`);
  document.getElementById('nav').innerHTML = html.join('');
}

function renderOverview() {
  document.getElementById('bar').innerHTML = `<h1>Overview</h1><span class="sum">Snapshot ${esc(fmt(parseDate(DATA.generated_at)))} · ${DATA.items.length} designs need a settlement answer · APPROVE = safe · EXCLUDE = settlement risk · UNSURE = park it</span>`;
  document.getElementById('intro').textContent = '';
  const tiles = QUEUES.map((q) => {
    const its = q.id === 'decided' ? DATA.items.filter((i) => DEC[i.key]) : DATA.items.filter((i) => i.queue === q.id);
    const tally = { APPROVE: 0, EXCLUDE: 0, UNSURE: 0 };
    its.forEach((i) => { if (DEC[i.key]) tally[DEC[i.key].verdict]++; });
    const left = q.id === 'decided' ? '' : `${its.filter((i) => !DEC[i.key]).length} undecided · `;
    return `<a class="tile" href="/${q.id}"><b>${its.length}</b>${esc(q.label)}<div class="meta">${left}✅ ${tally.APPROVE} · ⛔ ${tally.EXCLUDE} · ❔ ${tally.UNSURE}</div><div class="meta" style="margin-top:6px">${esc(q.desc)}</div></a>`;
  });
  document.getElementById('main').innerHTML = `<div class="over">${tiles.join('')}</div>`;
}

const SORTS = {
  newest: (a, b) => (parseDate(b.date) || 0) - (parseDate(a.date) || 0),
  sku: (a, b) => String(a.sku || '').localeCompare(String(b.sku || ''), undefined, { numeric: true }),
  title: (a, b) => String(a.title || '').localeCompare(String(b.title || '')),
  status: (a, b) => String(a.status || '').localeCompare(String(b.status || '')) || String(a.title).localeCompare(String(b.title)),
  vendor: (a, b) => String(a.vendor || '').localeCompare(String(b.vendor || '')) || String(a.title).localeCompare(String(b.title)),
};

function renderQueue() {
  const q = QUEUES.find((x) => x.id === page);
  if (!q) { document.getElementById('main').innerHTML = '<div class="empty">Unknown page.</div>'; return; }
  const sort = LS.get('sr.sort', 'newest');
  const cols = LS.get('sr.cols', '5');
  const showDecided = page === 'decided' || LS.get('sr.showDecided', '0') === '1';
  const qs = new URLSearchParams(location.search);
  const term = qs.get('q') || '';
  document.getElementById('bar').innerHTML = `
    <h1>${esc(q.label)}</h1>
    <label>Search <input type="search" id="q" value="${esc(term)}" placeholder="title, sku, vendor, reason"></label>
    <label>Sort <select id="sort">
      <option value="newest">Newest</option><option value="sku">SKU A→Z</option><option value="title">Title A→Z</option>
      <option value="status">Status</option><option value="vendor">Vendor A→Z</option></select></label>
    <label>Density <input type="range" id="cols" min="1" max="10" value="${esc(cols)}"> <span id="colsv">${esc(cols)}</span></label>
    ${page === 'decided' ? '' : `<label><input type="checkbox" id="showd" ${showDecided ? 'checked' : ''}> show decided</label>`}
    <span class="sum" id="sum"></span>`;
  document.getElementById('intro').textContent = q.desc;
  document.getElementById('sort').value = sort;
  document.documentElement.style.setProperty('--cols', cols);
  document.getElementById('sort').onchange = (e) => { LS.set('sr.sort', e.target.value); drawCards(); };
  document.getElementById('cols').oninput = (e) => { LS.set('sr.cols', e.target.value); document.getElementById('colsv').textContent = e.target.value; document.documentElement.style.setProperty('--cols', e.target.value); };
  const sd = document.getElementById('showd'); if (sd) sd.onchange = (e) => { LS.set('sr.showDecided', e.target.checked ? '1' : '0'); drawCards(); };
  document.getElementById('q').oninput = (e) => { const u = new URL(location.href); if (e.target.value) u.searchParams.set('q', e.target.value); else u.searchParams.delete('q'); history.replaceState(null, '', u.pathname + u.search); drawCards(); };
  drawCards();
}

function drawCards() {
  const term = (document.getElementById('q').value || '').toLowerCase();
  const sort = document.getElementById('sort').value;
  const sd = document.getElementById('showd');
  const showDecided = page === 'decided' || (sd && sd.checked);
  let its = page === 'decided' ? DATA.items.filter((i) => DEC[i.key]) : DATA.items.filter((i) => i.queue === page);
  const total = its.length;
  if (!showDecided) its = its.filter((i) => !DEC[i.key]);
  if (term) its = its.filter((i) => [i.title, i.sku, i.vendor, i.reason, i.status, i.mfr_code, (DEC[i.key] || {}).note].join(' ').toLowerCase().includes(term));
  its.sort(SORTS[sort] || SORTS.newest);
  document.getElementById('sum').textContent = `showing ${its.length} of ${total}`;
  const main = document.getElementById('main');
  if (!its.length) { main.innerHTML = `<div class="empty">${total ? 'Nothing left here — all decided (tick "show decided" to review).' : 'Empty queue.'}</div>`; return; }
  main.innerHTML = `<div class="grid">${its.map(cardHTML).join('')}</div>`;
}

function cardHTML(i) {
  const d = DEC[i.key];
  const dt = parseDate(i.date);
  const qlabel = (QUEUES.find((q) => q.id === i.queue) || {}).label || i.queue;
  const img = i.image
    ? `<div class="img" style="background-image:url('${esc(i.image)}')" data-zoom="${esc(i.key)}"><span class="badge">${esc(qlabel)}</span>${(i.images || []).length > 1 ? `<span class="badge" style="left:auto;right:6px">${i.images.length} imgs</span>` : ''}</div>`
    : `<div class="img none"><span class="badge">${esc(qlabel)}</span>no image</div>`;
  const btn = (v, lbl) => `<button class="${v} ${d && d.verdict === v ? 'on' : ''}" data-k="${esc(i.key)}" data-v="${v}">${lbl}</button>`;
  return `<div class="card ${d ? 'd-' + d.verdict : ''}" id="c-${esc(i.key)}">${img}<div class="body">
    <div class="t">${esc(i.title)}</div>
    <div class="meta">SKU <b>${esc(i.sku || '—')}</b>${i.mfr_code ? ' · mfr ' + esc(i.mfr_code) : ''} · ${esc(i.vendor || '—')}</div>
    <div class="st">${esc(i.status)}</div>
    ${dt ? `<div class="when" title="${esc(dt.toISOString())}">🕓 ${esc(i.date_label)} ${esc(fmt(dt))}</div>` : '<div class="when">🕓 no date</div>'}
    <div class="reason">${esc(i.reason || '')}</div>
    ${i.flags && i.flags.length ? `<div class="flags">${esc(i.flags.join(' · '))}</div>` : ''}
    <div class="meta">source: ${esc(i.source)}</div>
    <div class="links">${i.link ? `<a href="${esc(i.link)}" target="_blank" rel="noopener noreferrer">product ↗</a>` : ''}${i.admin ? `<a href="${esc(i.admin)}" target="_blank" rel="noopener noreferrer">admin ↗</a>` : ''}</div>
    <div class="btns">${btn('APPROVE', '✅ Approve')}${btn('EXCLUDE', '⛔ Exclude')}${btn('UNSURE', '❔ Unsure')}</div>
    <textarea class="note" data-note="${esc(i.key)}" placeholder="optional note">${esc(d ? d.note : '')}</textarea>
    ${d ? `<div class="dec" title="${esc(d.at)}">${esc(d.verdict)} · ${esc(fmt(new Date(d.at)))} · <button class="clear" data-clear="${esc(i.key)}">clear</button></div>` : ''}
  </div></div>`;
}

async function decide(key, verdict) {
  const ta = document.querySelector(`textarea[data-note="${CSS.escape(key)}"]`);
  const note = ta ? ta.value : '';
  try {
    const r = await api('/api/decision', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, verdict, note }) });
    if (r.decision) DEC[key] = r.decision; else delete DEC[key];
    renderNav();
    const el = document.getElementById('c-' + key);
    const it = DATA.items.find((x) => x.key === key);
    if (el && it) el.outerHTML = cardHTML(it);
  } catch (e) { alert('Save failed: ' + e.message); }
}

document.addEventListener('click', (e) => {
  const b = e.target.closest('button[data-v]');
  if (b) return decide(b.dataset.k, b.dataset.v);
  const c = e.target.closest('button[data-clear]');
  if (c) return decide(c.dataset.clear, null);
  const z = e.target.closest('[data-zoom]');
  if (z) {
    const it = DATA.items.find((x) => x.key === z.dataset.zoom);
    LBI = { imgs: (it.images && it.images.length ? it.images : [it.image]), idx: 0, it };
    LBI.idx = Math.max(0, LBI.imgs.indexOf(it.image));
    showLB();
    document.getElementById('lb').classList.add('on');
    return;
  }
  if (e.target.id === 'lbimg' && LBI && LBI.imgs.length > 1) { LBI.idx = (LBI.idx + 1) % LBI.imgs.length; return showLB(); }
  if (e.target.closest('#lb')) document.getElementById('lb').classList.remove('on');
});
let LBI = null;
function showLB() {
  document.getElementById('lbimg').src = LBI.imgs[LBI.idx];
  const n = LBI.imgs.length;
  document.getElementById('lbcap').textContent = `${LBI.it.title} — ${LBI.it.sku || ''}${n > 1 ? `  ·  image ${LBI.idx + 1}/${n} (click image or ←/→ to cycle, click outside/Esc to close)` : ''}`;
}
document.addEventListener('keydown', (e) => {
  const on = document.getElementById('lb').classList.contains('on');
  if (e.key === 'Escape') document.getElementById('lb').classList.remove('on');
  if (on && LBI && LBI.imgs.length > 1 && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) {
    LBI.idx = (LBI.idx + (e.key === 'ArrowRight' ? 1 : -1) + LBI.imgs.length) % LBI.imgs.length; showLB();
  }
});
// saving a note on blur re-saves the current verdict with the new note
document.addEventListener('change', (e) => {
  const ta = e.target.closest('textarea[data-note]');
  if (ta && DEC[ta.dataset.note]) decide(ta.dataset.note, DEC[ta.dataset.note].verdict);
});

(async function init() {
  try {
    [DATA, DEC] = await Promise.all([api('/api/items'), api('/api/decisions')]);
  } catch (e) { document.getElementById('main').innerHTML = `<div class="empty">Load failed: ${esc(e.message)}</div>`; return; }
  renderNav();
  if (page === '') renderOverview(); else renderQueue();
})();
</script>
</body>
</html>