[object Object]

← back to Rentv

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

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)} &nbsp; 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  ·  auto-save: 2026-08-05T13:12:15 (5 files) — data/deals.json d 1658be9d →