← 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>