← back to Rentv 2026
pr-intelligence Organizations: left-panel category rail (PR firms / builders / lenders / etc.), all columns sortable with ▲/▼, expandable inline rows, density slider, grid+list toggle, Reset — backend group filter (types=) + people/pr sort keys
d777700da8fc4b6caef2451421eba3906527e655 · 2026-08-05 13:09:44 -0700 · Steve Abrams
Files touched
M public/admin/pr-intelligence/organizations.htmlM public/admin/pr-intelligence/pr-shared.cssM public/admin/pr-intelligence/pr-shared.jsM src/pr/services/organizations.js
Diff
commit d777700da8fc4b6caef2451421eba3906527e655
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 5 13:09:44 2026 -0700
pr-intelligence Organizations: left-panel category rail (PR firms / builders / lenders / etc.), all columns sortable with ▲/▼, expandable inline rows, density slider, grid+list toggle, Reset — backend group filter (types=) + people/pr sort keys
---
public/admin/pr-intelligence/organizations.html | 424 +++++++++++++++++++++---
public/admin/pr-intelligence/pr-shared.css | 62 ++++
public/admin/pr-intelligence/pr-shared.js | 99 +++++-
src/pr/services/organizations.js | 5 +-
4 files changed, 524 insertions(+), 66 deletions(-)
diff --git a/public/admin/pr-intelligence/organizations.html b/public/admin/pr-intelligence/organizations.html
index 0c0b7eee..71944ce4 100644
--- a/public/admin/pr-intelligence/organizations.html
+++ b/public/admin/pr-intelligence/organizations.html
@@ -9,24 +9,96 @@
<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'), AZ = meta.states.find((s) => s.key === 'AZ');
+ 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);
- let sort = 'priority', dir = 'desc', offset = 0; const limit = 50;
+ // ── 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: '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']] },
+ { 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>
@@ -37,65 +109,304 @@
<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();
- const tbl = PR.table([
- { key: '_sel', label: '', render: (r) => `<input type="checkbox" data-sel="${r.id}" ${selected.has(r.id) ? 'checked' : ''}>` },
- { key: 'name', label: 'Organization', render: (r) => `<b>${PR.esc(r.display_name)}</b><br><span style="color:var(--muted);font-size:12px">${PR.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: '_ppl', label: '👤', render: (r) => String(r.people_count) },
- { 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) },
- ], {
- onSort: (s, d2) => { sort = s; dir = d2; load(); },
+
+ // ── 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) },
+ ];
+
+ // ── 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,
});
- c.appendChild(tbl.el);
- const pagerBox = document.createElement('div'); c.appendChild(pagerBox);
+ // 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 = new URLSearchParams({ ...Object.fromEntries(Object.entries(state).filter(([, v]) => v)), sort, dir, limit, offset });
- const j = await PR.api('/organizations?' + q);
- tbl.render(j.rows);
- pagerBox.innerHTML = '';
- pagerBox.appendChild(PR.pager(j.total, limit, offset, (o) => { offset = o; load(); }));
- document.getElementById('export').href = '/api/pr/export/organizations?' + q;
- tbl.el.querySelectorAll('[data-sel]').forEach((cb) => cb.addEventListener('change', () => {
+ 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) {
- const o = await PR.api('/organizations/' + 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(`${PR.esc(o.display_name)} ${PR.statusBadge(o.lifecycle_status)}`, `
+ const body = drawer.open(`${esc(o.display_name)} ${PR.statusBadge(o.lifecycle_status)}`, `
<div class="kv">
- <dt>Legal name</dt><dd>${PR.esc(o.legal_name || '—')}</dd>
- <dt>Website</dt><dd>${o.website_url ? `<a href="${PR.esc(o.website_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(o.website_url)}</a>` : '—'}</dd>
- <dt>LinkedIn</dt><dd>${o.linkedin_company_url ? `<a href="${PR.esc(o.linkedin_company_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(o.linkedin_company_url)}</a> ${PR.verifBadge(o.linkedin_status)}` : '—'}</dd>
- <dt>Category</dt><dd>${PR.badge(o.organization_type, 'blue')} ${PR.esc(o.organization_subtype || '')}</dd>
+ <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>${PR.esc(o.headquarters || '—')}</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="${PR.esc(o.press_page_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(o.press_page_url.slice(0, 70))}</a>` : '—'}</dd>
- <dt>Press email</dt><dd>${PR.esc(o.general_press_email || '—')} ${o.general_press_phone ? '· ' + PR.esc(o.general_press_phone) : ''}</dd>
- <dt>Contact form</dt><dd>${o.contact_form_url ? `<a href="${PR.esc(o.contact_form_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(o.contact_form_url.slice(0, 70))}</a>` : '—'}</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">${PR.esc(JSON.stringify(comp, null, 1))}</pre></details></dd>
+ <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="${PR.esc(o.created_at)}">🕓 ${PR.date(o.created_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>
@@ -105,22 +416,22 @@
</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>${PR.esc(p.full_name)}</b>
- <span style="color:var(--muted)">${PR.esc(p.exact_title || '')}</span>
+ ${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)}
<a href="/admin/pr-intelligence/people?id=${p.id}" style="margin-left: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>${PR.esc(r.source_name_org)} → ${PR.esc(r.target_name_org)}</span>
+ <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>${PR.esc(m.subject || '(no subject)')}</span>
+ ${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">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>${PR.esc(a.activity)}</span><span style="color:var(--muted)">${PR.esc(a.actor || '')}</span></div>`).join('')}`);
+ <span>${esc(a.activity)}</span><span style="color:var(--muted)">${esc(a.actor || '')}</span></div>`).join('')}`);
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'));
@@ -130,8 +441,8 @@
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>${PR.esc(m.org.display_name)}</b> ${PR.badge(m.verdict, m.verdict === 'exact' ? 'red' : 'amber')} score ${m.score}
- <div class="x">${m.reasons.map(PR.esc).join(' · ')}</div>
+ <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 () => {
@@ -147,7 +458,7 @@
}));
}
- // toolbar actions
+ // ── Toolbar actions ──────────────────────────────────────────────────────────
document.getElementById('add-org').addEventListener('click', () => {
const body = drawer.open('Add organization (manual research entry)', `
<div class="form">
@@ -162,9 +473,9 @@
</div>`);
body.querySelector('#f-save').addEventListener('click', async () => {
const name = body.querySelector('#f-name').value.trim();
- const src = body.querySelector('#f-src').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);
+ 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,
@@ -174,10 +485,12 @@
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) {
@@ -186,6 +499,7 @@
}
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);
@@ -200,7 +514,7 @@
PR.toast(n + ' people enrolled');
});
- // deep link ?id=
+ // ── Initial load + deep link ?id= ────────────────────────────────────────────
const deepId = new URLSearchParams(location.search).get('id');
await load();
if (deepId) openOrg(Number(deepId));
diff --git a/public/admin/pr-intelligence/pr-shared.css b/public/admin/pr-intelligence/pr-shared.css
index 46430e70..67f51fbd 100644
--- a/public/admin/pr-intelligence/pr-shared.css
+++ b/public/admin/pr-intelligence/pr-shared.css
@@ -122,3 +122,65 @@ table.grid tr.sel td { background:#eaf2ff; }
.gatechk { display:flex; gap:8px; align-items:baseline; padding:6px 0; border-bottom:1px solid var(--bg); font-size:13px; }
.gatechk .s { font-size:15px; }
kbd { background:#eef1f5; border-radius:4px; padding:1px 6px; font-size:11px; border:1px solid var(--line); }
+
+/* ── Additive: sort indicators on table headers (used by organizations + future pages) ── */
+table.grid th.pr-sort-asc::after { content:' ▲'; color:var(--accent); font-size:10px; }
+table.grid th.pr-sort-desc::after { content:' ▼'; color:var(--accent); font-size:10px; }
+
+/* ── Additive: expandable rows (expand option in PR.table) ── */
+.pr-caret-col { width:28px; text-align:center; padding:6px 4px !important; }
+.pr-caret-btn { background:none; border:none; cursor:pointer; color:var(--muted); font-size:13px; padding:2px 4px; line-height:1; border-radius:4px; }
+.pr-caret-btn:hover { color:var(--accent); background:var(--bg); }
+.pr-expand-row { display:none; }
+.pr-expand-row.open { display:table-row; }
+.pr-expand-cell { background:#f7faff; border-bottom:2px solid var(--line); padding:12px 16px !important; font-size:12.5px; }
+.pr-expand-cell .xstrip { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:8px 18px; }
+.pr-expand-cell .xrow { display:flex; flex-direction:column; gap:2px; }
+.pr-expand-cell .xk { font-size:10.5px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); }
+.pr-expand-cell .xv { font-size:12.5px; word-break:break-word; }
+.pr-expand-cell .xv a { color:var(--accent); }
+.pr-expand-cell .badges { display:flex; flex-wrap:wrap; gap:4px; margin-top:2px; }
+
+/* ── Additive: org page layout — left category rail + main body ── */
+.org-shell { display:grid; grid-template-columns:220px 1fr; gap:0; align-items:start; }
+.org-rail { background:#fff; border-right:1px solid var(--line); border-top:1px solid var(--line); border-radius:12px 0 0 12px; min-height:400px; padding:0 0 20px; position:sticky; top:57px; max-height:calc(100vh - 57px); overflow-y:auto; flex-shrink:0; }
+.org-rail-inner { padding:0; }
+.org-rail h4 { margin:0; font-size:10px; text-transform:uppercase; letter-spacing:.1em; color:var(--muted); padding:12px 14px 6px; }
+.org-rail-item { display:block; padding:7px 14px; font-size:13px; color:var(--ink); cursor:pointer; border:none; background:none; width:100%; text-align:left; border-left:3px solid transparent; line-height:1.3; }
+.org-rail-item:hover { background:var(--bg); color:var(--accent); }
+.org-rail-item.active { background:#eaf2ff; color:var(--accent); font-weight:600; border-left-color:var(--accent); }
+.org-rail-item .rct { float:right; font-size:10px; color:var(--muted); margin-top:2px; }
+.org-rail-sep { height:1px; background:var(--line); margin:6px 0; }
+.org-rail-group-toggle { display:flex; align-items:center; justify-content:space-between; padding:7px 14px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); cursor:pointer; border:none; background:none; width:100%; }
+.org-rail-group-toggle::after { content:'▾'; font-size:10px; transition:transform .15s; }
+.org-rail-group-toggle.collapsed::after { transform:rotate(-90deg); }
+.org-all-cats { overflow:hidden; transition:max-height .2s; }
+.org-all-cats.collapsed { max-height:0; }
+.org-main { min-width:0; }
+
+/* ── Additive: org page toolbar (density slider + view toggle) ── */
+.org-toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:0 0 10px; }
+.org-view-toggle { display:inline-flex; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
+.org-view-toggle button { background:#fff; border:none; padding:6px 12px; font-size:13px; cursor:pointer; color:var(--muted); }
+.org-view-toggle button.on { background:var(--accent); color:#fff; }
+.org-density-wrap { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--muted); }
+.org-density-wrap input[type=range] { width:80px; accent-color:var(--accent); }
+.org-row-count { margin-left:auto; font-size:12px; color:var(--muted); }
+
+/* ── Additive: org density CSS vars (applied via JS to the table wrapper) ── */
+:root { --org-row-pad:9px; --org-font:13px; }
+.org-main table.grid td { padding:var(--org-row-pad) 12px; font-size:var(--org-font); }
+.org-main table.grid th { font-size:11px; }
+
+/* ── Additive: org card grid view ── */
+.org-card-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(var(--org-card-min,280px),1fr)); gap:14px; }
+.org-card { background:#fff; border:1px solid var(--line); border-radius:12px; padding:16px 18px; cursor:pointer; transition:border-color .15s, box-shadow .15s; }
+.org-card:hover { border-color:var(--accent); box-shadow:0 4px 16px rgba(10,108,255,.09); }
+.org-card .oc-name { font-weight:700; font-size:14px; margin:0 0 2px; }
+.org-card .oc-domain { font-size:11.5px; color:var(--muted); margin:0 0 8px; }
+.org-card .oc-badges { display:flex; flex-wrap:wrap; gap:4px; margin:0 0 8px; }
+.org-card .oc-metros { font-size:11.5px; color:var(--muted); margin:0 0 8px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.org-card .oc-scores { display:flex; gap:12px; align-items:center; font-size:11.5px; color:var(--muted); }
+.org-card .oc-when { font-size:10.5px; color:var(--muted); margin-top:8px; border-top:1px solid var(--bg); padding-top:7px; }
+
+@media(max-width:900px){ .org-shell { grid-template-columns:1fr; } .org-rail { display:none; } }
diff --git a/public/admin/pr-intelligence/pr-shared.js b/public/admin/pr-intelligence/pr-shared.js
index 8d641dc2..6e291e2b 100644
--- a/public/admin/pr-intelligence/pr-shared.js
+++ b/public/admin/pr-intelligence/pr-shared.js
@@ -156,27 +156,106 @@
},
// Sortable table renderer. cols: [{key,label,render(row)}] — click header toggles sort.
- table(cols, { onSort, onRow } = {}) {
+ // 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>${cols.map((c) => `<th data-k="${c.key || ''}">${c.label}</th>`).join('')}</tr></thead><tbody></tbody>`;
+ 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 || !onSort) return;
+ 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) {
- const tb = el.querySelector('tbody');
- tb.innerHTML = rows.length ? rows.map((r, i) =>
- `<tr data-i="${i}">${cols.map((c) => `<td>${c.render ? c.render(r) : PR.esc(r[c.key])}</td>`).join('')}</tr>`).join('')
- : `<tr><td colspan="${cols.length}"><div class="empty">Nothing here yet</div></td></tr>`;
- 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); }));
+ 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');
+ });
},
};
},
diff --git a/src/pr/services/organizations.js b/src/pr/services/organizations.js
index 8934d426..0d565ec1 100644
--- a/src/pr/services/organizations.js
+++ b/src/pr/services/organizations.js
@@ -152,6 +152,9 @@ async function list(f = {}) {
if (f.metro) add(`? = ANY(metros)`, f.metro);
if (f.county) add(`? = ANY(counties)`, f.county);
if (f.type) add(`organization_type = ?`, f.type);
+ // Left-panel group rail: filter by a SET of categories at once (comma-separated), e.g.
+ // "PR Firms" = pr_agency,marketing_agency,public_affairs. Ignored if a single `type` is set.
+ if (f.types && !f.type) add(`organization_type = ANY(?)`, String(f.types).split(',').map((s) => s.trim()).filter(Boolean));
if (f.asset_class) add(`? = ANY(asset_classes)`, f.asset_class);
if (f.status) add(`lifecycle_status = ?::pr_org_status`, f.status);
if (f.min_confidence) add(`confidence_score >= ?`, Number(f.min_confidence));
@@ -162,7 +165,7 @@ async function list(f = {}) {
AND p.department IN ('communications','agency') AND p.lifecycle_status NOT IN ('duplicate','suppressed'))`);
}
if (!f.include_duplicates) where.push(`lifecycle_status <> 'duplicate'`);
- const sortable = { name: 'display_name', type: 'organization_type', status: 'lifecycle_status', confidence: 'confidence_score', priority: 'priority_score', verified: 'last_verified_at', created: 'created_at', updated: 'updated_at' };
+ const sortable = { name: 'display_name', type: 'organization_type', status: 'lifecycle_status', confidence: 'confidence_score', priority: 'priority_score', verified: 'last_verified_at', created: 'created_at', updated: 'updated_at', people: 'people_count', evidence: 'evidence_count', pr: 'internal_pr_status' };
const sort = sortable[f.sort] || 'priority_score';
const dir = f.dir === 'asc' ? 'ASC' : 'DESC';
const limit = Math.min(Number(f.limit) || 50, 500);
← 06fe8285 deploy: exclude data/deals-registry.json so deploys never cl
·
back to Rentv 2026
·
auto-save: 2026-08-05T13:12:15 (5 files) — data/deals.json d 1658be9d →