← back to Rentv 2026

public/admin/pr-intelligence/index.html

106 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 — Dashboard</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('', 'Dashboard', 'California-first CRE PR research, review, letters, and replies. Arizona unlocks after the CA quality gate.');
  c.innerHTML = '<div class="empty"><span class="spin"></span> Loading…</div>';
  let d;
  try { d = await PR.api('/dashboard'); }
  catch (e) {
    c.innerHTML = `<div class="panel"><h3>Database unavailable</h3><p>${PR.esc(e.message)}</p>
      <p>Create the database once (<code>createdb rentv_pr</code>) then run <code>npm run pr:migrate</code>.</p></div>`;
    return;
  }
  const gate = d.california.gate || {};
  c.innerHTML = `
    <div class="kpis">
      ${kcard('', d.california.completion_pct + '%', `CA coverage (metro×category cells: ${d.california.cells_covered}/${d.california.cells_total})`, '/admin/pr-intelligence/organizations?state=CA')}
      ${kcard(d.arizona.unlocked ? 'ok' : 'warn', d.arizona.unlocked ? 'ACTIVE' : '🔒 LOCKED', `Arizona phase (${d.arizona.organizations} orgs)`, '/admin/pr-intelligence/organizations?state=AZ')}
      ${kcard('', d.organizations.total.toLocaleString(), `Organizations (${d.organizations.verified} verified · ${d.organizations.unreviewed} unreviewed)`, '/admin/pr-intelligence/organizations')}
      ${kcard('', d.people.total.toLocaleString(), `People (${d.people.verified} verified · ${d.people.unverified} unverified)`, '/admin/pr-intelligence/people')}
      ${kcard(d.people.stale ? 'warn' : '', d.people.stale, 'Stale contacts', '/admin/pr-intelligence/people?status=stale')}
      ${kcard('', d.orgs_without_pr_contact, 'Orgs without a PR contact', '/admin/pr-intelligence/organizations?no_pr_contact=1')}
      ${kcard(d.drafts_awaiting_review ? 'warn' : '', d.drafts_awaiting_review, 'Drafts awaiting review', '/admin/pr-intelligence/review')}
      ${kcard(d.replies_requiring_action ? 'bad' : '', d.replies_requiring_action, 'Replies requiring action', '/admin/pr-intelligence/inbox')}
      ${kcard(d.followups_due ? 'warn' : '', d.followups_due, 'Follow-ups due', '/admin/pr-intelligence/tasks')}
      ${kcard('', d.organizations.new7 + d.people.new7, 'New discoveries (7d)', '/admin/pr-intelligence/organizations')}
    </div>

    <div class="cols3">
      <div class="panel"><h3>California quality gate ${gate.passed ? PR.badge('PASSED', 'green') : PR.badge('not passed', 'amber')}</h3>
        <div id="gate-checks">${gate.checks ? renderGate(gate.checks) : '<div class="empty">Not yet evaluated — run the check from Settings.</div>'}</div>
        <p style="margin:10px 0 0"><a class="btn sm" href="/admin/pr-intelligence/settings">Gate details & Arizona unlock →</a></p>
      </div>
      <div class="panel"><h3>Organizations by category</h3>
        ${d.by_category.slice(0, 14).map((x) => bar(x.organization_type, x.n, d.by_category[0]?.n || 1, '/admin/pr-intelligence/organizations?type=' + encodeURIComponent(x.organization_type))).join('') || '<div class="empty">No data yet</div>'}
      </div>
      <div class="panel"><h3>Contacts by role · metro</h3>
        ${d.by_role.slice(0, 7).map((x) => bar(x.department, x.n, d.by_role[0]?.n || 1, '/admin/pr-intelligence/people?department=' + encodeURIComponent(x.department))).join('') || '<div class="empty">No contacts yet</div>'}
        <hr style="border:none;border-top:1px solid var(--line);margin:10px 0">
        ${d.by_metro.slice(0, 7).map((x) => bar(x.metro, x.n, d.by_metro[0]?.n || 1, '/admin/pr-intelligence/organizations?metro=' + encodeURIComponent(x.metro))).join('') || '<div class="empty">No metro data</div>'}
      </div>
    </div>

    <div class="cols3">
      <div class="panel"><h3>Research-run health</h3>
        <div class="kv">
          <dt>Running</dt><dd>${d.run_health.running}</dd><dt>Queued</dt><dd>${d.run_health.queued} runs · ${d.run_health.queued_jobs} jobs</dd>
          <dt>Paused</dt><dd>${d.run_health.paused}</dd><dt>Failed</dt><dd>${d.run_health.failed} runs · ${d.run_health.failed_jobs} jobs</dd>
          <dt>Completed</dt><dd>${d.run_health.completed}</dd>
        </div>
        <a class="btn sm" href="/admin/pr-intelligence/runs">Runs & jobs →</a>
      </div>
      <div class="panel"><h3>Source adapters</h3>
        ${d.adapters.map((a) => `<div class="gatechk"><span class="s">${a.configured ? '🟢' : '⚪️'}</span>
          <b>${a.name}</b> <span style="color:var(--muted)">${a.kind}${a.missing_config.length ? ' · needs: ' + PR.esc(a.missing_config.join(', ')) : ''}</span></div>`).join('')}
      </div>
      <div class="panel"><h3>PR agencies & CRE clients</h3>
        ${d.pr_agencies.length ? d.pr_agencies.map((a) => `<a href="/admin/pr-intelligence/organizations?id=${a.id}" style="text-decoration:none;color:inherit;display:block" title="Open ${PR.esc(a.display_name)}"><div class="gatechk"><b>${PR.esc(a.display_name)}</b><span style="color:var(--muted)">${a.clients} linked client(s)</span></div></a>`).join('') : '<div class="empty">No agencies yet</div>'}
      </div>
    </div>

    <div class="panel"><h3>California coverage gaps ${d.coverage_gaps ? '<span style="color:var(--muted);font-weight:400;font-size:12px">generated ' + PR.date(d.coverage_gaps.generated_at) + '</span>' : ''}</h3>
      ${d.coverage_gaps ? `<p style="font-size:13px">${d.coverage_gaps.gap_count} empty metro×category cells remain. Sample:</p>
        <div>${d.coverage_gaps.sample.map((g) => `<a href="/admin/pr-intelligence/organizations?metro=${encodeURIComponent(g.metro)}&type=${encodeURIComponent(g.type)}" title="Open this empty cell in Organizations">${PR.badge(g.metro + ' · ' + g.type, 'amber')}</a>`).join(' ')}</div>`
      : '<div class="empty">No coverage report yet — queue <code>generate-coverage-report</code> from Runs & Jobs.</div>'}
    </div>`;

  // KPI card — a link when a drill target is given (every data point hrefs deeper); .kpi styles
  // the anchor as the card + grid item so layout is unchanged.
  function kcard(cls, val, label, href) {
    const inner = `<b>${val}</b><span>${label}</span>`;
    return href
      ? `<a class="kpi ${cls}" href="${href}" style="text-decoration:none;color:inherit" title="Open ${PR.esc(label)}">${inner}</a>`
      : `<div class="kpi ${cls}">${inner}</div>`;
  }
  function bar(label, n, max, href) {
    const row = `<div style="display:flex;align-items:center;gap:8px;padding:2px 0;font-size:12.5px">
      <span style="width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${PR.esc(label)}</span>
      <span class="bar" style="flex:1;height:8px;background:#eef1f5;border-radius:4px;overflow:hidden"><i style="display:block;height:100%;width:${Math.round(n / max * 100)}%;background:var(--accent)"></i></span>
      <b style="width:40px;text-align:right">${n}</b></div>`;
    // Every data point hrefs to deeper data (Steve's rule) — drill into the filtered list.
    return href ? `<a href="${href}" style="display:block;text-decoration:none;color:inherit" title="Drill into ${PR.esc(label)}">${row}</a>` : row;
  }
  function renderGate(checks) {
    const item = (ok, label, extra) => `<div class="gatechk"><span class="s">${ok ? '✅' : '❌'}</span><span>${label}${extra ? ' — ' + extra : ''}</span></div>`;
    return [
      item(checks.pipeline_resumable, 'Pipeline resumable (completed + checkpointed runs)'),
      item(checks.dedupe_working, 'Deduplication exercised'),
      item(checks.org_completeness && checks.org_completeness.pass, '≥80% reviewed CA orgs complete', checks.org_completeness ? checks.org_completeness.pct + '% of ' + checks.org_completeness.reviewed : ''),
      item(checks.contact_coverage && checks.contact_coverage.pass, '≥70% high-priority orgs have a contact', checks.contact_coverage ? checks.contact_coverage.pct + '% of ' + checks.contact_coverage.high_priority : ''),
      item(checks.workflows_exercised, 'Review / letters / outreach / opt-out flows exercised'),
      item(checks.no_mislabeled_inferred, 'No inferred contact mislabeled as sendable'),
    ].join('');
  }
})();
</script>
</body>
</html>