← back to Rentv 2026
public/admin/pr-intelligence/pr-shared.js
390 lines
'use strict';
// Shared workspace runtime: frame (top bar + nav), API helper, table renderer,
// URL-persisted filters, drawer, toast, global search, keyboard nav helpers.
(function () {
const NAV = [
['', '📊', 'Dashboard'],
['research', '🔎', 'Research'],
['organizations', '🏢', 'Organizations'],
['people', '👤', 'People'],
['linkedin', '🔗', 'LinkedIn Network'],
['review', '✅', 'Review Queue'],
['__sepC', '', 'Content'],
['content-studio', '📰', 'Content Studio'],
['deal-desk', '🏢', 'Deal Desk'],
['content-drafts', '📝', 'Drafts'],
['linkedin-posting', '📣', 'LinkedIn Posting'],
['__sep1', '', 'Outreach'],
['campaigns', '📮', 'Campaigns'],
['letters', '✍️', 'Letter Builder'],
['inbox', '📥', 'Inbox'],
['tasks', '🗓️', 'Tasks'],
['__sep2', '', 'System'],
['sources', '📚', 'Sources'],
['runs', '⚙️', 'Runs & Jobs'],
['settings', '🛠️', 'Settings'],
];
window.PR = {
// ── API ──────────────────────────────────────────────────────────────────
async api(path, opts = {}) {
const r = await fetch('/api/pr' + path, {
headers: { 'Content-Type': 'application/json' },
...opts,
body: opts.body != null && typeof opts.body !== 'string' ? JSON.stringify(opts.body) : opts.body,
});
let j = null;
try { j = await r.json(); } catch { j = { ok: false, error: 'bad response (' + r.status + ')' }; }
if (!r.ok) { const e = new Error((j && j.error) || ('HTTP ' + r.status)); e.status = r.status; e.body = j; throw e; }
return j;
},
// ── Frame ────────────────────────────────────────────────────────────────
frame(active, title, lede) {
document.body.insertAdjacentHTML('afterbegin', `
<div class="pr-top">
<h1><a href="/admin/pr-intelligence">CRE PR Intelligence</a></h1><span class="tag">Admin</span>
<a href="/backend" style="font-size:12.5px">← Backend</a>
<div class="gsearch"><input id="gsearch" type="search" placeholder="Search orgs, people, emails, LinkedIn, metros… ( / )">
<div class="results" id="gsearch-results"></div></div>
</div>
<div class="pr-wrap">
<nav class="pr-nav">${NAV.map(([slug, ico, label]) =>
slug.startsWith('__sep') ? `<div class="sep">${label}</div>` :
`<a href="/admin/pr-intelligence${slug ? '/' + slug : ''}" class="${slug === active ? 'on' : ''}">${ico} ${label}</a>`).join('')}
</nav>
<main class="pr-main">
<h2>${title}</h2><p class="lede">${lede || ''}</p>
<div id="pr-content"></div>
</main>
</div>
<div id="toast"></div>`);
// global search
const inp = document.getElementById('gsearch');
const box = document.getElementById('gsearch-results');
let t = null;
inp.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(async () => {
const q = inp.value.trim();
if (q.length < 2) { box.classList.remove('open'); return; }
try {
const j = await PR.api('/search?q=' + encodeURIComponent(q));
box.innerHTML = j.groups.length ? j.groups.map((g) =>
`<div class="g">${g.type}</div>` + g.items.map((x) =>
`<a href="${x.href}"><b>${PR.esc(x.title)}</b><br><span style="color:var(--muted);font-size:12px">${PR.esc(x.sub || '')}</span></a>`).join('')
).join('') : '<div class="empty">No matches</div>';
box.classList.add('open');
} catch { box.classList.remove('open'); }
}, 220);
});
document.addEventListener('click', (e) => { if (!e.target.closest('.gsearch')) box.classList.remove('open'); });
document.addEventListener('keydown', (e) => {
if (e.key === '/' && !/input|textarea|select/i.test(document.activeElement.tagName)) { e.preventDefault(); inp.focus(); }
});
return document.getElementById('pr-content');
},
// ── Utilities ────────────────────────────────────────────────────────────
esc(s) { return String(s == null ? '' : s).replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c])); },
date(s) { if (!s) return '—'; const d = new Date(s); return isNaN(d) ? '—' : d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); },
dateShort(s) { return s ? new Date(s).toLocaleDateString(undefined, { year: '2-digit', month: 'short', day: 'numeric' }) : '—'; },
toast(msg, err) {
const t = document.getElementById('toast');
t.textContent = msg; t.className = 'show' + (err ? ' err' : '');
setTimeout(() => { t.className = ''; }, err ? 5000 : 2600);
},
async guard(fn, okMsg) {
try { const r = await fn(); if (okMsg) PR.toast(okMsg); return r; }
catch (e) { PR.toast(e.message, true); throw e; }
},
score(n) {
const cls = n >= 70 ? 'high' : n >= 40 ? 'mid' : 'low';
return `<span class="score ${cls}"><span class="bar"><i style="width:${n}%"></i></span>${n}</span>`;
},
badge(text, color) { return `<span class="b ${color || 'gray'}">${PR.esc(text)}</span>`; },
statusBadge(s) {
const map = { verified: 'green', ready_for_outreach: 'green', active_relationship: 'green', contacted: 'blue', replied: 'blue',
discovered: 'gray', needs_review: 'amber', needs_verification: 'amber', incomplete: 'amber', stale: 'amber', follow_up_due: 'amber',
duplicate: 'purple', archived: 'gray', suppressed: 'red', wrong_person: 'red', left_company: 'red', bounced: 'red', opted_out: 'red', declined: 'red',
draft_generated: 'gray', approved: 'blue', provider_draft_created: 'blue', sent: 'blue', interested: 'green', referred: 'purple', not_now: 'amber', closed: 'gray',
queued: 'gray', running: 'blue', paused: 'amber', completed: 'green', failed: 'red', cancelled: 'gray',
};
return PR.badge((s || '').replace(/_/g, ' '), map[s] || 'gray');
},
verifBadge(s) {
const map = { manually_verified: 'green', company_site_verified: 'green', press_release_verified: 'green', registry_verified: 'green',
corroborated: 'blue', search_indexed: 'amber', inferred: 'red', unverified: 'gray', stale: 'amber', invalid: 'red',
found_corroborated: 'blue', found_role_corroborated: 'purple', found_uncorroborated: 'amber', none: 'gray' };
return PR.badge((s || 'unverified').replace(/_/g, ' '), map[s] || 'gray');
},
// URL-persisted filters: read current query, write on change, call onChange.
filters(defs, onChange) {
const p = new URLSearchParams(location.search);
const state = {};
defs.forEach((d) => { state[d.key] = p.get(d.key) || d.value || ''; });
const bar = document.createElement('div'); bar.className = 'filters';
defs.forEach((d) => {
let el;
if (d.type === 'select') {
el = document.createElement('select');
(d.options || []).forEach(([v, label]) => {
const o = document.createElement('option'); o.value = v; o.textContent = label; el.appendChild(o);
});
el.value = state[d.key];
} else {
el = document.createElement('input');
el.type = d.type || 'search'; el.placeholder = d.placeholder || d.key;
el.value = state[d.key];
}
el.dataset.key = d.key;
let deb = null;
el.addEventListener(d.type === 'select' ? 'change' : 'input', () => {
clearTimeout(deb);
deb = setTimeout(() => {
state[d.key] = el.value;
const q = new URLSearchParams(location.search);
if (el.value) q.set(d.key, el.value); else q.delete(d.key);
history.replaceState(null, '', location.pathname + (q.toString() ? '?' + q : ''));
onChange(state);
}, d.type === 'select' ? 0 : 300);
});
bar.appendChild(el);
});
return { bar, state };
},
// Sortable table renderer. cols: [{key,label,render(row)}] — click header toggles sort.
// NEW optional opts:
// expand(row) → HTML string — if provided, adds a caret column and renders an
// inline expand strip beneath the row when toggled. Default undefined = off.
// Other pages that don't pass expand are completely unaffected.
// sortKey / sortDir — if provided, the table syncs ▲/▼ indicators on the matching th.
// Call tbl.setSortIndicator(key, dir) after server-side sort to keep the header in sync.
table(cols, { onSort, onRow, expand } = {}) {
const hasExpand = typeof expand === 'function';
// Prepend a caret column when expand is requested (not added for other pages)
const allCols = hasExpand
? [{ key: '_caret', label: '' }, ...cols]
: cols;
const el = document.createElement('table'); el.className = 'grid';
el.innerHTML = `<thead><tr>${allCols.map((c) => {
const cls = c.key === '_caret' ? ' class="pr-caret-col"' : '';
return `<th data-k="${c.key || ''}"${cls}>${c.label}</th>`;
}).join('')}</tr></thead><tbody></tbody>`;
let sort = null, dir = 'desc';
el.querySelectorAll('th[data-k]').forEach((th) => {
th.addEventListener('click', () => {
if (!th.dataset.k || th.dataset.k === '_caret' || !onSort) return;
dir = sort === th.dataset.k && dir === 'desc' ? 'asc' : 'desc';
sort = th.dataset.k;
// Update sort indicator classes
el.querySelectorAll('thead th').forEach((h) => h.classList.remove('pr-sort-asc', 'pr-sort-desc'));
th.classList.add(dir === 'asc' ? 'pr-sort-asc' : 'pr-sort-desc');
onSort(sort, dir);
});
});
const expandedRows = new Set(); // tracks which row indices are open
function renderRows(rows) {
const tb = el.querySelector('tbody');
if (!rows.length) {
tb.innerHTML = `<tr><td colspan="${allCols.length}"><div class="empty">Nothing here yet</div></td></tr>`;
return;
}
tb.innerHTML = '';
rows.forEach((r, i) => {
// Main data row
const tr = document.createElement('tr');
tr.dataset.i = i;
if (hasExpand) {
const isOpen = expandedRows.has(i);
const caretTd = document.createElement('td');
caretTd.className = 'pr-caret-col';
caretTd.innerHTML = `<button class="pr-caret-btn" title="Expand row" aria-expanded="${isOpen}" data-ri="${i}">${isOpen ? '▾' : '▸'}</button>`;
tr.appendChild(caretTd);
}
cols.forEach((c) => {
const td = document.createElement('td');
td.innerHTML = c.render ? c.render(r) : PR.esc(r[c.key]);
tr.appendChild(td);
});
tb.appendChild(tr);
// Expand strip row (hidden unless toggled)
if (hasExpand) {
const xtr = document.createElement('tr');
xtr.className = 'pr-expand-row' + (expandedRows.has(i) ? ' open' : '');
xtr.dataset.xi = i;
const xtd = document.createElement('td');
xtd.colSpan = allCols.length;
xtd.className = 'pr-expand-cell';
if (expandedRows.has(i)) xtd.innerHTML = expand(r);
xtr.appendChild(xtd);
tb.appendChild(xtr);
}
});
// Caret toggle handler
if (hasExpand) {
tb.querySelectorAll('.pr-caret-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const ri = Number(btn.dataset.ri);
if (expandedRows.has(ri)) expandedRows.delete(ri); else expandedRows.add(ri);
// Re-render just this row's caret + expand strip
const xtr = tb.querySelector(`.pr-expand-row[data-xi="${ri}"]`);
const mainTr = tb.querySelector(`tr[data-i="${ri}"]`);
if (!xtr || !mainTr) return;
const isOpen = expandedRows.has(ri);
btn.textContent = isOpen ? '▾' : '▸';
btn.setAttribute('aria-expanded', isOpen);
xtr.classList.toggle('open', isOpen);
if (isOpen) xtr.querySelector('.pr-expand-cell').innerHTML = expand(rows[ri]);
});
});
}
if (onRow) tb.querySelectorAll('tr[data-i]').forEach((tr) =>
tr.addEventListener('click', (e) => { if (!e.target.closest('a,button,input,select')) onRow(rows[Number(tr.dataset.i)], tr); }));
}
return {
el,
render(rows) { expandedRows.clear(); renderRows(rows); },
// Call after server-side sort to update ▲/▼ without re-fetching
setSortIndicator(key, d) {
sort = key; dir = d;
el.querySelectorAll('thead th').forEach((h) => {
h.classList.remove('pr-sort-asc', 'pr-sort-desc');
if (h.dataset.k === key) h.classList.add(d === 'asc' ? 'pr-sort-asc' : 'pr-sort-desc');
});
},
};
},
drawer() {
let d = document.querySelector('.drawer');
if (!d) {
d = document.createElement('div'); d.className = 'drawer';
d.innerHTML = `<div class="dhead"><h3 id="d-title"></h3><button class="close" title="Close (Esc)">×</button></div><div class="dbody" id="d-body"></div>`;
document.body.appendChild(d);
d.querySelector('.close').addEventListener('click', () => d.classList.remove('open'));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') d.classList.remove('open'); });
}
return {
open(title, html) { d.querySelector('#d-title').innerHTML = title; d.querySelector('#d-body').innerHTML = html; d.classList.add('open'); return d.querySelector('#d-body'); },
close() { d.classList.remove('open'); },
el: d,
};
},
pager(total, limit, offset, onPage) {
const el = document.createElement('div'); el.className = 'pager';
const pages = Math.max(1, Math.ceil(total / limit));
const cur = Math.floor(offset / limit) + 1;
el.innerHTML = `<button class="btn sm" ${cur <= 1 ? 'disabled' : ''} data-p="prev">‹ Prev</button>
<span>${total.toLocaleString()} rows · page ${cur} / ${pages}</span>
<button class="btn sm" ${cur >= pages ? 'disabled' : ''} data-p="next">Next ›</button>`;
el.querySelectorAll('button').forEach((b) => b.addEventListener('click', () =>
onPage(b.dataset.p === 'next' ? offset + limit : Math.max(0, offset - limit))));
return el;
},
// ── Current user (memoized) + client-side capability check ─────────────────
// Server-side RBAC (requireCap + the router gate) is the real enforcement; this is
// only for hiding controls the caller can't use, to avoid a guaranteed 403.
_me: null,
async me() { if (PR._me === null) { try { PR._me = await PR.api('/auth/me'); } catch { PR._me = {}; } } return PR._me; },
can(cap) {
const role = PR._me && PR._me.user && PR._me.user.role;
const caps = { admin: ['read', 'write', 'outreach', 'export', 'settings', 'integrations', 'users', 'tenants'],
developer: ['read', 'write', 'outreach', 'export', 'settings', 'integrations'],
pro: ['read', 'write', 'outreach', 'export'], user: ['read'] };
return !!(role && caps[role] && caps[role].includes(cap));
},
// ── Reusable NOTES widget ──────────────────────────────────────────────────
// Mounts a full notes thread (compose + list, author + timestamp, pin/edit/delete)
// into `el` for ANY entity: PR.notes(el, 'organization'|'person'|'property'|'deal'|…, id).
// One implementation, used by the org drawer, person drawer, deal desk, etc.
async notes(el, entityType, entityId) {
if (!el) return;
await PR.me();
const canWrite = PR.can('write');
el.innerHTML = `
<div class="pr-notes">
${canWrite ? `<div class="pr-note-compose" style="display:flex;gap:6px;margin-bottom:10px">
<textarea class="pr-note-input" rows="2" placeholder="Add a note… (⌘/Ctrl+Enter to save)"
style="flex:1;padding:6px 8px;border:1px solid var(--line);border-radius:6px;font:12.5px inherit;resize:vertical"></textarea>
<button class="btn sm pr-note-add" style="align-self:flex-start">Add note</button>
</div>` : ''}
<div class="pr-note-list"><div class="empty">Loading…</div></div>
</div>`;
const listEl = el.querySelector('.pr-note-list');
const render = (n) => {
const acts = canWrite
? `<span class="pr-note-actions" style="margin-left:8px">
<a href="#" data-act="pin">${n.pinned ? 'Unpin' : 'Pin'}</a> ·
<a href="#" data-act="edit">Edit</a> ·
<a href="#" data-act="del">Delete</a></span>` : '';
return `<div class="pr-note" data-id="${n.id}" style="border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin-bottom:8px;${n.pinned ? 'background:#fffbea' : ''}">
<div class="pr-note-body" style="white-space:pre-wrap;font-size:13px">${PR.esc(n.body)}</div>
<div class="pr-note-meta" style="color:var(--muted);font-size:11.5px;margin-top:6px" title="${PR.esc(n.created_at || '')}">
${n.pinned ? '📌 ' : ''}${PR.esc(n.author_name || '—')} · 🕓 ${PR.date(n.created_at)}${n.updated_at && n.updated_at !== n.created_at ? ' · edited ' + PR.date(n.updated_at) : ''}${acts}
</div>
</div>`;
};
async function refresh() {
try {
const { rows } = await PR.api('/notes/' + encodeURIComponent(entityType) + '/' + encodeURIComponent(entityId));
listEl.innerHTML = rows.length ? rows.map(render).join('') : '<div class="empty">No notes yet.</div>';
if (canWrite) wire(rows);
} catch (e) { listEl.innerHTML = `<div class="empty">Notes unavailable (${PR.esc(e.message)})</div>`; }
}
function wire(rows) {
listEl.querySelectorAll('.pr-note').forEach((row) => {
const id = row.dataset.id;
const n = rows.find((r) => String(r.id) === String(id));
row.querySelector('[data-act=pin]').onclick = async (e) => { e.preventDefault(); await PR.guard(() => PR.api('/notes/' + id + '/pin', { method: 'POST' })); refresh(); };
row.querySelector('[data-act=del]').onclick = async (e) => { e.preventDefault(); if (!confirm('Delete this note?')) return; await PR.guard(() => PR.api('/notes/' + id, { method: 'DELETE' }), 'Note deleted'); refresh(); };
row.querySelector('[data-act=edit]').onclick = (e) => {
e.preventDefault();
const bodyEl = row.querySelector('.pr-note-body');
bodyEl.innerHTML = `<textarea class="pr-note-edit" rows="3" style="width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:6px;font:12.5px inherit">${PR.esc(n.body)}</textarea>
<div style="margin-top:4px"><button class="btn sm pr-note-save">Save</button> <button class="btn sm ghost pr-note-cancel">Cancel</button></div>`;
bodyEl.querySelector('.pr-note-cancel').onclick = () => refresh();
bodyEl.querySelector('.pr-note-save').onclick = async () => {
const body = bodyEl.querySelector('.pr-note-edit').value.trim(); if (!body) return;
await PR.guard(() => PR.api('/notes/' + id, { method: 'PUT', body: { body } }), 'Note updated'); refresh();
};
};
});
}
if (canWrite) {
const input = el.querySelector('.pr-note-input');
const addBtn = el.querySelector('.pr-note-add');
addBtn.onclick = async () => {
const body = input.value.trim(); if (!body) return;
addBtn.disabled = true;
try { await PR.api('/notes', { method: 'POST', body: { entity_type: entityType, entity_id: entityId, body } }); input.value = ''; PR.toast('Note added'); refresh(); }
catch (e) { PR.toast(e.message, true); } finally { addBtn.disabled = false; }
};
input.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') addBtn.click(); });
}
refresh();
},
// Evidence list HTML (used by org/person drawers + review center pane)
evidenceHtml(evidence, opts = {}) {
if (!evidence || !evidence.length) return '<div class="empty">No source evidence recorded</div>';
return evidence.map((e) => `
<div class="evi">
<span class="f">${PR.esc(e.field_name)}</span> ${e.field_value ? '= ' + PR.esc(String(e.field_value).slice(0, 120)) : ''}
· confidence ${e.confidence} · ${PR.badge(e.source_type, e.is_primary_source ? 'green' : 'gray')}
${e.short_excerpt ? `<div class="x">“${PR.esc(e.short_excerpt.slice(0, 220))}”</div>` : ''}
<div>${e.url ? `<a href="${PR.esc(e.url)}" target="_blank" rel="noopener noreferrer">${PR.esc(e.url.slice(0, 90))}</a> · ` : ''}
retrieved ${PR.dateShort(e.retrieved_at)}${e.license_or_usage_note ? ` · <span title="${PR.esc(e.license_or_usage_note)}">terms ⓘ</span>` : ''}
${opts.addToLetter ? ` · <a href="#" data-addletter="${e.source_id}" data-excerpt="${PR.esc(e.short_excerpt || e.field_value || '')}">+ Add to letter</a>` : ''}</div>
</div>`).join('');
},
};
})();