← back to Rentv 2026
public/admin/pr-intelligence/organizations.html
530 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 — Organizations</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 () {
// ── Constants ────────────────────────────────────────────────────────────────
const LS = {
view: 'prorg:view',
sort: 'prorg:sort',
dir: 'prorg:dir',
density: 'prorg:density',
rail: 'prorg:rail',
};
const LIMIT = 50;
// ── Rail group definitions ───────────────────────────────────────────────────
// Each group maps a label to a set of org_type keys sent as types=a,b,c
const RAIL_GROUPS = [
{ id: 'pr', label: 'PR Firms', types: ['pr_agency','marketing_agency','public_affairs'] },
{ id: 'builders', label: 'Builders / Developers', types: ['developer_owner','general_contractor','construction_mgmt','architecture_firm','engineering_firm'] },
{ id: 'brok', label: 'Brokerages', types: ['brokerage'] },
{ id: 'owners', label: 'Owners / Investors / REITs', types: ['developer_owner','reit','asset_property_mgmt'] },
{ id: 'lenders', label: 'Lenders / Finance', types: ['commercial_mortgage_bank','debt_equity_broker','construction_lender','bridge_lender','private_lender','cmbs_structured','life_co_lender','bank','credit_union'] },
{ id: 'title', label: 'Title & Escrow', types: ['title_insurer','title_agency','escrow_company','commercial_escrow_dept','exchange_1031'] },
{ id: 'legal', label: 'Legal / Advisory', types: ['law_firm','accounting_consulting','appraiser','insurance_risk'] },
{ id: 'media', label: 'Media / Associations', types: ['trade_publication','association','economic_development','municipal_re','proptech'] },
];
// ── Helpers ──────────────────────────────────────────────────────────────────
const esc = PR.esc;
const lsGet = (k, def) => { try { const v = localStorage.getItem(k); return v != null ? v : def; } catch { return def; } };
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch {} };
const siteName = (u) => { try { return new URL(/^https?:/.test(u) ? u : 'https://' + u).hostname.replace(/^www\./, ''); } catch { return u; } };
const web = (v) => v ? `<a href="${esc(/^https?:/.test(v) ? v : 'https://' + v)}" target="_blank" rel="noopener noreferrer">${esc(siteName(v))} ↗</a>` : '<span style="color:var(--muted)">—</span>';
// Steve HARD rule: admin cards show created date AND time with full ISO in title=
const adminDt = (s) => {
if (!s) return '—';
try {
const d = new Date(s);
if (isNaN(d)) return '—';
const t = d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
return `<span title="${esc(d.toISOString())}">🕓 ${esc(t)}</span>`;
} catch { return '—'; }
};
// ── Frame + meta ─────────────────────────────────────────────────────────────
const c = PR.frame('organizations', 'Organizations', 'Filter, sort, review, merge, enroll, export. Click a row for the full profile with source evidence.');
const meta = await PR.api('/meta');
const CA = meta.states.find((s) => s.key === 'CA') || { metros: [] };
const AZ = meta.states.find((s) => s.key === 'AZ') || { metros: [] };
// ── State ────────────────────────────────────────────────────────────────────
let sort = lsGet(LS.sort, 'priority');
let dir = lsGet(LS.dir, 'desc');
let offset = 0;
let view = lsGet(LS.view, 'list'); // 'list' | 'grid'
let railSel = lsGet(LS.rail, ''); // '' = all, 'group:id' or 'type:key'
let totalRows = 0;
let lastRows = [];
// density: 1 (compact) → 10 (comfortable). Map to row padding + font size.
const DENSITY_MIN_PAD = 4, DENSITY_MAX_PAD = 16;
const DENSITY_MIN_FONT = 12, DENSITY_MAX_FONT = 14.5;
const CARD_MIN_MIN = 220, CARD_MIN_MAX = 380;
let density = parseInt(lsGet(LS.density, '5'), 10);
if (isNaN(density) || density < 1 || density > 10) density = 5;
function applyDensity(d) {
const t = (d - 1) / 9;
const pad = Math.round(DENSITY_MIN_PAD + t * (DENSITY_MAX_PAD - DENSITY_MIN_PAD));
const font = (DENSITY_MIN_FONT + t * (DENSITY_MAX_FONT - DENSITY_MIN_FONT)).toFixed(1);
const card = Math.round(CARD_MIN_MIN + t * (CARD_MIN_MAX - CARD_MIN_MIN));
document.documentElement.style.setProperty('--org-row-pad', pad + 'px');
document.documentElement.style.setProperty('--org-font', font + 'px');
document.documentElement.style.setProperty('--org-card-min', card + 'px');
}
applyDensity(density);
// ── Build page layout ────────────────────────────────────────────────────────
// Top filter bar (existing PR.filters — keep as-is)
const { bar, state } = PR.filters([
{ key: 'q', placeholder: 'Search name / domain…' },
{ key: 'state', type: 'select', options: [['', 'State: all'], ['CA', 'California'], ['AZ', 'Arizona']] },
{ 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: 'type', type: 'select', options: [['', 'Category: all'], ...meta.org_types.map((t) => [t.key, t.label])] },
{ key: 'asset_class', type: 'select', options: [['', 'Asset class: all'], ...meta.asset_classes.map((a) => [a, a.replace(/_/g, ' ')])] },
{ key: 'status', type: 'select', options: [['', 'Status: all']].concat('discovered,needs_review,verified,incomplete,duplicate,archived,ready_for_outreach,contacted,active_relationship'.split(',').map((s) => [s, s.replace(/_/g, ' ')])) },
{ key: 'min_confidence', type: 'select', options: [['', 'Confidence: any'], ['40', '≥40'], ['70', '≥70']] },
{ key: 'min_priority', type: 'select', options: [['', 'Priority: any'], ['40', '≥40'], ['60', '≥60'], ['80', '≥80']] },
{ key: 'no_pr_contact', type: 'select', options: [['', 'PR contact: any'], ['1', 'No PR contact yet']] },
], () => { offset = 0; load(); });
c.appendChild(bar);
// Action row (bulk actions + export)
const actions = document.createElement('div'); actions.className = 'filters';
actions.innerHTML = `
<button class="btn" id="add-org">+ Add organization</button>
<button class="btn" id="verify-sel" disabled>Queue verify</button>
<button class="btn" id="tag-sel" disabled>Bulk tag</button>
<button class="btn" id="campaign-sel" disabled>Add contacts to campaign</button>
<a class="btn" id="export" href="#">⬇ Export CSV (current filters)</a>
<span id="selcount" style="color:var(--muted);font-size:12px"></span>`;
c.appendChild(actions);
// Toolbar: view toggle + density slider + row count + Reset
const toolbar = document.createElement('div'); toolbar.className = 'org-toolbar';
toolbar.innerHTML = `
<div class="org-view-toggle" id="viewtoggle" role="group" aria-label="View mode">
<button data-v="list" title="List / table view" aria-pressed="${view === 'list'}">▤ List</button>
<button data-v="grid" title="Card grid view" aria-pressed="${view === 'grid'}">▦ Grid</button>
</div>
<label class="org-density-wrap" title="Row density (List) / card width (Grid)">
Density
<input type="range" id="density-slider" min="1" max="10" step="1" value="${density}" aria-label="Display density">
</label>
<button class="btn sm" id="reset-prefs" title="Reset all saved view preferences (sort, density, view, rail selection)">↺ Reset</button>
<span class="org-row-count" id="rowcount"></span>`;
c.appendChild(toolbar);
// Sync view toggle button states
function syncViewBtns() {
toolbar.querySelectorAll('#viewtoggle button').forEach((b) => {
const on = b.dataset.v === view;
b.classList.toggle('on', on);
b.setAttribute('aria-pressed', on);
});
}
syncViewBtns();
// Density slider
const densitySlider = toolbar.querySelector('#density-slider');
densitySlider.addEventListener('input', () => {
density = parseInt(densitySlider.value, 10);
applyDensity(density);
lsSet(LS.density, density);
});
// View toggle
toolbar.querySelector('#viewtoggle').addEventListener('click', (e) => {
const b = e.target.closest('button[data-v]'); if (!b) return;
view = b.dataset.v;
lsSet(LS.view, view);
syncViewBtns();
renderView();
});
// Reset prefs
toolbar.querySelector('#reset-prefs').addEventListener('click', () => {
[LS.view, LS.sort, LS.dir, LS.density, LS.rail].forEach((k) => { try { localStorage.removeItem(k); } catch {} });
sort = 'priority'; dir = 'desc'; view = 'list'; railSel = ''; density = 5;
applyDensity(density);
densitySlider.value = density;
syncViewBtns();
syncRailActive();
// clear the type filter in the top bar
const typeEl = bar.querySelector('[data-key="type"]');
if (typeEl) { typeEl.value = ''; state.type = ''; }
tbl.setSortIndicator(sort, dir);
offset = 0; load();
});
// ── Shell: rail + main ───────────────────────────────────────────────────────
const shell = document.createElement('div'); shell.className = 'org-shell';
// -- LEFT RAIL --
const railEl = document.createElement('aside');
railEl.className = 'org-rail'; railEl.setAttribute('aria-label', 'Category filter');
// Build rail groups HTML
let railHtml = `<div class="org-rail-inner">
<h4>Quick filter</h4>
<button class="org-rail-item${railSel === '' ? ' active' : ''}" data-rail="" aria-current="${railSel === '' ? 'true' : 'false'}">All organizations</button>`;
RAIL_GROUPS.forEach((g) => {
const active = railSel === 'group:' + g.id;
railHtml += `<button class="org-rail-item${active ? ' active' : ''}" data-rail="group:${g.id}" title="${esc(g.types.join(', '))}" aria-current="${active ? 'true' : 'false'}">${esc(g.label)}</button>`;
});
railHtml += `<div class="org-rail-sep"></div>
<button class="org-rail-group-toggle collapsed" id="allcats-toggle" aria-expanded="false" aria-controls="allcats">All categories</button>
<div class="org-all-cats collapsed" id="allcats">`;
meta.org_types.forEach((t) => {
const active = railSel === 'type:' + t.key;
railHtml += `<button class="org-rail-item${active ? ' active' : ''}" data-rail="type:${esc(t.key)}" aria-current="${active ? 'true' : 'false'}">${esc(t.label)}</button>`;
});
railHtml += `</div></div>`;
railEl.innerHTML = railHtml;
// All categories expand/collapse
railEl.querySelector('#allcats-toggle').addEventListener('click', () => {
const tog = railEl.querySelector('#allcats-toggle');
const cats = railEl.querySelector('#allcats');
const collapsed = cats.classList.toggle('collapsed');
tog.classList.toggle('collapsed', collapsed);
tog.setAttribute('aria-expanded', !collapsed);
});
// Rail item click
railEl.addEventListener('click', (e) => {
const btn = e.target.closest('[data-rail]');
if (!btn || e.target.closest('#allcats-toggle')) return;
railSel = btn.dataset.rail;
lsSet(LS.rail, railSel);
syncRailActive();
// Sync the top-bar "type" dropdown where possible (single-type rail items)
const typeEl = bar.querySelector('[data-key="type"]');
if (typeEl) {
if (railSel.startsWith('type:')) {
const key = railSel.slice(5);
typeEl.value = key; state.type = key;
} else {
// group or all: clear the single-type filter (the types= param handles it)
typeEl.value = ''; state.type = '';
}
}
offset = 0; load();
});
function syncRailActive() {
railEl.querySelectorAll('[data-rail]').forEach((btn) => {
const on = btn.dataset.rail === railSel;
btn.classList.toggle('active', on);
btn.setAttribute('aria-current', on ? 'true' : 'false');
});
}
// -- MAIN area --
const mainEl = document.createElement('div'); mainEl.className = 'org-main';
shell.appendChild(railEl);
shell.appendChild(mainEl);
c.appendChild(shell);
// ── Selected set ─────────────────────────────────────────────────────────────
const selected = new Set();
// ── Table columns ─────────────────────────────────────────────────────────────
const COLS = [
{ key: '_sel', label: '', render: (r) => `<input type="checkbox" data-sel="${r.id}" ${selected.has(r.id) ? 'checked' : ''}>` },
{ key: 'name', label: 'Organization', render: (r) => `<b>${esc(r.display_name)}</b><br><span style="color:var(--muted);font-size:12px">${esc(r.domain || '')}</span>` },
{ key: 'type', label: 'Category', render: (r) => PR.badge(r.organization_type, 'blue') },
{ key: '_geo', label: 'Metros', render: (r) => (r.metros || []).map((m) => PR.badge(m)).join(' ') || (r.state_presence || []).join(',') },
{ key: 'pr', label: 'PR', render: (r) => r.internal_pr_status === 'has_internal_pr' ? PR.badge('internal PR', 'green') : r.general_press_email ? PR.badge('press email', 'blue') : PR.badge(r.internal_pr_status, 'gray') },
{ key: 'people', label: '👤', render: (r) => String(r.people_count || 0) },
{ key: 'confidence', label: 'Conf', render: (r) => PR.score(r.confidence_score) },
{ key: 'priority', label: 'Priority', render: (r) => PR.score(r.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) => adminDt(r.created_at) },
];
// ── Expand renderer (inline detail strip) ───────────────────────────────────
function expandRow(r) {
const assetBadges = (r.asset_classes || []).map((a) => PR.badge(a, 'gray')).join(' ') || '<span style="color:var(--muted)">—</span>';
const tagBadges = (r.tags || []).map((t) => PR.badge(t, 'purple')).join(' ') || '<span style="color:var(--muted)">—</span>';
const metros = (r.metros || []).join(', ') || '—';
return `<div class="xstrip">
<div class="xrow"><span class="xk">Website</span><span class="xv">${web(r.website_url)}</span></div>
<div class="xrow"><span class="xk">Press email</span><span class="xv">${r.general_press_email ? `<a href="mailto:${esc(r.general_press_email)}">${esc(r.general_press_email)}</a>` : '<span style="color:var(--muted)">—</span>'}</span></div>
<div class="xrow"><span class="xk">Press page</span><span class="xv">${web(r.press_page_url)}</span></div>
<div class="xrow"><span class="xk">Asset classes</span><span class="xv"><div class="badges">${assetBadges}</div></span></div>
<div class="xrow"><span class="xk">All metros</span><span class="xv">${esc(metros)}</span></div>
<div class="xrow"><span class="xk">Confidence</span><span class="xv">${PR.score(r.confidence_score)}</span></div>
<div class="xrow"><span class="xk">Priority</span><span class="xv">${PR.score(r.priority_score)}</span></div>
<div class="xrow"><span class="xk">Tags</span><span class="xv"><div class="badges">${tagBadges}</div></span></div>
<div class="xrow"><span class="xk">People</span><span class="xv">${r.people_count || 0} · Evidence: ${r.evidence_count || 0}</span></div>
<div class="xrow"><span class="xk">Created</span><span class="xv">${adminDt(r.created_at)}</span></div>
</div>`;
}
// ── Table (list view) ────────────────────────────────────────────────────────
const tbl = PR.table(COLS, {
onSort: (s, d) => {
sort = s; dir = d;
lsSet(LS.sort, sort); lsSet(LS.dir, dir);
load();
},
onRow: (r) => openOrg(r.id),
expand: expandRow,
});
// Set initial sort indicator
tbl.setSortIndicator(sort, dir);
// ── Card grid view ────────────────────────────────────────────────────────────
const gridEl = document.createElement('div'); gridEl.className = 'org-card-grid';
function renderCards(rows) {
gridEl.innerHTML = rows.length ? rows.map((r) => {
const metroStr = (r.metros || []).slice(0, 3).join(', ') + ((r.metros || []).length > 3 ? ` +${r.metros.length - 3}` : '');
return `<div class="org-card" data-id="${r.id}" tabindex="0" role="button" aria-label="${esc(r.display_name)}">
<div class="oc-name">${esc(r.display_name)}</div>
<div class="oc-domain">${esc(r.domain || r.website_url || '')}</div>
<div class="oc-badges">
${PR.badge(r.organization_type, 'blue')}
${r.internal_pr_status === 'has_internal_pr' ? PR.badge('internal PR', 'green') : r.general_press_email ? PR.badge('press email', 'blue') : ''}
${PR.statusBadge(r.lifecycle_status)}
</div>
<div class="oc-metros">${esc(metroStr) || '<span style="color:var(--muted)">No metro</span>'}</div>
<div class="oc-scores">
Conf: ${PR.score(r.confidence_score)} Pri: ${PR.score(r.priority_score)}
</div>
<div class="oc-when" title="${esc(r.created_at || '')}">${adminDt(r.created_at)}</div>
</div>`;
}).join('') : '<div class="empty">Nothing here yet</div>';
gridEl.querySelectorAll('.org-card').forEach((card) => {
const handler = (e) => {
if (!e.target.closest('a,button,input,select')) openOrg(Number(card.dataset.id));
};
card.addEventListener('click', handler);
card.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handler(e); } });
});
}
// Containers for both views, placed inside mainEl
const listWrap = document.createElement('div'); listWrap.id = 'org-list-wrap'; listWrap.appendChild(tbl.el);
const gridWrap = document.createElement('div'); gridWrap.id = 'org-grid-wrap'; gridWrap.appendChild(gridEl);
mainEl.appendChild(listWrap);
mainEl.appendChild(gridWrap);
const pagerBox = document.createElement('div'); mainEl.appendChild(pagerBox);
function renderView() {
const isList = view === 'list';
listWrap.style.display = isList ? '' : 'none';
gridWrap.style.display = isList ? 'none' : '';
if (!isList) renderCards(lastRows);
}
renderView();
// ── Build query params for load ───────────────────────────────────────────────
function buildQuery() {
const params = { ...Object.fromEntries(Object.entries(state).filter(([, v]) => v)) };
params.sort = sort;
params.dir = dir;
params.limit = LIMIT;
params.offset = offset;
// Rail group → types= param (overrides state.type when a group is selected)
if (railSel.startsWith('group:')) {
const gid = railSel.slice(6);
const grp = RAIL_GROUPS.find((g) => g.id === gid);
if (grp) { params.types = grp.types.join(','); delete params.type; }
} else if (railSel.startsWith('type:')) {
params.type = railSel.slice(5);
}
return new URLSearchParams(params);
}
// ── Load ──────────────────────────────────────────────────────────────────────
async function load() {
const q = buildQuery();
let j;
try { j = await PR.api('/organizations?' + q); } catch (e) { PR.toast('Load failed: ' + e.message, true); return; }
lastRows = j.rows || [];
totalRows = j.total || 0;
// Update row count
document.getElementById('rowcount').textContent = `${totalRows.toLocaleString()} organization${totalRows === 1 ? '' : 's'}`;
if (view === 'list') { tbl.render(lastRows); } else { renderCards(lastRows); }
// Reattach checkbox listeners after table render
listWrap.querySelectorAll('[data-sel]').forEach((cb) => cb.addEventListener('change', () => {
const id = Number(cb.dataset.sel);
cb.checked ? selected.add(id) : selected.delete(id);
syncSel();
}));
// Pager
pagerBox.innerHTML = '';
pagerBox.appendChild(PR.pager(totalRows, LIMIT, offset, (o) => { offset = o; load(); }));
// Export href
document.getElementById('export').href = '/api/pr/export/organizations?' + q;
}
function syncSel() {
['verify-sel', 'tag-sel', 'campaign-sel'].forEach((id) => document.getElementById(id).disabled = !selected.size);
document.getElementById('selcount').textContent = selected.size ? selected.size + ' selected' : '';
}
// ── Drawer / org detail ──────────────────────────────────────────────────────
const drawer = PR.drawer();
async function openOrg(id) {
let o;
try { o = await PR.api('/organizations/' + id); } catch (e) { PR.toast('Could not load org: ' + e.message, true); return; }
const comp = (o.score_components || {});
const body = drawer.open(`${esc(o.display_name)} ${PR.statusBadge(o.lifecycle_status)}`, `
<div class="kv">
<dt>Legal name</dt><dd>${esc(o.legal_name || '—')}</dd>
<dt>Website</dt><dd>${o.website_url ? `<a href="${esc(o.website_url)}" target="_blank" rel="noopener noreferrer">${esc(o.website_url)}</a>` : '—'}</dd>
<dt>LinkedIn</dt><dd>${o.linkedin_company_url ? `<a href="${esc(o.linkedin_company_url)}" target="_blank" rel="noopener noreferrer">${esc(o.linkedin_company_url)}</a> ${PR.verifBadge(o.linkedin_status)}` : '—'}</dd>
<dt>Category</dt><dd>${PR.badge(o.organization_type, 'blue')} ${esc(o.organization_subtype || '')}</dd>
<dt>Asset classes</dt><dd>${(o.asset_classes || []).map((a) => PR.badge(a)).join(' ') || '—'}</dd>
<dt>Geography</dt><dd>${(o.state_presence || []).join(', ')} · ${(o.metros || []).join(', ') || 'no metro'} · ${(o.counties || []).join(', ') || 'no county'}</dd>
<dt>HQ</dt><dd>${esc(o.headquarters || '—')}</dd>
<dt>Internal PR</dt><dd>${PR.badge(o.internal_pr_status, o.internal_pr_status === 'has_internal_pr' ? 'green' : 'gray')}</dd>
<dt>Press page</dt><dd>${o.press_page_url ? `<a href="${esc(o.press_page_url)}" target="_blank" rel="noopener noreferrer">${esc(o.press_page_url.slice(0, 70))}</a>` : '—'}</dd>
<dt>Press email</dt><dd>${esc(o.general_press_email || '—')} ${o.general_press_phone ? '· ' + esc(o.general_press_phone) : ''}</dd>
<dt>Contact form</dt><dd>${o.contact_form_url ? `<a href="${esc(o.contact_form_url)}" target="_blank" rel="noopener noreferrer">${esc(o.contact_form_url.slice(0, 70))}</a>` : '—'}</dd>
<dt>Scores</dt><dd>Confidence ${PR.score(o.confidence_score)} · Priority ${PR.score(o.priority_score)}
<details style="margin-top:4px"><summary style="cursor:pointer;font-size:12px;color:var(--muted)">score components</summary>
<pre style="font-size:11px;background:var(--bg);padding:8px;border-radius:6px;overflow:auto">${esc(JSON.stringify(comp, null, 1))}</pre></details></dd>
<dt>Verification</dt><dd>${PR.verifBadge(o.verification_status)} · last verified ${PR.date(o.last_verified_at)}</dd>
<dt>Created</dt><dd title="${esc(o.created_at)}">🕓 ${PR.date(o.created_at)}</dd>
</div>
<div class="filters">
<button class="btn sm" id="d-verify">Queue website verify</button>
<button class="btn sm" id="d-people">Queue person discovery</button>
<button class="btn sm" id="d-matches">Find duplicates</button>
<a class="btn sm" href="/admin/pr-intelligence/review?kind=organizations&focus=${o.id}">Open in review</a>
</div>
<div id="d-matches-out"></div>
<h3 style="margin:14px 0 8px">People (${o.people.length})</h3>
${o.people.map((p) => `<div class="gatechk"><b>${esc(p.full_name)}</b>
<span style="color:var(--muted)">${esc(p.exact_title || '')}</span>
${PR.verifBadge(p.email_verification_status)} ${PR.statusBadge(p.lifecycle_status)}
${p.linkedin_url ? `<a href="${esc(p.linkedin_url)}" target="_blank" rel="noopener noreferrer" title="LinkedIn profile" style="margin-left:auto">🔗</a>` : ''}
<a href="/admin/pr-intelligence/people?id=${p.id}" style="margin-left:${p.linkedin_url ? '8px' : 'auto'}">open →</a></div>`).join('') || '<div class="empty">No people yet</div>'}
<h3 style="margin:14px 0 8px">Agency / parent relationships</h3>
${o.relationships.map((r) => `<div class="gatechk">${PR.badge(r.relationship_type, 'purple')}
<span>${esc(r.source_name_org)} → ${esc(r.target_name_org)}</span>
${PR.badge(r.current_status, r.current_status === 'current' ? 'green' : 'gray')}<span style="color:var(--muted)">conf ${r.confidence}</span></div>`).join('') || '<div class="empty">None recorded</div>'}
<h3 style="margin:14px 0 8px">Source evidence</h3>
${PR.evidenceHtml(o.evidence, { addToLetter: true })}
<h3 style="margin:14px 0 8px">Outreach history</h3>
${o.outreach.map((m) => `<div class="gatechk">${PR.statusBadge(m.status)} <span>${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">Notes <span style="font-weight:400;color:var(--muted);font-size:12px">(team notes — timestamped & attributed)</span></h3>
<div id="o-notethread"></div>
<h3 style="margin:14px 0 8px">Recent activity</h3>
${o.recent_activity.slice(0, 12).map((a) => `<div class="gatechk"><span style="color:var(--muted)">${PR.dateShort(a.created_at)}</span>
<span>${esc(a.activity)}</span><span style="color:var(--muted)">${esc(a.actor || '')}</span></div>`).join('')}`);
PR.notes(body.querySelector('#o-notethread'), 'organization', o.id);
body.querySelector('#d-verify').addEventListener('click', () =>
PR.guard(() => PR.api('/jobs', { method: 'POST', body: { job_type: 'verify-organization', payload: { organization_id: o.id } } }), 'Verify job queued'));
body.querySelector('#d-people').addEventListener('click', () =>
PR.guard(() => PR.api('/jobs', { method: 'POST', body: { job_type: 'discover-people', payload: { organization_id: o.id, team_pages: [] } } }), 'Person-discovery job queued'));
body.querySelector('#d-matches').addEventListener('click', async () => {
const j = await PR.guard(() => PR.api('/organizations/' + o.id + '/matches'));
const out = body.querySelector('#d-matches-out');
out.innerHTML = j.matches.length ? j.matches.map((m) => `
<div class="evi"><b>${esc(m.org.display_name)}</b> ${PR.badge(m.verdict, m.verdict === 'exact' ? 'red' : 'amber')} score ${m.score}
<div class="x">${m.reasons.map(esc).join(' · ')}</div>
<button class="btn sm" data-merge="${m.org.id}">Merge that one into this</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 "' + o.display_name + '"? The other record becomes a duplicate pointer.')) return;
await PR.guard(() => PR.api('/organizations/' + o.id + '/merge', { method: 'POST', body: { duplicate_id: Number(b.dataset.merge) } }), 'Merged');
drawer.close(); load();
}));
});
body.querySelectorAll('[data-addletter]').forEach((a) => a.addEventListener('click', (e) => {
e.preventDefault();
localStorage.setItem('pr-letter-clip', JSON.stringify({ org_id: o.id, org_name: o.display_name, source_id: Number(a.dataset.addletter), excerpt: a.dataset.excerpt }));
PR.toast('Added to letter clipboard — open the Letter Builder to insert it');
}));
}
// ── Toolbar actions ──────────────────────────────────────────────────────────
document.getElementById('add-org').addEventListener('click', () => {
const body = drawer.open('Add organization (manual research entry)', `
<div class="form">
<div><label>Display name *</label><input id="f-name"></div>
<div class="frow"><div><label>Website URL</label><input id="f-web" placeholder="https://…"></div>
<div><label>Category</label><select id="f-type">${meta.org_types.map((t) => `<option value="${t.key}">${t.label}</option>`).join('')}</select></div></div>
<div class="frow"><div><label>State</label><select id="f-state"><option>CA</option><option>AZ</option></select></div>
<div><label>Metro</label><select id="f-metro">${CA.metros.map((m) => `<option value="${m.key}">${m.label}</option>`).join('')}</select></div></div>
<div><label>Verification URL (the page you checked) *</label><input id="f-src" placeholder="https://…"></div>
<div><label>Evidence excerpt</label><textarea id="f-exc" placeholder="Short quote from the page backing this entry"></textarea></div>
<button class="btn primary" id="f-save">Save organization</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 for manual entries', true);
await PR.guard(() => PR.api('/organizations', { method: 'POST', body: {
display_name: name, website_url: body.querySelector('#f-web').value.trim() || null,
organization_type: body.querySelector('#f-type').value,
state_presence: [body.querySelector('#f-state').value], metros: [body.querySelector('#f-metro').value],
evidence: { source: { source_type: 'manual', source_name: 'Manual research entry', url: src, short_excerpt: body.querySelector('#f-exc').value.trim() || null, is_primary_source: false, adapter: 'manual' }, fields: { organization: 85 } },
} }), 'Organization 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-organization', payload: { organization_id: id } } });
PR.toast(selected.size + ' verify job(s) queued'); selected.clear(); syncSel(); load();
});
document.getElementById('tag-sel').addEventListener('click', async () => {
const tag = prompt('Tag to add to ' + selected.size + ' organization(s):'); if (!tag) return;
for (const id of selected) {
const o = await PR.api('/organizations/' + id);
await PR.api('/organizations/' + id, { method: 'PUT', body: { tags: [...new Set([...(o.tags || []), tag])] } });
}
PR.toast('Tagged'); selected.clear(); syncSel(); load();
});
document.getElementById('campaign-sel').addEventListener('click', async () => {
const camps = (await PR.api('/campaigns')).rows;
if (!camps.length) return PR.toast('Create a campaign first (Campaigns page)', true);
const cid = prompt('Campaign id to enroll each org\'s PEOPLE into:\n' + camps.map((x) => `#${x.id} ${x.name}`).join('\n'));
if (!cid) return;
let n = 0;
for (const id of selected) {
const o = await PR.api('/organizations/' + id);
const ids = o.people.map((p) => p.id);
if (ids.length) { const r = await PR.api('/campaigns/' + cid + '/enroll', { method: 'POST', body: { person_ids: ids } }); n += r.added.length; }
}
PR.toast(n + ' people enrolled');
});
// ── Initial load + deep link ?id= ────────────────────────────────────────────
const deepId = new URLSearchParams(location.search).get('id');
await load();
if (deepId) openOrg(Number(deepId));
})();
</script>
</body>
</html>