← 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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[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('');
    },
  };
})();