← back to Rentv 2026

public/admin/pr-intelligence/people.html

230 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 — People</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('people', 'People', 'Public business contacts. Exact title AND normalized role are both kept; corrections preserve the extracted original; inferred emails can never be sent.');
  const meta = await PR.api('/meta');
  const CA = meta.states.find((s) => s.key === 'CA'), AZ = meta.states.find((s) => s.key === 'AZ');

  let sort = 'priority', dir = 'desc', offset = 0; const limit = 50;
  const { bar, state } = PR.filters([
    { key: 'q', placeholder: 'Name, org, title, email, LinkedIn…' },
    { key: 'department', type: 'select', options: [['', 'Dept: all'], ['communications', 'Communications/PR'], ['marketing', 'Marketing'], ['editorial', 'Editorial'], ['media_business', 'Media/Business'], ['agency', 'Agency'], ['leadership', 'Leadership'], ['lending', 'Lending'], ['title_escrow', 'Title & escrow'], ['bd', 'Biz dev'], ['other', 'Other']] },
    { key: 'metro', type: 'select', options: [['', 'Metro: all'], ...CA.metros.map((m) => [m.key, 'CA · ' + m.label]), ...AZ.metros.map((m) => [m.key, 'AZ · ' + m.label])] },
    { key: 'status', type: 'select', options: [['', 'Status: all']].concat('discovered,needs_verification,verified,stale,wrong_person,left_company,duplicate,suppressed,ready_for_outreach,contacted,replied'.split(',').map((s) => [s, s.replace(/_/g, ' ')])) },
    { key: 'email_status', type: 'select', options: [['', 'Email: all'], ['manually_verified', 'manually verified'], ['company_site_verified', 'site verified'], ['press_release_verified', 'press verified'], ['corroborated', 'corroborated'], ['inferred', '⚠ inferred'], ['unverified', 'unverified']] },
    { key: 'linkedin_status', type: 'select', options: [['', 'LinkedIn: all'], ['found_corroborated', 'found + corroborated'], ['found_role_corroborated', 'found + role-corroborated'], ['found_uncorroborated', 'found, not corroborated'], ['manually_verified', 'manually verified'], ['none', 'none']] },
    { key: 'outreach_eligible', type: 'select', options: [['', 'Eligible: any'], ['1', 'Outreach-eligible'], ['0', 'Not eligible']] },
  ], () => { offset = 0; load(); });
  c.appendChild(bar);

  const actions = document.createElement('div'); actions.className = 'filters';
  actions.innerHTML = `
    <button class="btn" id="add-person">+ Add person</button>
    <button class="btn" id="verify-sel" disabled>Queue verify</button>
    <button class="btn" id="campaign-sel" disabled>Add to campaign</button>
    <button class="btn" id="draft-sel" disabled>Generate drafts</button>
    <a class="btn" id="export" href="#">⬇ Export CSV</a>
    <span id="selcount" style="color:var(--muted);font-size:12px"></span>`;
  c.appendChild(actions);

  const selected = new Set();
  const tbl = PR.table([
    { key: '_sel', label: '', render: (r) => `<input type="checkbox" data-sel="${r.id}" ${selected.has(r.id) ? 'checked' : ''}>` },
    { key: 'name', label: 'Person', render: (r) => `<b>${PR.esc(r.full_name)}</b><br><span style="color:var(--muted);font-size:12px">${PR.esc(r.exact_title || '')}</span>` },
    { key: 'org', label: 'Organization', render: (r) => r.organization_id ? `<a href="/admin/pr-intelligence/organizations?id=${r.organization_id}">${PR.esc(r.organization_name || '')}</a>` : '—' },
    { key: 'role', label: 'Role', render: (r) => PR.badge(r.normalized_role || 'other', 'blue') + ' ' + PR.badge(r.department || '', 'gray') },
    { key: '_public', label: 'Public info', render: (r) => `
        <div style="line-height:1.9;font-size:12.5px;min-width:230px">
          <div>📧 ${r.public_work_email ? PR.esc(r.public_work_email) + ' ' + PR.verifBadge(r.email_verification_status) : '—'}</div>
          <div>🔗 ${r.linkedin_url ? `<a href="${PR.esc(r.linkedin_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(r.linkedin_url.replace('https://www.', ''))}</a> ${PR.verifBadge(r.linkedin_status)}` : '—'}</div>
          <div>☎️ ${r.public_business_phone ? PR.esc(String(r.public_business_phone).replace(/(\d{3})(\d{3})(\d{4})/, '($1) $2-$3')) : '—'}</div>
          <div>📍 ${PR.esc([r.office_location, r.metro, r.state].filter(Boolean).join(' · ')) || '—'}</div>
          <div>📝 ${r.notes ? PR.esc(String(r.notes).slice(0, 120)) : '—'}</div>
        </div>` },
    { key: 'confidence', label: 'Conf', render: (r) => PR.score(r.confidence_score) },
    { key: 'priority', label: 'Priority', render: (r) => PR.score(r.contact_priority_score) },
    { key: 'status', label: 'Status', render: (r) => PR.statusBadge(r.lifecycle_status) },
    { key: 'verified', label: 'Verified', render: (r) => PR.dateShort(r.last_verified_at) },
    { key: 'created', label: 'Added', render: (r) => `<span title="${PR.esc(r.created_at || '')}">🕓 ${PR.date(r.created_at)}</span>` },
  ], { onSort: (s, d2) => { sort = s; dir = d2; load(); }, onRow: (r) => openPerson(r.id) });
  c.appendChild(tbl.el);
  const pagerBox = document.createElement('div'); c.appendChild(pagerBox);

  async function load() {
    const q = new URLSearchParams({ ...Object.fromEntries(Object.entries(state).filter(([, v]) => v)), sort, dir, limit, offset });
    const j = await PR.api('/people?' + q);
    tbl.render(j.rows);
    pagerBox.innerHTML = ''; pagerBox.appendChild(PR.pager(j.total, limit, offset, (o) => { offset = o; load(); }));
    document.getElementById('export').href = '/api/pr/export/people?' + q;
    tbl.el.querySelectorAll('[data-sel]').forEach((cb) => cb.addEventListener('change', () => {
      const id = Number(cb.dataset.sel); cb.checked ? selected.add(id) : selected.delete(id); syncSel();
    }));
  }
  function syncSel() {
    ['verify-sel', 'campaign-sel', 'draft-sel'].forEach((id) => document.getElementById(id).disabled = !selected.size);
    document.getElementById('selcount').textContent = selected.size ? selected.size + ' selected' : '';
  }

  const drawer = PR.drawer();
  async function openPerson(id) {
    let p;
    try { p = await PR.api('/people/' + id); }
    catch (e) {
      drawer.open('Person not found', `<p style="padding:16px 18px;color:var(--muted)">No person with id <code>${PR.esc(String(id))}</code>${e && e.status === 404 ? ' — it may have been merged or removed.' : ' (' + PR.esc((e && e.message) || 'load failed') + ')'}</p>`);
      return;
    }
    const originals = p.original_values || {};
    const body = drawer.open(`${PR.esc(p.full_name)} ${PR.statusBadge(p.lifecycle_status)} ${p.suppressed ? PR.badge('SUPPRESSED', 'red') : ''}`, `
      <div class="kv">
        <dt>Exact title</dt><dd><input id="e-title" value="${PR.esc(p.exact_title || '')}" style="width:100%;padding:5px 8px;border:1px solid var(--line);border-radius:6px">
          ${originals.exact_title !== undefined ? `<div style="font-size:11px;color:var(--muted)">original extracted: “${PR.esc(originals.exact_title)}”</div>` : ''}</dd>
        <dt>Normalized role</dt><dd>${PR.badge(p.normalized_role || 'other', 'blue')} · ${PR.esc(p.department || '')} · ${PR.esc(p.seniority || '')}</dd>
        <dt>Organization</dt><dd>${p.organization_id ? `<a href="/admin/pr-intelligence/organizations?id=${p.organization_id}">${PR.esc(p.organization_name || '')}</a>` : '—'}</dd>
        <dt>Email</dt><dd><input id="e-email" value="${PR.esc(p.public_work_email || '')}" style="width:60%;padding:5px 8px;border:1px solid var(--line);border-radius:6px">
          <select id="e-emailstatus" style="padding:5px">${['unverified', 'inferred', 'corroborated', 'company_site_verified', 'press_release_verified', 'manually_verified'].map((s) => `<option ${s === p.email_verification_status ? 'selected' : ''}>${s}</option>`).join('')}</select>
          ${p.email_verification_status === 'inferred' ? '<div class="b red" style="margin-top:4px">⚠ INFERRED — stored only, never sendable until verified</div>' : ''}
          ${originals.public_work_email !== undefined ? `<div style="font-size:11px;color:var(--muted)">original: ${PR.esc(originals.public_work_email || '(none)')}</div>` : ''}</dd>
        <dt>Phone</dt><dd>${PR.esc(p.public_business_phone || '—')}</dd>
        <dt>LinkedIn</dt><dd>${p.linkedin_url ? `<a href="${PR.esc(p.linkedin_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(p.linkedin_url)}</a>` : '—'} ${PR.verifBadge(p.linkedin_status)}
          ${p.linkedin_indexed_title ? `<div style="font-size:11px;color:var(--muted)">indexed title (uncorroborated): “${PR.esc(p.linkedin_indexed_title)}”</div>` : ''}</dd>
        <dt>Location</dt><dd>${PR.esc([p.office_location, p.metro, p.state].filter(Boolean).join(' · ') || '—')}</dd>
        <dt>Notes</dt><dd><textarea id="e-notes" style="width:100%;min-height:56px;padding:6px 8px;border:1px solid var(--line);border-radius:6px;font:12.5px inherit" placeholder="Internal note…">${PR.esc(p.notes || '')}</textarea></dd>
        <dt>Scores</dt><dd>Confidence ${PR.score(p.confidence_score)} · Priority ${PR.score(p.contact_priority_score)}
          <details style="margin-top:4px"><summary style="cursor:pointer;font-size:12px;color:var(--muted)">components</summary>
          <pre style="font-size:11px;background:var(--bg);padding:8px;border-radius:6px;overflow:auto">${PR.esc(JSON.stringify(p.score_components || {}, null, 1))}</pre></details></dd>
        <dt>Outreach eligible</dt><dd>${p.outreach_eligible ? PR.badge('YES', 'green') : PR.badge('no', 'gray')} ${p.suppression_reason ? '· ' + PR.esc(p.suppression_reason) : ''}</dd>
        <dt>Verified</dt><dd>${PR.verifBadge(p.verification_status)} · ${PR.date(p.last_verified_at)}</dd>
        <dt>Created</dt><dd title="${PR.esc(p.created_at)}">🕓 ${PR.date(p.created_at)}</dd>
      </div>
      ${(p.role_history || []).length ? `<h3 style="margin:10px 0 6px">Role history (preserved)</h3>` +
        p.role_history.map((h) => `<div class="gatechk"><span style="color:var(--muted)">${PR.dateShort(h.recorded_at)}</span><span>${PR.esc(h.exact_title)}</span></div>`).join('') : ''}
      <div class="filters" style="margin-top:10px">
        <button class="btn sm primary" id="e-save">Save corrections</button>
        <button class="btn sm" id="e-verifyjob">Queue verify (LinkedIn locate)</button>
        <button class="btn sm" id="e-draft">Generate letter draft</button>
        <button class="btn sm danger" id="e-suppress">Suppress</button>
      </div>
      <div id="e-readiness"></div>
      <h3 style="margin:14px 0 8px">Source evidence</h3>
      ${PR.evidenceHtml(p.evidence, { addToLetter: true })}
      <h3 style="margin:14px 0 8px">Outreach</h3>
      ${p.outreach.map((m) => `<div class="gatechk">${PR.statusBadge(m.status)} <span>${PR.esc(m.subject || '(no subject)')}</span>
        <span style="color:var(--muted);margin-left:auto">${PR.dateShort(m.sent_at || m.drafted_at)}</span></div>`).join('') || '<div class="empty">No outreach yet</div>'}
      <h3 style="margin:14px 0 8px">Correspondence <span style="font-weight:400;color:var(--muted);font-size:12px">(email dates &amp; times, synced from Gmail)</span></h3>
      <div id="e-timeline"><div class="empty">Loading…</div></div>
      <h3 style="margin:14px 0 8px">Notes <span style="font-weight:400;color:var(--muted);font-size:12px">(team notes — timestamped &amp; attributed)</span></h3>
      <div id="e-notethread"></div>
      <h3 style="margin:14px 0 8px">Audit trail</h3>
      ${p.recent_activity.slice(0, 12).map((a) => `<div class="gatechk"><span style="color:var(--muted)">${PR.dateShort(a.created_at)}</span>
        <span>${PR.esc(a.activity)}</span><span style="color:var(--muted)">${PR.esc(a.actor || '')}</span></div>`).join('')}`);

    PR.api('/outreach/readiness/' + id).then((r) => {
      body.querySelector('#e-readiness').innerHTML = r.ready
        ? '<div class="b green" style="margin-top:8px">✅ Outreach-ready</div>'
        : '<div class="evi" style="border-color:var(--warn)"><b>Not outreach-ready:</b> ' + r.reasons.map(PR.esc).join('; ') + '</div>';
    });
    // Correspondence timeline (Gmail-synced messages, with real date + time)
    PR.api('/people/' + id + '/timeline').then((r) => {
      const el = body.querySelector('#e-timeline'); if (!el) return;
      const rows = (r && r.rows) || [];
      if (!rows.length) { el.innerHTML = '<div class="empty">No correspondence yet — connect Gmail on the Credentials page and run a sync.</div>'; return; }
      const arrow = (d) => d === 'inbound' ? '<span title="received" style="color:#0a8a4a">▼ in</span>' : '<span title="sent" style="color:#0b6cff">▲ out</span>';
      const when = (m) => { const t = m.corresponded_at || m.sent_at || m.replied_at; return t ? new Date(t).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—'; };
      el.innerHTML = rows.map((m) => `<div class="gatechk">${arrow(m.direction)} <span>${PR.esc(m.subject || '(no subject)')}</span>
        <span style="color:var(--muted);margin-left:auto" title="${PR.esc(m.corresponded_at || '')}">🕓 ${PR.esc(when(m))}</span></div>`).join('');
    }).catch(() => { const el = body.querySelector('#e-timeline'); if (el) el.innerHTML = '<div class="empty">Timeline unavailable.</div>'; });
    PR.notes(body.querySelector('#e-notethread'), 'person', id);
    body.querySelector('#e-save').addEventListener('click', async () => {
      await PR.guard(() => PR.api('/people/' + id, { method: 'PUT', body: {
        exact_title: body.querySelector('#e-title').value,
        public_work_email: body.querySelector('#e-email').value || null,
        email_verification_status: body.querySelector('#e-emailstatus').value,
        notes: body.querySelector('#e-notes').value || null,
      } }), 'Saved (original values preserved)');
      openPerson(id); load();
    });
    body.querySelector('#e-verifyjob').addEventListener('click', () =>
      PR.guard(() => PR.api('/jobs', { method: 'POST', body: { job_type: 'verify-person', payload: { person_id: id } } }), 'Verify job queued'));
    body.querySelector('#e-draft').addEventListener('click', async () => {
      const r = await PR.guard(() => PR.api('/outreach/draft', { method: 'POST', body: { person_id: id } }), 'Draft generated');
      location.href = '/admin/pr-intelligence/letters?message=' + r.message.id;
    });
    body.querySelector('#e-suppress').addEventListener('click', async () => {
      const reason = prompt('Suppression reason (opt_out / manual / complaint):', 'manual'); if (!reason) return;
      await PR.guard(() => PR.api('/suppression', { method: 'POST', body: { person_id: id, email: p.public_work_email, reason } }), 'Suppressed');
      drawer.close(); load();
    });
    body.querySelectorAll('[data-addletter]').forEach((a) => a.addEventListener('click', (e) => {
      e.preventDefault();
      localStorage.setItem('pr-letter-clip', JSON.stringify({ person_id: id, org_id: p.organization_id, org_name: p.organization_name, source_id: Number(a.dataset.addletter), excerpt: a.dataset.excerpt }));
      PR.toast('Added to letter clipboard');
    }));
  }

  document.getElementById('add-person').addEventListener('click', () => {
    const body = drawer.open('Add person (manual research entry)', `
      <div class="form">
        <div><label>Full name *</label><input id="f-name"></div>
        <div><label>Exact public title</label><input id="f-title"></div>
        <div><label>Organization id (from Organizations page)</label><input id="f-org" type="number"></div>
        <div class="frow"><div><label>Public work email</label><input id="f-email"></div>
          <div><label>Email status</label><select id="f-es"><option>unverified</option><option>inferred</option><option>company_site_verified</option><option>press_release_verified</option><option>manually_verified</option></select></div></div>
        <div><label>LinkedIn URL (public)</label><input id="f-li"></div>
        <div><label>Verification URL (the page you checked) *</label><input id="f-src"></div>
        <div><label>Evidence excerpt</label><textarea id="f-exc"></textarea></div>
        <button class="btn primary" id="f-save">Save person</button>
      </div>`);
    body.querySelector('#f-save').addEventListener('click', async () => {
      const name = body.querySelector('#f-name').value.trim();
      const src = body.querySelector('#f-src').value.trim();
      if (!name) return PR.toast('Name required', true);
      if (!src) return PR.toast('A verification URL is required', true);
      await PR.guard(() => PR.api('/people', { method: 'POST', body: {
        full_name: name, exact_title: body.querySelector('#f-title').value.trim() || null,
        organization_id: Number(body.querySelector('#f-org').value) || null,
        public_work_email: body.querySelector('#f-email').value.trim() || null,
        email_verification_status: body.querySelector('#f-es').value,
        linkedin_url: body.querySelector('#f-li').value.trim() || null,
        evidence: { source: { source_type: 'manual', source_name: 'Manual research entry', url: src, short_excerpt: body.querySelector('#f-exc').value.trim() || null, adapter: 'manual' },
          fields: { organization: 85, exact_title: 85, public_work_email: body.querySelector('#f-email').value ? 85 : 0 } },
      } }), 'Person saved');
      drawer.close(); load();
    });
  });
  document.getElementById('verify-sel').addEventListener('click', async () => {
    for (const id of selected) await PR.api('/jobs', { method: 'POST', body: { job_type: 'verify-person', payload: { person_id: id } } });
    PR.toast(selected.size + ' verify job(s) queued'); selected.clear(); syncSel();
  });
  document.getElementById('campaign-sel').addEventListener('click', async () => {
    const camps = (await PR.api('/campaigns')).rows;
    if (!camps.length) return PR.toast('Create a campaign first', true);
    const cid = prompt('Campaign id:\n' + camps.map((x) => `#${x.id} ${x.name}`).join('\n')); if (!cid) return;
    const r = await PR.guard(() => PR.api('/campaigns/' + cid + '/enroll', { method: 'POST', body: { person_ids: [...selected] } }));
    PR.toast(`${r.added.length} enrolled, ${r.skipped.length} skipped`); selected.clear(); syncSel();
  });
  document.getElementById('draft-sel').addEventListener('click', async () => {
    let ok = 0, failReasons = [];
    for (const id of selected) {
      try { await PR.api('/outreach/draft', { method: 'POST', body: { person_id: id } }); ok++; }
      catch (e) { failReasons.push(e.message); }
    }
    PR.toast(ok + ' draft(s) generated' + (failReasons.length ? ' · ' + failReasons.length + ' blocked: ' + failReasons[0] : ''), failReasons.length > 0);
    selected.clear(); syncSel();
  });

  const deepId = new URLSearchParams(location.search).get('id');
  await load();
  if (deepId) openPerson(Number(deepId));
})();
</script>
</body>
</html>