← back to Rentv 2026

public/admin/pr-intelligence/review.html

236 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>CRE PR Intelligence — Review Queue</title>
<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
</head>
<body>
<script src="/admin/pr-intelligence/pr-shared.js"></script>
<script>
(async function () {
  const c = PR.frame('review', 'Review Queue', 'Left: candidates. Center: profile + evidence + duplicates. Right: letter preview + notes. Keys: ↑/↓ navigate · A accept · R reject · D defer · S stale.');
  const params = new URLSearchParams(location.search);
  let kind = params.get('kind') === 'people' ? 'people' : 'organizations';
  let items = [], idx = 0, current = null;

  c.innerHTML = `
    <div class="filters">
      <select id="r-kind"><option value="organizations" ${kind === 'organizations' ? 'selected' : ''}>Organizations needing review</option>
        <option value="people" ${kind === 'people' ? 'selected' : ''}>People needing verification</option></select>
      <select id="r-status"></select>
      <span id="r-count" style="color:var(--muted);font-size:12px"></span>
    </div>
    <div class="review3">
      <div class="rlist" id="r-list"><div class="empty"><span class="spin"></span></div></div>
      <div id="r-center"><div class="empty">Select a candidate</div></div>
      <div id="r-right"><div class="panel"><h3>Letter preview</h3><div id="r-letter" class="empty">Select a person (or an org with people) to preview the grounded letter.</div></div>
        <div class="panel"><h3>Internal research notes</h3><textarea id="r-notes" style="width:100%;min-height:90px;border:1px solid var(--line);border-radius:8px;padding:8px;font:13px inherit"></textarea>
        <button class="btn sm" id="r-savenote" style="margin-top:6px">Save note</button></div>
        <div class="panel" id="r-actions-right"></div>
      </div>
    </div>`;

  const statusSel = document.getElementById('r-status');
  function fillStatuses() {
    const opts = kind === 'organizations'
      ? [['needs_review', 'needs review'], ['discovered', 'discovered'], ['incomplete', 'incomplete'], ['verified', 'verified']]
      : [['needs_verification', 'needs verification'], ['discovered', 'discovered'], ['stale', 'stale'], ['verified', 'verified']];
    statusSel.innerHTML = opts.map(([v, l]) => `<option value="${v}">${l}</option>`).join('');
  }
  fillStatuses();

  document.getElementById('r-kind').addEventListener('change', (e) => { kind = e.target.value; fillStatuses(); loadList(); });
  statusSel.addEventListener('change', loadList);

  async function loadList() {
    const j = await PR.api(`/review/queue?kind=${kind}&status=${statusSel.value}&limit=100`);
    items = j.rows; idx = 0;
    document.getElementById('r-count').textContent = j.total + ' in queue';
    const list = document.getElementById('r-list');
    list.innerHTML = items.length ? items.map((r, i) => kind === 'organizations'
      ? `<div class="it" data-i="${i}"><b>${PR.esc(r.display_name)}</b><span>${PR.esc(r.organization_type)} · conf ${r.confidence_score} · ${PR.esc((r.metros || []).join(','))}</span></div>`
      : `<div class="it" data-i="${i}"><b>${PR.esc(r.full_name)}</b><span>${PR.esc(r.exact_title || '(no title)')} · ${PR.esc(r.organization_name || '')} · conf ${r.confidence_score}</span></div>`).join('')
      : '<div class="empty">Queue is clear 🎉</div>';
    list.querySelectorAll('.it').forEach((el) => el.addEventListener('click', () => select(Number(el.dataset.i))));
    const focus = params.get('focus');
    if (focus) { const fi = items.findIndex((x) => String(x.id) === focus); if (fi >= 0) idx = fi; }
    if (items.length) select(idx);
  }

  async function select(i) {
    idx = i;
    document.querySelectorAll('#r-list .it').forEach((el) => el.classList.toggle('on', Number(el.dataset.i) === i));
    const row = items[i]; if (!row) return;
    try {
      current = kind === 'organizations' ? await PR.api('/organizations/' + row.id) : await PR.api('/people/' + row.id);
    } catch (e) { PR.toast('Could not load candidate: ' + e.message, true); return; } // surface, don't die silently
    renderCenter();
    renderRight();
    // On the collapsed single-column layout (≤1200px) the center/right panels stack
    // BELOW the tall candidate list, so a click updates content off-screen and looks
    // like "nothing happened." Bring the freshly-rendered profile into view.
    if (window.matchMedia('(max-width:1200px)').matches) {
      document.getElementById('r-center').scrollIntoView({ behavior: 'smooth', block: 'start' });
    }
  }

  function renderCenter() {
    const o = current;
    const isOrg = kind === 'organizations';
    const dupBtn = `<button class="btn sm" id="c-dups">Find duplicate candidates</button>`;
    document.getElementById('r-center').innerHTML = `
      <div class="panel">
        <h3>${PR.esc(isOrg ? o.display_name : o.full_name)} ${PR.statusBadge(o.lifecycle_status)}</h3>
        ${isOrg ? `
          <div class="kv">
            <dt>Website</dt><dd>${o.website_url ? `<a href="${PR.esc(o.website_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(o.website_url)}</a>` : '<span class="b amber">missing</span>'}</dd>
            <dt>Category</dt><dd>${PR.badge(o.organization_type, 'blue')}</dd>
            <dt>Geography</dt><dd>${(o.state_presence || []).join(',')} · ${(o.metros || []).join(', ') || '<span class="b amber">no metro</span>'}</dd>
            <dt>Press</dt><dd>${o.press_page_url ? `<a href="${PR.esc(o.press_page_url)}" target="_blank" rel="noopener noreferrer">press page</a>` : '—'} · ${PR.esc(o.general_press_email || 'no press email')}</dd>
            <dt>People</dt><dd>${o.people.length}</dd>
            <dt>Scores</dt><dd>conf ${o.confidence_score} · priority ${o.priority_score}</dd>
            <dt>Created</dt><dd>🕓 ${PR.date(o.created_at)}</dd>
          </div>` : `
          <div class="kv">
            <dt>Title</dt><dd>${PR.esc(o.exact_title || '—')} ${PR.badge(o.normalized_role || '', 'blue')}</dd>
            <dt>Organization</dt><dd>${PR.esc(o.organization_name || '—')}</dd>
            <dt>Email</dt><dd>${PR.esc(o.public_work_email || '—')} ${PR.verifBadge(o.email_verification_status)}</dd>
            <dt>LinkedIn</dt><dd>${o.linkedin_url ? `<a href="${PR.esc(o.linkedin_url)}" target="_blank" rel="noopener noreferrer">profile</a>` : '—'} ${PR.verifBadge(o.linkedin_status)}
              ${o.linkedin_indexed_title ? `<div style="font-size:11px;color:var(--muted)">indexed: “${PR.esc(o.linkedin_indexed_title)}” (needs corroboration)</div>` : ''}</dd>
            <dt>Scores</dt><dd>conf ${o.confidence_score} · priority ${o.contact_priority_score}</dd>
            <dt>Created</dt><dd>🕓 ${PR.date(o.created_at)}</dd>
          </div>`}
        <div class="filters">
          <button class="btn sm primary" id="c-accept">✓ Accept (A)</button>
          <button class="btn sm" id="c-edit">✎ Edit</button>
          <button class="btn sm" id="c-defer">Defer (D)</button>
          <button class="btn sm" id="c-stale">Mark stale (S)</button>
          <button class="btn sm danger" id="c-reject">✗ Reject (R)</button>
          ${dupBtn}
        </div>
        <div id="c-dups-out"></div>
      </div>
      <div class="panel"><h3>Evidence (${(o.evidence || []).length})</h3>${PR.evidenceHtml(o.evidence, { addToLetter: true })}</div>`;
    document.getElementById('c-accept').addEventListener('click', () => act('accept'));
    document.getElementById('c-reject').addEventListener('click', () => act('reject'));
    document.getElementById('c-defer').addEventListener('click', () => act('defer'));
    document.getElementById('c-stale').addEventListener('click', () => act('stale'));
    document.getElementById('c-edit').addEventListener('click', editCurrent);
    document.getElementById('c-dups').addEventListener('click', showDups);
    document.querySelectorAll('#r-center [data-addletter]').forEach((a) => a.addEventListener('click', (e) => {
      e.preventDefault();
      localStorage.setItem('pr-letter-clip', JSON.stringify({ org_id: isOrg ? o.id : o.organization_id, source_id: Number(a.dataset.addletter), excerpt: a.dataset.excerpt }));
      PR.toast('Added to letter clipboard');
    }));
  }

  async function renderRight() {
    const o = current; const isOrg = kind === 'organizations';
    const personId = isOrg ? (o.people[0] || {}).id : o.id;
    const letterBox = document.getElementById('r-letter');
    if (!personId) { letterBox.innerHTML = '<div class="empty">No person on this org yet</div>'; }
    else {
      try {
        const prev = await PR.api('/letters/preview', { method: 'POST', body: { person_id: personId } });
        letterBox.innerHTML = `<div style="font-size:12px;color:var(--muted);margin-bottom:6px">Subject: <b style="color:var(--ink)">${PR.esc(prev.subject)}</b></div>
          <div class="letter-preview" style="max-height:300px;overflow:auto;font-size:12.5px">${prev.text.split('\n\n').map((p) => PR.esc(p)).join('\n\n').replace(/\{\{([a-z0-9_]+)\}\}/gi, '<span class="missing">{{$1}}</span>')}</div>
          ${prev.missing.length ? '<div style="font-size:11px;color:var(--warn);margin-top:6px">Unfilled: ' + prev.missing.join(', ') + '</div>' : ''}
          <div class="filters" style="margin-top:8px">
            <button class="btn sm" id="rr-draft">Save as draft</button>
            <button class="btn sm" id="rr-provider" title="Draft must be generated + approved first" disabled>Create provider draft</button>
          </div>`;
        letterBox.querySelector('#rr-draft').addEventListener('click', async () => {
          const r = await PR.guard(() => PR.api('/outreach/draft', { method: 'POST', body: { person_id: personId } }), 'Draft saved — open the Letter Builder or Campaigns to approve');
          letterBox.querySelector('#rr-provider').disabled = false;
          letterBox.querySelector('#rr-provider').dataset.mid = r.message.id;
        });
        letterBox.querySelector('#rr-provider').addEventListener('click', async (e) => {
          const mid = e.target.dataset.mid; if (!mid) return;
          await PR.guard(() => PR.api('/outreach/' + mid + '/approve', { method: 'POST' }));
          await PR.guard(() => PR.api('/outreach/' + mid + '/provider-draft', { method: 'POST' }), 'Provider draft created (nothing sent)');
        });
      } catch (e) { letterBox.innerHTML = '<div class="empty">' + PR.esc(e.message) + '</div>'; }
    }
    document.getElementById('r-notes').value = o.notes || '';
    document.getElementById('r-savenote').onclick = async () => {
      const api = isOrg ? '/organizations/' : '/people/';
      await PR.guard(() => PR.api(api + o.id, { method: 'PUT', body: { notes: document.getElementById('r-notes').value } }), 'Note saved');
    };
    // Outreach / draft history. Non-sent drafts get a Discard button (soft-delete → status
    // 'discarded'); sent/replied messages are audit-trail and can never be discarded, so no button.
    const DISCARDABLE = ['draft_generated', 'needs_review', 'provider_draft_created', 'approved'];
    document.getElementById('r-actions-right').innerHTML = `<h3>Reply history</h3>` +
      ((o.outreach || []).map((m) => `<div class="gatechk" data-mid="${m.id}">${PR.statusBadge(m.status)} <span style="flex:1">${PR.esc(m.subject || '(no subject)')}</span>`
        + (!m.sent_at && DISCARDABLE.includes(m.status)
            ? `<button class="btn sm danger" data-discard="${m.id}" title="Soft-delete this draft (nothing sent is ever removed)">Discard</button>` : '')
        + `</div>`).join('') || '<div class="empty">None</div>');
    document.querySelectorAll('#r-actions-right [data-discard]').forEach((b) => b.addEventListener('click', async () => {
      if (!confirm('Discard this draft? It drops out of the review queue. Sent messages can never be discarded.')) return;
      await PR.guard(() => PR.api('/outreach/' + b.dataset.discard, { method: 'DELETE' }), 'Draft discarded');
      const row = b.closest('.gatechk'); if (row) row.remove();
    }));
  }

  async function act(action) {
    const o = current; if (!o) return;
    const kindSingular = kind === 'organizations' ? 'organization' : 'person';
    await PR.guard(() => PR.api(`/review/${kindSingular}/${o.id}`, { method: 'POST', body: { action } }), action + ' ✓');
    items.splice(idx, 1);
    loadListSoft();
  }
  function loadListSoft() {
    const list = document.getElementById('r-list');
    document.getElementById('r-count').textContent = items.length + ' in queue';
    list.innerHTML = items.length ? items.map((r, i) => kind === 'organizations'
      ? `<div class="it" data-i="${i}"><b>${PR.esc(r.display_name)}</b><span>${PR.esc(r.organization_type)} · conf ${r.confidence_score}</span></div>`
      : `<div class="it" data-i="${i}"><b>${PR.esc(r.full_name)}</b><span>${PR.esc(r.exact_title || '')} · conf ${r.confidence_score}</span></div>`).join('')
      : '<div class="empty">Queue is clear 🎉</div>';
    list.querySelectorAll('.it').forEach((el) => el.addEventListener('click', () => select(Number(el.dataset.i))));
    if (items.length) select(Math.min(idx, items.length - 1));
    else { document.getElementById('r-center').innerHTML = '<div class="empty">Queue is clear 🎉</div>'; document.getElementById('r-letter').innerHTML = ''; }
  }

  function editCurrent() {
    const o = current; const isOrg = kind === 'organizations';
    const patchStr = prompt('Edit JSON patch (allowed fields per record type):', isOrg
      ? JSON.stringify({ display_name: o.display_name, website_url: o.website_url, organization_type: o.organization_type, metros: o.metros })
      : JSON.stringify({ exact_title: o.exact_title, public_work_email: o.public_work_email, email_verification_status: o.email_verification_status }));
    if (!patchStr) return;
    let patch; try { patch = JSON.parse(patchStr); } catch { return PR.toast('Invalid JSON', true); }
    PR.guard(() => PR.api(`/review/${isOrg ? 'organization' : 'person'}/${o.id}`, { method: 'POST', body: { action: 'edit', patch } }), 'Edited').then(() => select(idx));
  }

  async function showDups() {
    const o = current;
    if (kind !== 'organizations') return PR.toast('Duplicate scan UI is org-first; person dups auto-flag on create', true);
    const j = await PR.guard(() => PR.api('/organizations/' + o.id + '/matches'));
    const out = document.getElementById('c-dups-out');
    out.innerHTML = j.matches.length ? j.matches.map((m) => `
      <div class="evi"><b>${PR.esc(m.org.display_name)}</b> ${PR.badge(m.verdict, 'amber')} score ${m.score}
        <div class="x">${m.reasons.map(PR.esc).join(' · ')}</div>
        <button class="btn sm" data-merge="${m.org.id}">Merge into current</button></div>`).join('')
      : '<div class="empty">No duplicate candidates</div>';
    out.querySelectorAll('[data-merge]').forEach((b) => b.addEventListener('click', async () => {
      if (!confirm('Merge #' + b.dataset.merge + ' into current?')) return;
      await PR.guard(() => PR.api('/organizations/' + o.id + '/merge', { method: 'POST', body: { duplicate_id: Number(b.dataset.merge) } }), 'Merged');
      select(idx);
    }));
  }

  document.addEventListener('keydown', (e) => {
    if (/input|textarea|select/i.test(document.activeElement.tagName)) return;
    if (e.key === 'ArrowDown') { e.preventDefault(); if (idx < items.length - 1) select(idx + 1); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); if (idx > 0) select(idx - 1); }
    else if (e.key === 'a' || e.key === 'A') act('accept');
    else if (e.key === 'r' || e.key === 'R') act('reject');
    else if (e.key === 'd' || e.key === 'D') act('defer');
    else if (e.key === 's' || e.key === 'S') act('stale');
  });

  loadList();
})();
</script>
</body>
</html>