← back to Commercialrealestate

public/row-notes.js

161 lines

/* row-notes.js — CRCP list rows: click a row (or its 📝 icon) to expand ONE consolidated detail
 * panel with the property's full details, LA County assessor/sold/permit history, and PRIVATE
 * per-user notes. Notes save to the SERVER per-user (/api/agent-notes, key "prop:<id>") so they
 * SYNC across every device the user signs in on — NOT localStorage. Single click handler (no
 * competing overlay). (Steve 8/20; server-notes + 📝 icon + history merge per DTD verdict A.) */
(function () {
  'use strict';
  var NOTE_KEY = function (id) { return 'prop:' + id; };
  var OPEN = new Set();
  var NOTES = null;                                        // server cache {key:{note,updated_at}}, null until loaded
  var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); };
  var money = function (n) { return (n == null || n === '') ? '—' : '$' + Number(n).toLocaleString(); };

  function loadNotes() {
    return fetch('/api/agent-notes', { headers: { Accept: 'application/json' } })
      .then(function (r) { return r.ok ? r.json() : { notes: {} }; })
      .then(function (j) { NOTES = j.notes || {}; return NOTES; })
      .catch(function () { NOTES = {}; return NOTES; });
  }
  // DATA is a top-level `let` in index.html's inline script — reachable by bare name from this
  // deferred classic script at click time (runs after the DOM + that inline script).
  var findProp = function (id) { try { return ((typeof DATA !== 'undefined' && DATA && DATA.ranked) || []).find(function (x) { return String(x.id) === String(id); }); } catch (_) { return null; } };

  function rowFor(el) { return el.closest('.crow[data-id], .kvcard[data-id], .listtbl tr[data-id], tr.mrow[data-addr], .card[data-id], .lead[data-id]'); }
  function isInteractive(el) { return !!el.closest('a, button:not(.rn-badge), input, textarea, select, label, [onclick], .lcbtn, .histbtn, .hist, .cm-star, .chip'); }
  function rowId(row) { return row.getAttribute('data-id') || row.getAttribute('data-addr') || ''; }

  // Detail pairs — prefer the real property object; fall back to consolidating cell text.
  function detailPairs(row, p) {
    if (p) {
      var ppu = (p.price && p.units) ? Math.round(p.price / p.units) : null;
      var ppsf = (p.price && p.sqft) ? Math.round(p.price / p.sqft) : null;
      var f = p.finance || {};
      return [
        ['Type', p.type], ['Price', money(p.price)], ['Units', p.units],
        ['$/Unit', ppu ? money(ppu) : null], ['Sqft', p.sqft ? Number(p.sqft).toLocaleString() : null], ['$/SF', ppsf ? money(ppsf) : null],
        ['Cap rate', (p.cap_rate != null && p.cap_rate !== '') ? p.cap_rate + '%' : null], ['Cash-on-cash', (f.coc != null) ? f.coc + '%' : null],
        ['Year built', p.year_built], ['Status', p.status], ['Deal score', (p.deal && p.deal.score != null) ? p.deal.score : null],
        ['Firm', p.firm || p.broker_firm], ['Broker phone', p.broker_phone], ['Agent', (p.broker_agents || []).join(', ') || null], ['Zip', p.zip]
      ].filter(function (r) { return r[1] != null && r[1] !== ''; });
    }
    var out = [], tbl = row.closest('table'), ths = [];
    if (tbl) tbl.querySelectorAll('thead th').forEach(function (th) { ths.push(th.textContent.trim()); });
    if (row.tagName === 'TR') {
      row.querySelectorAll(':scope > td').forEach(function (td, i) { var v = td.textContent.replace(/\s+/g, ' ').trim(); if (v) out.push([ths[i] || ('Field ' + (i + 1)), v]); });
    } else {
      row.querySelectorAll('.deeplbl, .kvk, .mk').forEach(function (k) { var v = k.nextElementSibling ? k.nextElementSibling.textContent.trim() : ''; if (v) out.push([k.textContent.trim(), v]); });
      if (!out.length) out.push(['Listing', row.textContent.replace(/\s+/g, ' ').trim().slice(0, 240)]);
    }
    return out;
  }

  function panelHTML(row, id, p) {
    var pairs = detailPairs(row, p).map(function (pr) { return '<div class="rn-kv"><span class="rn-k">' + esc(pr[0]) + '</span><span class="rn-v">' + esc(pr[1]) + '</span></div>'; }).join('');
    var note = (NOTES && NOTES[NOTE_KEY(id)] && NOTES[NOTE_KEY(id)].note) || '';
    return '<div class="rn-panel" data-rn="' + esc(id) + '">'
      + '<div class="rn-grid">' + pairs + '</div>'
      + (p ? '<div class="rn-hist" data-hist="' + esc(id) + '"><span class="rn-priv">loading LA County history…</span></div>' : '')
      + '<div class="rn-notes"><label class="rn-notelbl">📝 My private notes <span class="rn-priv">(private to you — syncs across your devices)</span></label>'
      + '<textarea class="rn-ta" placeholder="Jot a private note about this listing…">' + esc(note) + '</textarea>'
      + '<div class="rn-saved" aria-live="polite"></div></div></div>';
  }

  function histHTML(d) {
    var a = d.assessor || d.assessorArchive, h = '';
    if (a) {
      h += '<div class="rn-ci">'
        + (a.year_built ? '<span>🏗 Built ' + esc(a.year_built) + '</span>' : '')
        + (a.sqft_main ? '<span>📐 ' + Number(a.sqft_main).toLocaleString() + ' sqft</span>' : '')
        + (a.units ? '<span>🏠 ' + esc(a.units) + ' units</span>' : '')
        + ((a.roll_total_value || a.total_value) ? '<span>💰 assessed ' + money(a.roll_total_value || a.total_value) + '</span>' : '') + '</div>';
    }
    var sales = d.sales || [];
    h += '<div class="rn-hsec"><b>Sold history (' + sales.length + ')</b>' + (sales.length ? sales.map(function (x) { return '<div class="rn-hrow"><span>' + esc(x.address || '') + '</span><span>' + money(x.sold_price) + ' · ' + String(x.sold_date || '').slice(0, 10) + '</span></div>'; }).join('') : '<span class="rn-priv"> none on file</span>') + '</div>';
    if (d.permits && d.permits.length) h += '<div class="rn-hsec"><b>Permits (' + d.permits.length + ')</b>' + d.permits.slice(0, 15).map(function (pm) { return '<div class="rn-hrow"><span>' + esc(pm.permit_type || pm.use_description || 'permit') + '</span><span>' + esc(String(pm.issue_date || '').slice(0, 10)) + '</span></div>'; }).join('') + '</div>';
    if (!a && !sales.length && !(d.permits && d.permits.length)) h = '<span class="rn-priv">No LA County assessor / sold / permit record matched this address.</span>';
    return h;
  }

  function insertPanel(row) {
    var id = rowId(row); if (!id) return;
    var p = findProp(id), host;
    if (row.tagName === 'TR') {
      var cols = row.children.length || 1, tr = document.createElement('tr');
      tr.className = 'rn-row'; tr.setAttribute('data-rn-for', id);
      var td = document.createElement('td'); td.colSpan = cols; td.style.padding = '0';
      td.innerHTML = panelHTML(row, id, p); tr.appendChild(td); row.after(tr); host = tr;
    } else {
      var div = document.createElement('div'); div.className = 'rn-row'; div.setAttribute('data-rn-for', id);
      div.innerHTML = panelHTML(row, id, p); row.after(div); host = div;
    }
    row.classList.add('rn-open');
    var ta = host.querySelector('.rn-ta'), saved = host.querySelector('.rn-saved'), t;
    // backfill the note if the cache wasn't ready when the panel rendered
    if (NOTES === null && ta) loadNotes().then(function () { var n = NOTES[NOTE_KEY(id)]; if (n && n.note && !ta.value) ta.value = n.note; });
    if (ta) ta.addEventListener('input', function () {
      clearTimeout(t); t = setTimeout(function () {
        saved.textContent = 'saving…';
        fetch('/api/agent-notes/' + encodeURIComponent(NOTE_KEY(id)), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ note: ta.value }) })
          .then(function (r) { return r.json(); })
          .then(function () { if (NOTES) NOTES[NOTE_KEY(id)] = { note: ta.value }; saved.textContent = 'saved ' + new Date().toLocaleTimeString(); markBadge(id, !!ta.value.trim()); })
          .catch(function () { saved.textContent = 'could not save'; });
      }, 400);
    });
    var histEl = host.querySelector('.rn-hist');
    if (histEl && p) fetch('/api/property-history?address=' + encodeURIComponent(p.address || '') + '&city=' + encodeURIComponent(p.city || ''))
      .then(function (r) { return r.json(); }).then(function (d) { histEl.innerHTML = histHTML(d); }).catch(function () { histEl.innerHTML = '<span class="rn-priv">history unavailable</span>'; });
    OPEN.add(id);
  }
  function removePanel(row) {
    var id = rowId(row);
    var host = document.querySelector('.rn-row[data-rn-for="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
    if (host) host.remove();
    row.classList.remove('rn-open'); OPEN.delete(id);
  }

  document.addEventListener('click', function (e) {
    var badge = e.target.closest('.rn-badge');
    if (badge) { e.preventDefault(); e.stopPropagation(); var r = rowFor(badge) || badge.closest('[data-id],[data-addr]'); if (r) { if (r.classList.contains('rn-open')) removePanel(r); else insertPanel(r); } return; }
    if (isInteractive(e.target)) return;
    if (e.target.closest('.rn-panel')) return;
    var row = rowFor(e.target); if (!row) return;
    if (row.classList.contains('rn-open')) removePanel(row); else insertPanel(row);
  });

  // 📝 icon affordance on every row + a gold marker when a note exists.
  function badgeEl(id) { var b = document.createElement('button'); b.type = 'button'; b.className = 'rn-badge' + ((NOTES && NOTES[NOTE_KEY(id)] && NOTES[NOTE_KEY(id)].note && NOTES[NOTE_KEY(id)].note.trim()) ? ' has' : ''); b.title = 'Details & notes'; b.textContent = '📝'; b.setAttribute('data-id', id); return b; }
  function markBadge(id, has) { var b = document.querySelector('.rn-badge[data-id="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]'); if (b) b.classList.toggle('has', !!has); }
  function stampAll() {
    document.querySelectorAll('.card[data-id]:not([data-rnb]), .crow[data-id]:not([data-rnb]), .kvcard[data-id]:not([data-rnb])').forEach(function (el) { el.setAttribute('data-rnb', '1'); var id = el.getAttribute('data-id'); if (id) el.insertBefore(badgeEl(id), el.firstChild); });
    document.querySelectorAll('.listtbl tr[data-id]:not([data-rnb])').forEach(function (el) { el.setAttribute('data-rnb', '1'); var id = el.getAttribute('data-id'); if (!id) return; var cell = el.querySelector('td.stick') || el.querySelector('td'); if (cell) cell.insertBefore(badgeEl(id), cell.firstChild); });
  }
  var raf = null; function schedule() { if (raf) return; raf = requestAnimationFrame(function () { raf = null; stampAll(); }); }
  var grid = document.getElementById('grid');
  if (grid) new MutationObserver(schedule).observe(grid, { childList: true, subtree: true });
  loadNotes().then(schedule); schedule();

  var css = document.createElement('style');
  css.textContent =
    '.crow[data-id],.kvcard[data-id],.listtbl tr[data-id],tr.mrow[data-addr],.card[data-id]{cursor:pointer}'
    + '.card[data-id]{position:relative}.card>.rn-badge{position:absolute;top:12px;left:12px;z-index:2}'
    + '.rn-badge{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:7px;border:1px solid var(--line,#e3e6ea);background:var(--bg2,#f7f8fa);color:var(--mut,#8a94a3);font-size:13px;line-height:1;cursor:pointer;flex:0 0 auto;vertical-align:middle;margin-right:8px}'
    + '.rn-badge:hover{color:var(--blue,#2f6df6);border-color:var(--blue,#2f6df6)}'
    + '.rn-badge.has{color:var(--gold,#d9b25f);border-color:var(--gold,#d9b25f)}'
    + '.rn-panel{background:var(--card,#f7f8fa);border:1px solid var(--line,#e3e6ea);border-top:none;border-radius:0 0 10px 10px;padding:12px 16px;margin:0 0 4px}'
    + '.rn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 18px;margin-bottom:10px}'
    + '.rn-kv{display:flex;gap:8px;font-size:12.5px;line-height:1.5;border-bottom:1px dotted var(--line,#e3e6ea);padding:2px 0}'
    + '.rn-k{color:var(--mut,#8a94a3);min-width:92px;text-transform:uppercase;font-size:10.5px;letter-spacing:.03em;padding-top:1px}'
    + '.rn-v{color:var(--ink,#1c2530);font-weight:500}'
    + '.rn-hist{margin:0 0 10px}'
    + '.rn-ci{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--ink,#1c2530);margin-bottom:6px}'
    + '.rn-hsec{margin-top:6px;font-size:12px}.rn-hsec b{color:var(--mut,#8a94a3);font-size:10.5px;text-transform:uppercase;letter-spacing:.03em}'
    + '.rn-hrow{display:flex;justify-content:space-between;gap:10px;padding:2px 0;border-bottom:1px dotted var(--line,#e3e6ea);color:var(--ink,#1c2530)}'
    + '.rn-notelbl{display:block;font-size:12px;color:var(--ink,#1c2530);margin-bottom:5px}'
    + '.rn-priv{color:var(--mut,#8a94a3);font-weight:400;font-size:11px}'
    + '.rn-ta{width:100%;box-sizing:border-box;min-height:66px;background:var(--bg,#fff);border:1px solid var(--line,#e3e6ea);border-radius:8px;color:var(--ink,#1c2530);font:inherit;font-size:12.5px;padding:8px 10px;resize:vertical;line-height:1.5}'
    + '.rn-ta:focus{outline:none;border-color:var(--blue,#2f6df6)}'
    + '.rn-saved{font-size:10.5px;color:var(--mut,#8a94a3);margin-top:3px;min-height:13px}';
  document.head.appendChild(css);
})();