← back to Rentv 2026
public/admin/pr-intelligence/letters.html
231 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 — Letter Builder</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('letters', 'Letter Builder', 'Block-based base letter with locked sections, immutable versions, vertical snippets, grounded personalization, and desktop/mobile preview. Nothing here sends email.');
let tpl, versionBlocks = [], subject = '', versionsList = [];
c.innerHTML = `
<div class="cols2">
<div>
<div class="panel">
<h3>Base letter <span id="l-vinfo" style="font-weight:400;color:var(--muted);font-size:12px"></span></h3>
<div style="margin:0 0 10px"><label style="font-size:12px;color:var(--muted)">Subject template</label>
<input id="l-subject" style="width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:8px"></div>
<div class="blocks" id="l-blocks"></div>
<div class="filters">
<button class="btn sm" id="l-addblock">+ Add block</button>
<input id="l-note" placeholder="Change note…" style="flex:1;padding:7px 10px;border:1px solid var(--line);border-radius:8px">
<button class="btn sm primary" id="l-save">Save as new version</button>
</div>
<div id="l-clip"></div>
</div>
<div class="panel">
<h3>Version history & compare</h3>
<div id="l-versions"></div>
</div>
<div class="panel">
<h3>Vertical & saved snippets</h3>
<div id="l-snippets"><span class="spin"></span></div>
<div class="filters" style="margin-top:8px">
<button class="btn sm" id="s-add">+ New snippet</button>
</div>
</div>
</div>
<div>
<div class="panel">
<h3>Preview with a real contact</h3>
<div class="filters">
<input id="p-person" type="number" placeholder="Person id" style="width:110px">
<button class="btn sm" id="p-run">Render preview</button>
<button class="btn sm" id="p-mode-toggle">📱 Mobile view</button>
</div>
<div style="font-size:12px;color:var(--muted)" id="p-subject"></div>
<div id="p-out" class="letter-preview" style="margin-top:8px"><div class="empty">Enter a person id and render — merge fields fill from the live record + settings; unfilled ones stay visibly {{placeholders}}.</div></div>
<div id="p-missing" style="font-size:12px;color:var(--warn);margin-top:6px"></div>
<div id="p-citations" style="margin-top:10px"></div>
</div>
<div class="panel">
<h3>Drafts awaiting review</h3>
<div id="l-drafts"><span class="spin"></span></div>
</div>
</div>
</div>`;
async function loadTemplate() {
const templates = (await PR.api('/letters/templates')).rows;
tpl = templates.find((t) => t.is_base) || templates[0];
if (!tpl) { c.innerHTML = '<div class="panel">No base template found — run migrations.</div>'; return; }
const versions = await PR.api('/letters/templates/' + tpl.id + '/versions');
versionsList = versions.rows;
const cur = await PR.api('/letters/versions/' + versionsList[0].id);
versionBlocks = (cur.blocks || []).map((b) => ({ ...b }));
subject = cur.subject_template || '';
document.getElementById('l-subject').value = subject;
document.getElementById('l-vinfo').textContent = `v${cur.version} · purpose: ${tpl.purpose ? tpl.purpose.slice(0, 90) + '…' : '(configurable in Settings → letter_main_purpose)'}`;
renderBlocks(); renderVersions();
}
function renderBlocks() {
const box = document.getElementById('l-blocks');
box.innerHTML = versionBlocks.sort((a, b) => (a.order || 0) - (b.order || 0)).map((b, i) => `
<div class="blk ${b.locked ? 'locked' : ''}" data-i="${i}">
<div class="bh"><b>${PR.esc(b.label || b.key)}</b>
<span>${PR.esc(b.key)}</span>
${b.locked ? '<span class="b amber">🔒 locked</span>' : ''}
<span style="margin-left:auto"></span>
<button class="btn sm" data-act="up" title="Move up">↑</button>
<button class="btn sm" data-act="down" title="Move down">↓</button>
<button class="btn sm" data-act="lock">${b.locked ? 'Unlock' : 'Lock'}</button>
<button class="btn sm danger" data-act="del" ${b.locked ? 'disabled' : ''}>✕</button></div>
<textarea data-i="${i}" ${b.locked ? 'readonly' : ''}>${PR.esc(b.content)}</textarea>
</div>`).join('');
box.querySelectorAll('textarea').forEach((t) => t.addEventListener('input', () => { versionBlocks[Number(t.dataset.i)].content = t.value; }));
box.querySelectorAll('button[data-act]').forEach((btn) => btn.addEventListener('click', () => {
const i = Number(btn.closest('.blk').dataset.i);
const b = versionBlocks[i];
if (btn.dataset.act === 'lock') {
if (b.locked && !confirm('Unlock "' + b.key + '"? Unlocking is audit-logged on the next save.')) return;
b.locked = !b.locked; b._unlocked = !b.locked ? b._unlocked : true;
}
if (btn.dataset.act === 'del') { if (!confirm('Remove block "' + b.key + '"?')) return; versionBlocks.splice(i, 1); }
if (btn.dataset.act === 'up' && i > 0) { const o = b.order; b.order = versionBlocks[i - 1].order; versionBlocks[i - 1].order = o; }
if (btn.dataset.act === 'down' && i < versionBlocks.length - 1) { const o = b.order; b.order = versionBlocks[i + 1].order; versionBlocks[i + 1].order = o; }
renderBlocks();
}));
renderClip();
}
function renderClip() {
const clip = localStorage.getItem('pr-letter-clip');
const el = document.getElementById('l-clip');
if (!clip) { el.innerHTML = ''; return; }
const j = JSON.parse(clip);
el.innerHTML = `<div class="evi" style="border-color:var(--accent)"><b>📎 Evidence clipboard</b> (from ${PR.esc(j.org_name || 'record')} · source #${j.source_id})
<div class="x">“${PR.esc((j.excerpt || '').slice(0, 200))}”</div>
<button class="btn sm" id="clip-insert">Insert as org-specific snippet</button>
<button class="btn sm" id="clip-clear">Dismiss</button></div>`;
el.querySelector('#clip-insert').addEventListener('click', async () => {
await PR.guard(() => PR.api('/letters/blocks', { method: 'POST', body: {
label: 'Org fact: ' + (j.org_name || j.org_id), scope: 'organization', organization_id: j.org_id,
content: j.excerpt || '', internal_sources: [j.source_id],
} }), 'Saved as an organization-specific snippet (with its internal source)');
localStorage.removeItem('pr-letter-clip'); renderClip(); loadSnippets();
});
el.querySelector('#clip-clear').addEventListener('click', () => { localStorage.removeItem('pr-letter-clip'); renderClip(); });
}
document.getElementById('l-addblock').addEventListener('click', () => {
const key = prompt('Block key (e.g. market_paragraph):'); if (!key) return;
versionBlocks.push({ key, label: key.replace(/_/g, ' '), content: '', order: versionBlocks.length + 1, locked: false });
renderBlocks();
});
document.getElementById('l-save').addEventListener('click', async () => {
const unlock = versionBlocks.filter((b) => b._unlocked).map((b) => b.key);
await PR.guard(() => PR.api('/letters/templates/' + tpl.id + '/versions', { method: 'POST', body: {
subject_template: document.getElementById('l-subject').value,
blocks: versionBlocks.map(({ _unlocked, ...b }) => b),
change_note: document.getElementById('l-note').value || null,
unlock,
} }), 'New version saved');
document.getElementById('l-note').value = '';
loadTemplate();
});
function renderVersions() {
document.getElementById('l-versions').innerHTML = versionsList.map((v) => `
<div class="gatechk"><b>v${v.version}</b><span style="color:var(--muted)">${PR.date(v.created_at)} · ${PR.esc(v.created_by || '')}</span>
<span style="flex:1;color:var(--muted);font-size:12px">${PR.esc(v.change_note || '')}</span>
<button class="btn sm" data-restore="${v.id}">Load</button>
${versionsList[0].id !== v.id ? `<button class="btn sm" data-cmp="${v.id}">Compare→current</button>` : PR.badge('current', 'green')}</div>`).join('');
document.querySelectorAll('[data-restore]').forEach((b) => b.addEventListener('click', async () => {
const v = await PR.api('/letters/versions/' + b.dataset.restore);
versionBlocks = (v.blocks || []).map((x) => ({ ...x })); subject = v.subject_template || '';
document.getElementById('l-subject').value = subject;
PR.toast('Loaded v' + v.version + ' into the editor — save to make it the new current version (undo = load a prior version)');
renderBlocks();
}));
document.querySelectorAll('[data-cmp]').forEach((b) => b.addEventListener('click', async () => {
const j = await PR.api('/letters/compare?a=' + b.dataset.cmp + '&b=' + versionsList[0].id);
alert(`v${j.from} → v${j.to}\nAdded: ${j.added.join(', ') || '—'}\nRemoved: ${j.removed.join(', ') || '—'}\nChanged: ${j.changed.join(', ') || '—'}\nSubject changed: ${j.subject_changed}`);
}));
}
async function loadSnippets() {
const rows = (await PR.api('/letters/blocks')).rows;
document.getElementById('l-snippets').innerHTML = rows.length ? rows.map((s) => `
<div class="gatechk">${PR.badge(s.scope, s.scope === 'vertical' ? 'blue' : s.scope === 'organization' ? 'purple' : 'gray')}
<b>${PR.esc(s.label)}</b><span style="color:var(--muted);font-size:12px">${PR.esc(s.org_type || '')}</span>
${(s.internal_sources || []).length ? `<span class="b green" title="grounded in stored sources">${s.internal_sources.length} src</span>` : ''}
<button class="btn sm" style="margin-left:auto" data-edit="${s.id}">Edit</button></div>`).join('') : '<div class="empty">No snippets</div>';
document.querySelectorAll('[data-edit]').forEach((b) => b.addEventListener('click', async () => {
const s = rows.find((x) => String(x.id) === b.dataset.edit);
const content = prompt('Edit snippet content:', s.content); if (content == null) return;
await PR.guard(() => PR.api('/letters/blocks', { method: 'POST', body: { ...s, content } }), 'Snippet saved');
loadSnippets();
}));
}
document.getElementById('s-add').addEventListener('click', async () => {
const label = prompt('Snippet label:'); if (!label) return;
const content = prompt('Snippet content ({{merge_fields}} allowed):'); if (content == null) return;
await PR.guard(() => PR.api('/letters/blocks', { method: 'POST', body: { label, content, scope: 'generic' } }), 'Snippet created');
loadSnippets();
});
// Preview
let mobile = false, lastPrev = null;
document.getElementById('p-mode-toggle').addEventListener('click', () => { mobile = !mobile; drawPreview(); });
document.getElementById('p-run').addEventListener('click', async () => {
const pid = Number(document.getElementById('p-person').value);
if (!pid) return PR.toast('Enter a person id (People page shows ids in the URL)', true);
lastPrev = await PR.guard(() => PR.api('/letters/preview', { method: 'POST', body: {
person_id: pid, blocks: versionBlocks, subject_template: document.getElementById('l-subject').value,
} }));
drawPreview();
});
function drawPreview() {
if (!lastPrev) return;
document.getElementById('p-subject').innerHTML = 'Subject: <b style="color:var(--ink)">' + PR.esc(lastPrev.subject) + '</b>';
const html = PR.esc(lastPrev.text).replace(/\{\{([a-z0-9_]+)\}\}/gi, '<span class="missing">{{$1}}</span>');
document.getElementById('p-out').outerHTML = mobile
? `<div id="p-out" class="mobile-frame"><div class="letter-preview" style="border:none;border-radius:0;font-size:13px">${html}</div></div>`
: `<div id="p-out" class="letter-preview">${html}</div>`;
document.getElementById('p-missing').textContent = lastPrev.missing.length
? '⚠ Unfilled merge fields (complete them in Settings before this letter can send): ' + lastPrev.missing.join(', ') : '';
document.getElementById('p-citations').innerHTML = lastPrev.citations.length
? '<b style="font-size:12px">Internal citations (kept on the admin record, stripped from outgoing email):</b> ' +
lastPrev.citations.map((x) => PR.badge('src #' + x.source_id + ' → ' + x.block, 'purple')).join(' ')
: '';
}
async function loadDrafts() {
const j = await PR.api('/outreach?status=draft_generated&limit=15');
const j2 = await PR.api('/outreach?status=needs_review&limit=15');
const rows = [...j.rows, ...j2.rows];
document.getElementById('l-drafts').innerHTML = rows.length ? rows.map((m) => `
<div class="gatechk">${PR.statusBadge(m.status)} <b>${PR.esc(m.full_name || '')}</b>
<span style="color:var(--muted)">${PR.esc(m.organization_name || '')}</span>
<span style="margin-left:auto"></span>
<button class="btn sm" data-approve="${m.id}">Approve</button>
<button class="btn sm" data-pd="${m.id}">Provider draft</button></div>`).join('') : '<div class="empty">No drafts awaiting review</div>';
document.querySelectorAll('[data-approve]').forEach((b) => b.addEventListener('click', () =>
PR.guard(() => PR.api('/outreach/' + b.dataset.approve + '/approve', { method: 'POST' }), 'Approved').then(loadDrafts)));
document.querySelectorAll('[data-pd]').forEach((b) => b.addEventListener('click', () =>
PR.guard(() => PR.api('/outreach/' + b.dataset.pd + '/provider-draft', { method: 'POST' }), 'Provider draft created — send stays a separate explicit click in the Inbox').then(loadDrafts)));
}
await loadTemplate();
loadSnippets();
loadDrafts();
})();
</script>
</body>
</html>