← back to Rentv Adintel

public/js/table.js

514 lines

/* table.js — Adjustable-columns engine for RENTV Advertiser Intelligence.
   Mirrors the broker-grid.html pattern (Steve rule 2026-07-31):
   resize + drag-reorder + toggle-visibility + persist localStorage + Reset.
   Client-side search-all (space=AND) + multi-sort + density slider + expandable rows.
   Reusable across all list pages: advertisers, contacts, ads, sources.

   Usage:
     <script src="/js/table.js"></script>
     <script>
       const T = new ATable({
         tableId: 'main-tbl',
         tbodyId: 'main-tbody',
         theadId: 'main-thead',
         countId: 'row-count',
         searchId: 'tbl-search',
         sortSelId: 'sort-sel',
         fieldsId: 'field-toggles',
         storageKey: 'advTable',      // localStorage prefix
         cols: [ { k:'company', l:'Company', t:'s', g:'Identity', def:1 }, ... ],
         data: [],                    // initial dataset
         onRowClick: (row) => {},
         renderCell: (row, col) => 'string or null for default',
       });
       T.setData(rows);
     </script>
*/

'use strict';

(function (global) {

  // ── Helpers ────────────────────────────────────────────────────────────────
  const esc = (s) => (s == null ? '' : String(s))
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

  const fmtDate = (s) => {
    if (!s) return '—';
    try {
      const d = new Date(s);
      if (isNaN(d)) return String(s);
      return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
    } catch (e) { return String(s); }
  };

  const fmtDateOnly = (s) => {
    if (!s) return '—';
    try {
      const d = new Date(s);
      if (isNaN(d)) return String(s);
      return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
    } catch (e) { return String(s); }
  };

  const tel = (v) => v ? `<a href="tel:${esc(v)}">${esc(v)}</a>` : '<span class="miss">—</span>';
  const mail = (v) => v ? `<a href="mailto:${esc(v)}">${esc(v)}</a>` : '<span class="miss">—</span>';
  const web = (v) => {
    if (!v) return '<span class="miss">—</span>';
    const h = /^https?:/.test(v) ? v : 'https://' + v;
    let label = v;
    try { label = new URL(h).hostname.replace(/^www\./, ''); } catch (_) { label = v; }
    return `<a href="${esc(h)}" target="_blank" rel="noopener noreferrer">${esc(label)}&nbsp;↗</a>`;
  };
  const li = (v) => v ? `<a href="${esc(v)}" target="_blank" rel="noopener noreferrer">LinkedIn&nbsp;↗</a>` : '<span class="miss">—</span>';

  const STATUS_BADGE_CLASS = {
    VERIFIED_ADVERTISER: 'badge-verified-advertiser',
    VERIFIED_CONFERENCE_SPONSOR: 'badge-verified-sponsor',
    VERIFIED_EXHIBITOR: 'badge-verified-exhibitor',
    VERIFIED_MEDIA_PARTNER: 'badge-verified-media',
    VERIFIED_CONTENT_PARTNER: 'badge-content-partner',
    SPEAKER_OR_PANELIST_ONLY: 'badge-panelist',
    PAST_ADVERTISER: 'badge-past-advertiser',
    LIKELY_PROSPECT: 'badge-likely-prospect',
    RESEARCH_NEEDED: 'badge-research-needed',
    DISQUALIFIED: 'badge-disqualified',
  };
  const STATUS_LABELS = {
    VERIFIED_ADVERTISER: 'Verified advertiser',
    VERIFIED_CONFERENCE_SPONSOR: 'Verified conf. sponsor',
    VERIFIED_EXHIBITOR: 'Verified exhibitor',
    VERIFIED_MEDIA_PARTNER: 'Verified media partner',
    VERIFIED_CONTENT_PARTNER: 'Content partner',
    SPEAKER_OR_PANELIST_ONLY: 'Speaker / panelist',
    PAST_ADVERTISER: 'Past advertiser',
    LIKELY_PROSPECT: 'Likely prospect',
    RESEARCH_NEEDED: 'Research needed',
    DISQUALIFIED: 'Disqualified',
  };

  function statusBadge(status) {
    const cls = STATUS_BADGE_CLASS[status] || 'badge-research-needed';
    const lbl = STATUS_LABELS[status] || status || '—';
    return `<span class="badge ${cls}">${esc(lbl)}</span>`;
  }

  function scoreBadge(score) {
    if (score == null) return '<span class="miss">—</span>';
    const n = Number(score);
    const cls = n >= 70 ? 'score-high' : n >= 40 ? 'score-mid' : 'score-low';
    return `<span class="score-badge ${cls}">${n}</span>`;
  }

  // Default cell renderer — custom renderCell overrides first.
  function defaultCell(row, col) {
    const v = row[col.k];
    switch (col.t) {
      case 'status': return statusBadge(v);
      case 'score':  return scoreBadge(v);
      case 'tel':    return tel(v);
      case 'mail':   return mail(v);
      case 'web':    return web(v);
      case 'li':     return li(v);
      case 'dt':     return `<span title="${esc(v ? new Date(v).toISOString() : '')}">${fmtDate(v)}</span>`;
      case 'date':   return fmtDateOnly(v);
      case 'n':      return `<span class="num">${v == null ? '—' : Number(v).toLocaleString()}</span>`;
      case 'link':   return v ? `<a href="${esc(v)}">${esc(col.linkLabel || v)}</a>` : '<span class="miss">—</span>';
      default:       return esc(v == null ? '—' : v);
    }
  }

  // ── ATable class ───────────────────────────────────────────────────────────

  class ATable {
    constructor(opts) {
      this.tableId   = opts.tableId;
      this.theadId   = opts.theadId;
      this.tbodyId   = opts.tbodyId;
      this.countId   = opts.countId;
      this.searchId  = opts.searchId;
      this.sortSelId = opts.sortSelId;
      this.fieldsId  = opts.fieldsId;
      this.storageKey = opts.storageKey || 'aTable';
      this.cols       = opts.cols || [];
      this.data       = opts.data || [];
      this._renderCell = opts.renderCell || null;
      this.onRowClick  = opts.onRowClick || null;
      this.expandedIds = new Set();

      // Sort state
      this.sortKey = opts.defaultSortKey || (this.cols[0] && this.cols[0].k) || '';
      this.sortDir = opts.defaultSortDir || 1;

      // Search
      this.q = '';

      // Persist column visibility + order
      this._loadPrefs();

      // Last rendered column signature (for resize invalidation)
      this._lastColSig = '';

      // Drag state
      this._dragKey = null;

      this._init();
    }

    // ── Prefs persistence ────────────────────────────────────────────────────
    _loadPrefs() {
      try {
        this._viscol  = JSON.parse(localStorage.getItem(this.storageKey + ':viscol')  || '{}');
        this._colorder = JSON.parse(localStorage.getItem(this.storageKey + ':colorder') || '[]');
        const sk = localStorage.getItem(this.storageKey + ':sortKey');
        const sd = localStorage.getItem(this.storageKey + ':sortDir');
        if (sk) this.sortKey = sk;
        if (sd === '1' || sd === '-1') this.sortDir = +sd;
      } catch (_) {
        this._viscol   = {};
        this._colorder = [];
      }
    }

    _saveViscol()   { try { localStorage.setItem(this.storageKey + ':viscol',   JSON.stringify(this._viscol)); } catch (_) {} }
    _saveColorder() { try { localStorage.setItem(this.storageKey + ':colorder', JSON.stringify(this._colorder)); } catch (_) {} }
    _saveSort()     {
      try {
        localStorage.setItem(this.storageKey + ':sortKey', this.sortKey);
        localStorage.setItem(this.storageKey + ':sortDir', String(this.sortDir));
      } catch (_) {}
    }

    // ── Column helpers ────────────────────────────────────────────────────────
    _colVis(k) {
      if (k in this._viscol) return !!this._viscol[k];
      const col = this.cols.find((c) => c.k === k);
      return col ? (col.def !== 0) : false;  // def:0 = off by default
    }

    _syncColOrder() {
      const keys = this.cols.map((c) => c.k);
      this._colorder = this._colorder.filter((k) => keys.includes(k));
      keys.forEach((k) => { if (!this._colorder.includes(k)) this._colorder.push(k); });
    }

    _orderedCols() {
      this._syncColOrder();
      return this._colorder.map((k) => this.cols.find((c) => c.k === k)).filter(Boolean);
    }

    _visCols() { return this._orderedCols().filter((c) => this._colVis(c.k)); }

    // ── Init ─────────────────────────────────────────────────────────────────
    _init() {
      this._bindSearch();
      this._bindSort();
      this._bindHeader();
    }

    _el(id) { return id ? document.getElementById(id) : null; }

    _bindSearch() {
      const el = this._el(this.searchId);
      if (!el) return;
      // Pre-fill from ?q= URL param
      const urlQ = new URLSearchParams(location.search).get('q');
      if (urlQ) { el.value = urlQ; this.q = urlQ.trim(); }
      el.addEventListener('input', () => { this.q = el.value.trim(); this.render(); });
    }

    _bindSort() {
      const sel = this._el(this.sortSelId);
      if (!sel) return;
      this._buildSortSel(sel);
      sel.value = this.sortKey;
      sel.addEventListener('change', () => {
        this.sortKey = sel.value;
        const c = this.cols.find((x) => x.k === this.sortKey);
        this.sortDir = (c && (c.t === 'n' || c.t === 'dt' || c.t === 'date')) ? -1 : 1;
        this._saveSort();
        this.render();
      });
    }

    _buildSortSel(sel) {
      if (!sel) return;
      const groups = {};
      this.cols.forEach((c) => { (groups[c.g || 'Other'] = groups[c.g || 'Other'] || []).push(c); });
      sel.innerHTML = Object.entries(groups).map(([g, cs]) =>
        `<optgroup label="${esc(g)}">` + cs.map((c) => `<option value="${esc(c.k)}">${esc(c.l)}</option>`).join('') + '</optgroup>'
      ).join('');
      if (Array.from(sel.options).some((o) => o.value === this.sortKey)) sel.value = this.sortKey;
    }

    _bindHeader() {
      const thead = this._el(this.theadId);
      if (!thead) return;

      // Sort click
      thead.addEventListener('click', (e) => {
        if (document.body.classList.contains('cr-dragging')) return;
        const th = e.target.closest('th');
        if (!th) return;
        const k = th.dataset.k;
        if (!k) return;
        if (this.sortKey === k) this.sortDir *= -1;
        else { this.sortKey = k; this.sortDir = 1; }
        this._saveSort();
        // Sync external sort select
        const sel = this._el(this.sortSelId);
        if (sel && Array.from(sel.options).some((o) => o.value === this.sortKey)) sel.value = this.sortKey;
        this.render();
      });

      // Drag-to-reorder
      let dragKey = null;
      thead.addEventListener('dragstart', (e) => {
        if (document.body.classList.contains('cr-dragging')) { e.preventDefault(); return; }
        const th = e.target.closest('th');
        if (!th) return;
        dragKey = th.dataset.k;
        e.dataTransfer.effectAllowed = 'move';
        try { e.dataTransfer.setData('text/plain', dragKey); } catch (_) {}
        th.classList.add('dragging');
      });
      thead.addEventListener('dragover', (e) => {
        if (!dragKey) return;
        const th = e.target.closest('th');
        if (!th || th.dataset.k === dragKey) return;
        e.preventDefault();
        e.dataTransfer.dropEffect = 'move';
        const r = th.getBoundingClientRect(), after = (e.clientX - r.left) > r.width / 2;
        th.classList.toggle('dropR', after); th.classList.toggle('dropL', !after);
      });
      thead.addEventListener('dragleave', (e) => {
        const th = e.target.closest('th'); if (th) { th.classList.remove('dropL', 'dropR'); }
      });
      thead.addEventListener('drop', (e) => {
        if (!dragKey) return;
        const th = e.target.closest('th'); if (!th) return;
        e.preventDefault();
        const tgt = th.dataset.k;
        if (tgt && tgt !== dragKey) {
          const r = th.getBoundingClientRect(), after = (e.clientX - r.left) > r.width / 2;
          this._syncColOrder();
          const from = this._colorder.indexOf(dragKey);
          if (from >= 0) {
            this._colorder.splice(from, 1);
            let to = this._colorder.indexOf(tgt);
            if (after) to += 1;
            this._colorder.splice(to, 0, dragKey);
            this._saveColorder();
            this._lastColSig = '';
            this.render();
          }
        }
        dragKey = null;
      });
      thead.addEventListener('dragend', () => {
        document.querySelectorAll(`#${this.theadId} th.dragging, #${this.theadId} th.dropL, #${this.theadId} th.dropR`)
          .forEach((x) => x.classList.remove('dragging', 'dropL', 'dropR'));
        dragKey = null;
      });

      // Expandable rows (tbody event delegation)
      const tbody = this._el(this.tbodyId);
      if (tbody) {
        tbody.addEventListener('click', (e) => {
          // Don't open expand on link/button clicks
          if (e.target.closest('a') || e.target.closest('button')) return;
          const tr = e.target.closest('tr[data-row-id]');
          if (!tr) return;
          const id = tr.dataset.rowId;
          const exp = tr.nextElementSibling;
          if (exp && exp.classList.contains('row-expand')) {
            exp.remove();
            this.expandedIds.delete(id);
          } else {
            // close others
            this.expandedIds.clear();
            document.querySelectorAll(`#${this.tbodyId} .row-expand`).forEach((x) => x.remove());
            this.expandedIds.add(id);
            const row = this.data.find((r) => String(r.id) === id);
            if (row && this.onRowClick) {
              const expTr = document.createElement('tr');
              expTr.className = 'row-expand';
              const td = document.createElement('td');
              td.colSpan = 999;
              td.innerHTML = '<em>Loading…</em>';
              expTr.appendChild(td);
              tr.after(expTr);
              Promise.resolve(this.onRowClick(row, td));
            }
          }
        });
      }
    }

    // ── Field-toggle panel ────────────────────────────────────────────────────
    buildFieldToggles() {
      const el = this._el(this.fieldsId);
      if (!el) return;
      const groups = {};
      this.cols.forEach((c) => { (groups[c.g || 'Other'] = groups[c.g || 'Other'] || []).push(c); });
      el.innerHTML = Object.entries(groups).map(([g, cs]) =>
        `<div class="fgrp">${esc(g)}</div>` +
        cs.map((c) => `<label class="ftog"><input type="checkbox" data-ck="${esc(c.k)}" ${this._colVis(c.k) ? 'checked' : ''}><span>${esc(c.l)}</span></label>`).join('')
      ).join('');

      el.addEventListener('change', (e) => {
        const cb = e.target.closest('input[data-ck]'); if (!cb) return;
        this._viscol[cb.dataset.ck] = cb.checked;
        this._saveViscol();
        this.render();
      });
    }

    resetColumns() {
      this._viscol   = {};
      this._colorder = [];
      this._saveViscol();
      this._saveColorder();
      this._lastColSig = '';
      // Clear column-resize localStorage keys
      try {
        Object.keys(localStorage).forEach((k) => {
          if (k.startsWith('cr:' + location.pathname + ':')) localStorage.removeItem(k);
        });
      } catch (_) {}
      this.buildFieldToggles();
      this.render();
    }

    // ── Data + filter ─────────────────────────────────────────────────────────
    setData(rows) {
      this.data = rows || [];
      this.render();
    }

    _filtered() {
      let rows = this.data.slice();

      // search-all-fields, space=AND
      if (this.q) {
        const terms = this.q.toLowerCase().split(/\s+/).filter(Boolean);
        rows = rows.filter((r) => {
          const hay = Object.keys(r).map((k) => {
            const v = r[k]; return v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
          }).join('  ').toLowerCase();
          return terms.every((t) => hay.includes(t));
        });
      }

      // sort
      const col = this.cols.find((c) => c.k === this.sortKey) || this.cols[0];
      if (col) {
        rows.sort((a, b) => {
          let x = a[col.k], y = b[col.k];
          const xE = (x == null || x === ''), yE = (y == null || y === '');
          if (xE && yE) return 0; if (xE) return 1; if (yE) return -1;
          const xn = !isNaN(+x), yn = !isNaN(+y);
          if (xn && yn) { x = +x; y = +y; }
          else { x = String(x).toLowerCase(); y = String(y).toLowerCase(); }
          if (x < y) return -this.sortDir; if (x > y) return this.sortDir; return 0;
        });
      }
      return rows;
    }

    // ── Render ────────────────────────────────────────────────────────────────
    render() {
      const rows = this._filtered();
      const cols = this._visCols();

      // count
      const countEl = this._el(this.countId);
      if (countEl) countEl.textContent = `${rows.length.toLocaleString()} of ${this.data.length.toLocaleString()} records`;

      // thead
      const thead = this._el(this.theadId);
      if (thead) {
        thead.innerHTML = '<tr>' + cols.map((c) =>
          `<th data-k="${esc(c.k)}" draggable="true" class="dragcol${this.sortKey === c.k ? (this.sortDir > 0 ? ' asc' : ' desc') : ''}">${esc(c.l)}</th>`
        ).join('') + '</tr>';
      }

      // tbody
      const tbody = this._el(this.tbodyId);
      if (tbody) {
        tbody.innerHTML = rows.map((r) => {
          const id = r.id || r.id || '';
          return `<tr data-row-id="${esc(String(id))}">` +
            cols.map((c) => {
              let html = null;
              if (this._renderCell) html = this._renderCell(r, c);
              if (html == null) html = defaultCell(r, c);
              const numCls = (c.t === 'n') ? ' class="num"' : '';
              return `<td${numCls}>${html}</td>`;
            }).join('') +
            '</tr>';
        }).join('');
      }

      // column-sig check for resize invalidation
      const sig = cols.map((c) => c.k).join(',');
      if (sig !== this._lastColSig) {
        this._lastColSig = sig;
        // Invalidate ColResize if present
        if (window.ColResize && window.ColResize.refresh) window.ColResize.refresh();
      }

      // sync field toggles visibility
      this.buildFieldToggles();
    }
  }

  // ── Density slider helper ────────────────────────────────────────────────────
  function initDensitySlider(sliderId, storageKey, minPx, maxPx, defaultVal) {
    const sl = document.getElementById(sliderId);
    if (!sl) return;
    minPx = minPx || 200; maxPx = maxPx || 450;
    const stored = parseInt(localStorage.getItem(storageKey + ':density'), 10);
    const val = (stored >= 1 && stored <= 10) ? stored : (defaultVal || 5);
    sl.value = val;
    function apply(v) {
      const px = Math.round(maxPx - (Math.max(1, Math.min(10, v)) - 1) * (maxPx - minPx) / 9);
      document.documentElement.style.setProperty('--card-min', px + 'px');
    }
    apply(val);
    sl.addEventListener('input', () => { apply(+sl.value); localStorage.setItem(storageKey + ':density', sl.value); });
  }

  // ── Export helpers ────────────────────────────────────────────────────────────
  function exportCSV(rows, cols, filename) {
    const lines = [
      cols.map((c) => '"' + c.l.replace(/"/g, '""') + '"').join(','),
      ...rows.map((r) => cols.map((c) => {
        const v = r[c.k]; const s = v == null ? '' : String(v);
        return '"' + s.replace(/"/g, '""') + '"';
      }).join(','))
    ];
    const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = filename || 'export.csv';
    a.click();
    URL.revokeObjectURL(a.href);
  }

  // Expose globally
  global.ATable            = ATable;
  global.initDensitySlider = initDensitySlider;
  global.tblExportCSV      = exportCSV;
  global.statusBadge       = statusBadge;
  global.scoreBadge        = scoreBadge;
  global.tblEsc            = esc;
  global.tblFmtDate        = fmtDate;
  global.tblFmtDateOnly    = fmtDateOnly;
  global.tblTel            = tel;
  global.tblMail           = mail;
  global.tblWeb            = web;
  global.tblLi             = li;

}(window));