← back to Rentv 2026

public/admin/pr-intelligence/research.html

183 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 — Research</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('research', 'Research', 'Start California discovery waves in the mandated metro order, edit the query matrix, and import data. Arizona controls unlock only after the CA quality gate.');
  const meta = await PR.api('/meta');
  const CA = meta.states.find((s) => s.key === 'CA'), AZ = meta.states.find((s) => s.key === 'AZ');
  const dash = await PR.api('/dashboard');
  const azUnlocked = dash.arizona.unlocked;

  c.innerHTML = `
    <div class="cols2">
      <div>
        <div class="panel">
          <h3>Start a discovery run</h3>
          <div class="form">
            <div class="frow">
              <div><label>State (CA first — mandatory order)</label>
                <select id="r-state"><option value="CA">California (Phase 1)</option>
                  <option value="AZ" ${azUnlocked ? '' : 'disabled'}>Arizona (Phase 2)${azUnlocked ? '' : ' — 🔒 locked'}</option></select></div>
              <div><label>Metro</label><select id="r-metro"></select></div>
            </div>
            <div><label>Category (optional — narrows the matrix)</label>
              <select id="r-cat"><option value="">All categories</option>${meta.org_types.map((t) => `<option value="${t.key}">${t.label}</option>`).join('')}</select></div>
            <button class="btn primary" id="r-start">▶ Queue discovery run</button>
            <div style="font-size:12px;color:var(--muted)">Runs execute on the worker (<code>npm run pr:worker</code>). Registry sources (FDIC/NCUA) work with no keys; the query matrix needs a search API key (see the adapter panel).</div>
          </div>
        </div>
        <div class="panel">
          <h3>California metro order (mandated)</h3>
          ${CA.metros.map((m, i) => `<div class="gatechk"><b>${i + 1}.</b><span>${m.label}</span><span style="color:var(--muted);font-size:12px">${m.counties.join(', ')}</span>
            <button class="btn sm" style="margin-left:auto" data-metro="${m.key}">Queue</button></div>`).join('')}
        </div>
        <div class="panel">
          <h3>Arizona order ${azUnlocked ? PR.badge('unlocked', 'green') : PR.badge('🔒 locked until CA gate', 'amber')}</h3>
          ${AZ.metros.map((m, i) => `<div class="gatechk" style="${azUnlocked ? '' : 'opacity:.5'}"><b>${i + 1}.</b><span>${m.label}</span>
            ${azUnlocked ? `<button class="btn sm" style="margin-left:auto" data-metro-az="${m.key}">Queue</button>` : ''}</div>`).join('')}
        </div>
      </div>
      <div>
        <div class="panel">
          <h3>Query matrix (editable — no code changes)</h3>
          <div id="qm"><span class="spin"></span></div>
          <button class="btn sm" id="qm-add" style="margin-top:8px">+ Add query template</button>
        </div>
        <div class="panel">
          <h3>Import data</h3>
          <div class="form">
            <div class="frow">
              <div><label>Kind</label><select id="i-kind">
                <option value="org_csv">Organizations CSV</option><option value="people_csv">People CSV</option>
                <option value="contact_list">Existing contact list CSV</option><option value="linkedin_urls">LinkedIn URLs CSV</option></select></div>
              <div><label>File</label><input id="i-file" type="file" accept=".csv,text/csv"></div>
            </div>
            <div><label>Usage note (your right to use this data)</label><input id="i-note" placeholder="e.g. Our own exported contact list"></div>
            <div id="i-map"></div>
            <div class="filters">
              <button class="btn" id="i-preview" disabled>Preview + validate</button>
              <button class="btn" id="i-dry" disabled>Dry run</button>
              <button class="btn primary" id="i-run" disabled>Import</button>
            </div>
            <div id="i-out"></div>
          </div>
        </div>
        <div class="panel"><h3>Import batches (reversible)</h3><div id="i-batches"><span class="spin"></span></div></div>
      </div>
    </div>`;

  function fillMetros() {
    const st = document.getElementById('r-state').value;
    const metros = (st === 'CA' ? CA : AZ).metros;
    document.getElementById('r-metro').innerHTML = '<option value="">All metros (ordered)</option>' + metros.map((m) => `<option value="${m.key}">${m.label}</option>`).join('');
  }
  fillMetros();
  document.getElementById('r-state').addEventListener('change', fillMetros);
  document.getElementById('r-start').addEventListener('click', () => queueRun(document.getElementById('r-state').value, document.getElementById('r-metro').value || null, document.getElementById('r-cat').value || null));
  document.querySelectorAll('[data-metro]').forEach((b) => b.addEventListener('click', () => queueRun('CA', b.dataset.metro, null)));
  document.querySelectorAll('[data-metro-az]').forEach((b) => b.addEventListener('click', () => queueRun('AZ', b.dataset.metroAz, null)));
  async function queueRun(state, metro, category) {
    try {
      const r = await PR.api('/research/start', { method: 'POST', body: { state, metro, category } });
      PR.toast(r.job.deduped ? 'Identical run already queued' : 'Discovery run queued (#' + r.job.id + ') — the worker will pick it up');
    } catch (e) {
      PR.toast(e.message, true);
    }
  }

  async function loadMatrix() {
    const rows = (await PR.api('/query-templates')).rows;
    document.getElementById('qm').innerHTML = rows.map((t) => `
      <div class="gatechk"><input type="checkbox" data-en="${t.id}" ${t.enabled ? 'checked' : ''}>
        <b style="font-size:12px">${PR.esc(t.name)}</b>
        <code style="flex:1;font-size:11.5px;color:var(--muted)">${PR.esc(t.template)}</code>
        ${PR.badge(t.target)} <button class="btn sm" data-eq="${t.id}">Edit</button></div>`).join('');
    document.querySelectorAll('[data-en]').forEach((cb) => cb.addEventListener('change', async () => {
      const t = rows.find((x) => String(x.id) === cb.dataset.en);
      await PR.api('/query-templates', { method: 'POST', body: { ...t, enabled: cb.checked } });
      PR.toast((cb.checked ? 'Enabled' : 'Disabled') + ' ' + t.name);
    }));
    document.querySelectorAll('[data-eq]').forEach((b) => b.addEventListener('click', async () => {
      const t = rows.find((x) => String(x.id) === b.dataset.eq);
      const template = prompt('Query template ([metro] / [state] / [company name] slots):', t.template);
      if (template == null) return;
      await PR.guard(() => PR.api('/query-templates', { method: 'POST', body: { ...t, template } }), 'Saved');
      loadMatrix();
    }));
  }
  document.getElementById('qm-add').addEventListener('click', async () => {
    const name = prompt('Template name:'); if (!name) return;
    const template = prompt('Query ([metro] / [state] / [company name] slots):'); if (!template) return;
    await PR.guard(() => PR.api('/query-templates', { method: 'POST', body: { name, template, target: 'organization' } }), 'Added');
    loadMatrix();
  });

  // ── Import wizard ──
  let csvText = null, headers = [];
  const MAPPABLE = {
    org_csv: ['display_name', 'legal_name', 'website_url', 'organization_type', 'state', 'metro', 'county', 'asset_classes'],
    people_csv: ['full_name', 'exact_title', 'organization_name', 'organization_website', 'public_work_email', 'public_business_phone', 'linkedin_url', 'state', 'metro'],
    contact_list: ['full_name', 'exact_title', 'organization_name', 'public_work_email', 'state', 'metro'],
    linkedin_urls: ['linkedin_url', 'full_name', 'person_id'],
  };
  document.getElementById('i-file').addEventListener('change', async (e) => {
    const f = e.target.files[0]; if (!f) return;
    csvText = await f.text();
    headers = (csvText.split(/\r?\n/)[0] || '').split(',').map((h) => h.replace(/^"|"$/g, '').trim());
    const kind = document.getElementById('i-kind').value;
    document.getElementById('i-map').innerHTML = '<label style="font-size:12px;color:var(--muted)">Column mapping (target ← CSV column)</label>' +
      MAPPABLE[kind].map((t) => `<div class="frow" style="margin:2px 0"><span style="font-size:12.5px;padding-top:7px">${t}</span>
        <select data-map="${t}"><option value="">— skip —</option>${headers.map((h) => `<option ${h.toLowerCase().replace(/\s/g, '_') === t ? 'selected' : ''}>${PR.esc(h)}</option>`).join('')}</select></div>`).join('');
    ['i-preview', 'i-dry', 'i-run'].forEach((id) => document.getElementById(id).disabled = false);
  });
  function mapObj() {
    const m = {};
    document.querySelectorAll('[data-map]').forEach((s) => { if (s.value) m[s.dataset.map] = s.value; });
    return m;
  }
  document.getElementById('i-preview').addEventListener('click', async () => {
    const p = await PR.guard(() => PR.api('/import/preview', { method: 'POST', body: { csv: csvText, columnMap: mapObj(), kind: document.getElementById('i-kind').value } }));
    document.getElementById('i-out').innerHTML = `<div class="evi"><b>${p.total_rows} rows.</b> ${p.issues.length ? '<span style="color:var(--warn)">' + p.issues.length + ' issue(s):</span> ' + p.issues.slice(0, 8).map(PR.esc).join('; ') : 'No validation issues.'}</div>`;
  });
  async function runImport(dry) {
    const r = await PR.guard(() => PR.api('/import/run', { method: 'POST', body: {
      kind: document.getElementById('i-kind').value, csv: csvText, columnMap: mapObj(), dry_run: dry,
      source_note: document.getElementById('i-note').value || null,
      filename: (document.getElementById('i-file').files[0] || {}).name,
    } }), dry ? 'Dry run complete' : 'Import complete');
    const rep = r.report;
    document.getElementById('i-out').innerHTML = `<div class="evi"><b>${dry ? 'DRY RUN' : 'Batch #' + r.batch_id}</b> —
      created ${rep.created.length} · updated ${rep.updated.length} · duplicates ${rep.duplicates.length} · skipped ${rep.skipped.length} · errors ${rep.errors.length}
      ${rep.errors.length ? '<div class="x">' + rep.errors.slice(0, 5).map((e) => PR.esc(e.error)).join('; ') + '</div>' : ''}</div>`;
    loadBatches();
  }
  document.getElementById('i-dry').addEventListener('click', () => runImport(true));
  document.getElementById('i-run').addEventListener('click', () => runImport(false));

  async function loadBatches() {
    const rows = (await PR.api('/import/batches')).rows;
    document.getElementById('i-batches').innerHTML = rows.length ? rows.map((b) => `
      <div class="gatechk"><b>#${b.id}</b> ${PR.badge(b.kind)} <span>${PR.esc(b.filename || '')}</span>
        <span style="color:var(--muted);font-size:12px">${b.row_count} rows · +${b.created_count} · dup ${b.duplicate_count}${b.dry_run ? ' · DRY' : ''}</span>
        <span style="margin-left:auto;color:var(--muted);font-size:12px">${PR.dateShort(b.created_at)}</span>
        ${b.reversed_at ? PR.badge('reversed', 'red') : (b.dry_run ? '' : `<button class="btn sm danger" data-rev="${b.id}">Reverse</button>`)}</div>`).join('') : '<div class="empty">No imports yet</div>';
    document.querySelectorAll('[data-rev]').forEach((btn) => btn.addEventListener('click', async () => {
      if (!confirm('Reverse batch #' + btn.dataset.rev + '? Its created records are archived (not deleted).')) return;
      await PR.guard(() => PR.api('/import/' + btn.dataset.rev + '/reverse', { method: 'POST' }), 'Batch reversed');
      loadBatches();
    }));
  }

  loadMatrix(); loadBatches();
})();
</script>
</body>
</html>