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