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