PageSourceSearch

https://app.replicants.ai/js/messages.js?v=20260929m29

js replicants.ai collected 2026-10-05 12:31:33 UTC 115,822 bytes, 2,104 lines download raw bytes

1
2// Shared HTML-escaper for user-controlled output (F5 stored-XSS hardening).
3// Escapes the five chars that matter in text + attribute contexts.
4// UNIQUELY NAMED on purpose: coachbot loads classic <script>s into one global
5// namespace, and other files (utils.js, site-plan-ui.js) define a weaker
6// `escHtml` that would shadow a bare global by load order. The IIFE below aliases
7// this to a closure-local `escHtml`, which lexical scope makes un-shadowable.
8function escHtmlHdr(s) {
9  return String(s == null ? '' : s)
10    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
11    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
12}
13
14// Refresh coach badge in header
15function refreshCoachBadge(coachName) {
16  // Update the header coach pill
17  var pill = document.getElementById('coachPill');
18  if (pill) {
19    pill.innerHTML = '\uD83E\uDD16 ' + escHtmlHdr(coachName);
20    // Flash animation to draw attention
21    pill.style.transition = 'all 0.3s';
22    pill.style.background = '#c7d2fe';
23    setTimeout(function() { pill.style.background = '#e3f2fd'; }, 1000);
24  }
25}
26
27// Admin API helper — uses cookie auth, hits /admin-api/ prefix
28async function adminApi(path, opts = {}) {
29  var headers = Object.assign({}, opts.headers || {});
30  if (opts.body && typeof opts.body === 'string') {
31    headers['Content-Type'] = 'application/json';
32  }
33  var res = await fetch('/admin-api' + path, Object.assign({}, opts, { headers: headers, credentials: 'include' }));
34  return res.json();
35}
36
37// messages.js — Unified Messaging Inbox (all channels, all coaches)
38(function() {
39  // Closure-local alias of the escaper. Lexical scope makes every escHtml(...)
40  // call below resolve to THIS (the strong, quote-safe version) — a weaker
41  // global escHtml from another script cannot shadow it. See escHtmlHdr above.
42  var escHtml = escHtmlHdr;
43  var conversations = [];
44  var tzFilter = null;
45  var currentFilter = 'all';
46  var channelFilter = 'all';
47  var selectedConvId = null;
48  var selectedMessages = [];
49  var coachFilterId = null;
50  var coachesList = [];
51  var funnelData = null;
52  var pipelineMode = false;
53  var stageFilter = null;
54  var pipelineData = [];
55  var pipelineMetrics = null;
56  var pipelinePollTimer = null;
57
58  window._msgCoachFilter = function(coachId) {
59    coachFilterId = coachId;
60    if (pipelineMode) {
61      if (channelFilter === 'pipeline') {
62        loadPipelineView();
63      } else if (channelFilter === 'activity') {
64        loadActivityView();
65      } else {
66        renderInbox();
67      }
68    } else {
69      renderInbox();
70    }
71    if (coachId) {
72      loadMsgFunnel(coachId);
73    }
74  };
75
76  async function loadMsgFunnel(coachId) {
77    var el = document.getElementById('msgFunnelBar');
78    if (!el) return;
79    try {
80      var data = await api('/coaches/' + coachId + '/funnel');
81      if (!data || !data.stages || data.stages.length === 0) { el.innerHTML = ''; return; }
82      funnelData = data;
83      var stages = data.stages;
84      var maxCount = Math.max(1, Math.max.apply(null, stages.map(function(s) { return s.count; })));
85
86      var html = '<div style="padding:12px 16px;border-bottom:1px solid #e0e0e0;background:#fafafa;">' +
87        '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">' +
88          '<span style="font-size:12px;font-weight:600;color:#312e81;">Pipeline</span>' +
89          '<button onclick="window._suggestFunnelSteps(' + coachId + ')" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 8px;font-size:10px;color:#666;cursor:pointer;">Update Steps</button>' +
90        ' <button onclick="window._reassessFunnel(' + coachId + ')" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 8px;font-size:10px;color:#666;cursor:pointer;">Reassess</button>' +
91        ' <button onclick="window._editFunnelSteps(' + coachId + ')" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 8px;font-size:10px;color:#666;cursor:pointer;">Edit</button>' +
92        '</div>' +
93        '<div style="display:flex;gap:3px;align-items:flex-end;height:48px;">';
94
95      stages.forEach(function(stage) {
96        var barH = Math.max(6, Math.round((stage.count / maxCount) * 40));
97        var isFiltered = (stageFilter === stage.id);
98        html += '<div style="flex:1;text-align:center;cursor:pointer;' + (isFiltered ? 'background:#f0f0ff;border-radius:6px;' : '') + '" onclick="window._msgStageFilter(\'' + stage.id + '\')" title="' + escHtml(stage.name) + '">' +
99          '<div style="font-size:10px;font-weight:700;color:' + stage.color + ';">' + stage.count + '</div>' +
100          '<div style="height:' + barH + 'px;background:' + stage.color + ';border-radius:3px 3px 0 0;margin:0 2px;"></div>' +
101          '<div style="font-size:8px;color:#888;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + escHtml(stage.name) + '</div>' +
102        '</div>';
103      });
104
105      if (data.unassessed > 0) {
106        html += '<div style="flex:0.5;text-align:center;" title="Not assessed">' +
107          '<div style="font-size:10px;color:#999;">' + data.unassessed + '</div>' +
108          '<div style="height:6px;background:#ddd;border-radius:3px 3px 0 0;margin:0 2px;"></div>' +
109          '<div style="font-size:8px;color:#999;">?</div>' +
110        '</div>';
111      }
112
113      html += '</div></div>';
114      el.innerHTML = html;
115    } catch (e) {
116      el.innerHTML = '';
117    }
118  }
119
120  window._reassessFunnel = async function(coachId) {
121    var btn = event.target.closest('button');
122    if (btn) { btn.disabled = true; btn.textContent = 'Assessing...'; }
123    try {
124      var result = await api('/coaches/' + coachId + '/funnel/assess', { method: 'POST' });
125      if (result && btn) btn.textContent = result.updated + '/' + result.total + ' updated';
126      if (pipelineMode) { await loadPipelineView(); } else { loadMsgFunnel(coachId); }
127    } catch (e) {
128      if (btn) btn.textContent = 'Error';
129    }
130    setTimeout(function() { if (btn) { btn.disabled = false; btn.textContent = 'Reassess'; } }, 3000);
131  };
132
133  // ── Update Funnel Steps (AI suggest → review → approve) ──
134  window._suggestFunnelSteps = async function(coachId) {
135    var btn = event.target.closest('button');
136    if (btn) { btn.disabled = true; btn.textContent = 'Generating...'; }
137    try {
138      var result = await api('/coaches/' + coachId + '/funnel/generate', { method: 'POST' });
139      if (result && result.stages) {
140        showStageReviewModal(coachId, result.stages);
141      }
142    } catch (e) {
143      App.dialog.notice({ title: 'We could not create the steps', body: 'Please try again in a moment.' });
144    }
145    if (btn) { btn.disabled = false; btn.textContent = 'Update Funnel Steps'; }
146  };
147
148  // ── Edit existing stages ──
149  window._editFunnelSteps = function(coachId) {
150    if (!funnelData || !funnelData.stages) return;
151    var existing = funnelData.stages.map(function(s) {
152      return { id: s.id, name: s.name, color: s.color, order: s.order, description: s.description };
153    });
154    showStageEditorModal(coachId, existing);
155  };
156
157  function showStageReviewModal(coachId, suggestedStages) {
158
159    var existing = document.getElementById('funnelModal');
160    if (existing) existing.remove();
161
162    var modal = document.createElement('div');
163    modal.id = 'funnelModal';
164    modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;';
165
166    var stageRows = suggestedStages.map(function(s, i) {
167      return '<div style="display:flex;align-items:center;gap:10px;padding:10px;background:#f9f9f9;border-radius:8px;margin-bottom:6px;">' +
168        '<div style="width:16px;height:16px;border-radius:50%;background:' + s.color + ';flex-shrink:0;"></div>' +
169        '<div style="flex:1;">' +
170          '<div style="font-weight:600;font-size:13px;">' + s.order + '. ' + escHtml(s.name) + '</div>' +
171          '<div style="font-size:11px;color:#666;">' + escHtml(s.description || '') + '</div>' +
172        '</div>' +
173      '</div>';
174    }).join('');
175
176    modal.innerHTML = '<div style="background:#fff;border-radius:12px;padding:24px;max-width:500px;width:90%;max-height:80vh;overflow-y:auto;">' +
177      '<h3 style="margin:0 0 4px;font-size:16px;">Suggested Funnel Steps</h3>' +
178      '<p style="color:#666;font-size:12px;margin:0 0 16px;">AI generated these based on the coach\'s goal and prompt. Review and approve or edit.</p>' +
179      stageRows +
180      '<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end;">' +
181        '<button onclick="document.getElementById(\'funnelModal\').remove()" style="padding:8px 16px;border:1px solid #ddd;border-radius:6px;background:#fff;cursor:pointer;font-size:13px;">Cancel</button>' +
182        '<button onclick="window._editSuggested(' + coachId + ')" style="padding:8px 16px;border:1px solid #ddd;border-radius:6px;background:#f0f0f0;cursor:pointer;font-size:13px;">Edit First</button>' +
183        '<button onclick="window._approveFunnelSteps(' + coachId + ')" style="padding:8px 16px;border:none;border-radius:6px;background:#4f46e5;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">Approve & Save</button>' +
184      '</div>' +
185    '</div>';
186
187    modal._stages = suggestedStages;
188    document.body.appendChild(modal);
189    modal.addEventListener('click', function(e) { if (e.target === modal) modal.remove(); });
190  }
191
192  window._approveFunnelSteps = async function(coachId) {
193    var modal = document.getElementById('funnelModal');
194    if (!modal || !modal._stages) return;
195    try {
196      await api('/coaches/' + coachId + '/funnel/stages', {
197        method: 'PUT',
198        headers: { 'Content-Type': 'application/json' },
199        body: JSON.stringify({ stages: modal._stages })
200      });
201      modal.remove();
202      loadMsgFunnel(coachId);
203    } catch (e) {
204      App.dialog.notice({ title: 'The steps were not saved', body: 'Please try again in a moment.' });
205    }
206  };
207
208  window._editSuggested = function(coachId) {
209    var modal = document.getElementById('funnelModal');
210    if (!modal || !modal._stages) return;
211    var stages = modal._stages;
212    modal.remove();
213    showStageEditorModal(coachId, stages);
214  };
215
216  function showStageEditorModal(coachId, stages) {
217    var existing = document.getElementById('funnelModal');
218    if (existing) existing.remove();
219
220    var modal = document.createElement('div');
221    modal.id = 'funnelModal';
222    modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;';
223    modal._stages = JSON.parse(JSON.stringify(stages));
224
225    renderEditorContent(modal, coachId);
226    document.body.appendChild(modal);
227    modal.addEventListener('click', function(e) { if (e.target === modal) modal.remove(); });
228  }
229
230  function renderEditorContent(modal, coachId) {
231    var stages = modal._stages;
232    var rows = stages.map(function(s, i) {
233      return '<div style="display:flex;align-items:center;gap:8px;padding:8px;background:#f9f9f9;border-radius:8px;margin-bottom:6px;">' +
234        '<input type="color" value="' + (s.color || '#6366f1') + '" data-idx="' + i + '" data-field="color" style="width:28px;height:28px;border:none;cursor:pointer;">' +
235        '<div style="flex:1;">' +
236          '<input type="text" value="' + escHtml(s.name || '') + '" data-idx="' + i + '" data-field="name" placeholder="Stage name" style="width:100%;border:1px solid #ddd;border-radius:4px;padding:4px 8px;font-size:13px;font-weight:600;margin-bottom:4px;">' +
237          '<input type="text" value="' + escHtml(s.description || '') + '" data-idx="' + i + '" data-field="description" placeholder="Description" style="width:100%;border:1px solid #ddd;border-radius:4px;padding:4px 8px;font-size:11px;color:#666;">' +
238        '</div>' +
239        '<div style="display:flex;flex-direction:column;gap:2px;">' +
240          (i > 0 ? '<button data-idx="' + i + '" data-action="up" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 6px;cursor:pointer;font-size:10px;">↑</button>' : '<div style="height:22px;"></div>') +
241          (i < stages.length - 1 ? '<button data-idx="' + i + '" data-action="down" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 6px;cursor:pointer;font-size:10px;">↓</button>' : '<div style="height:22px;"></div>') +
242        '</div>' +
243        '<button data-idx="' + i + '" data-action="delete" style="background:none;border:none;color:#ef4444;cursor:pointer;font-size:16px;padding:4px;" title="Remove">×</button>' +
244      '</div>';
245    }).join('');
246
247    var inner = modal.querySelector('.funnel-editor-inner');
248    if (!inner) {
249      inner = document.createElement('div');
250      inner.className = 'funnel-editor-inner';
251      inner.style.cssText = 'background:#fff;border-radius:12px;padding:24px;max-width:520px;width:90%;max-height:80vh;overflow-y:auto;';
252      modal.innerHTML = '';
253      modal.appendChild(inner);
254    }
255
256    inner.innerHTML = '<h3 style="margin:0 0 16px;font-size:16px;">Edit Funnel Steps</h3>' +
257      rows +
258      '<button onclick="window._addFunnelStep()" style="width:100%;padding:8px;border:1px dashed #ccc;border-radius:8px;background:none;color:#666;cursor:pointer;font-size:12px;margin-top:4px;">+ Add Step</button>' +
259      '<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end;">' +
260        '<button onclick="document.getElementById(\'funnelModal\').remove()" style="padding:8px 16px;border:1px solid #ddd;border-radius:6px;background:#fff;cursor:pointer;font-size:13px;">Cancel</button>' +
261        '<button onclick="window._saveFunnelEdits(' + coachId + ')" style="padding:8px 16px;border:none;border-radius:6px;background:#4f46e5;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">
261Save Changes</button>' +
262      '</div>';
263
264    // Wire up change handlers
265    inner.querySelectorAll('input').forEach(function(inp) {
266      inp.addEventListener('change', function() {
267        var idx = parseInt(this.dataset.idx);
268        var field = this.dataset.field;
269        modal._stages[idx][field] = this.value;
270      });
271    });
272
273    inner.querySelectorAll('button[data-action]').forEach(function(btn) {
274      btn.addEventListener('click', function() {
275        var idx = parseInt(this.dataset.idx);
276        var action = this.dataset.action;
277        if (action === 'delete' && modal._stages.length > 2) {
278          modal._stages.splice(idx, 1);
279          modal._stages.forEach(function(s, i) { s.order = i + 1; });
280          renderEditorContent(modal, coachId);
281        } else if (action === 'up' && idx > 0) {
282          var tmp = modal._stages[idx];
283          modal._stages[idx] = modal._stages[idx - 1];
284          modal._stages[idx - 1] = tmp;
285          modal._stages.forEach(function(s, i) { s.order = i + 1; });
286          renderEditorContent(modal, coachId);
287        } else if (action === 'down' && idx < modal._stages.length - 1) {
288          var tmp2 = modal._stages[idx];
289          modal._stages[idx] = modal._stages[idx + 1];
290          modal._stages[idx + 1] = tmp2;
291          modal._stages.forEach(function(s, i) { s.order = i + 1; });
292          renderEditorContent(modal, coachId);
293        }
294      });
295    });
296  }
297
298  window._addFunnelStep = function() {
299    var modal = document.getElementById('funnelModal');
300    if (!modal) return;
301    var newOrder = modal._stages.length + 1;
302    modal._stages.push({
303      id: 'stage_' + newOrder,
304      name: '',
305      color: '#6366f1',
306      order: newOrder,
307      description: ''
308    });
309    renderEditorContent(modal, coachFilterId);
310  };
311
312  window._saveFunnelEdits = async function(coachId) {
313    var modal = document.getElementById('funnelModal');
314    if (!modal || !modal._stages) return;
315    // Update ids from names
316    modal._stages.forEach(function(s) {
317      if (!s.id || s.id.startsWith('stage_')) {
318        s.id = s.name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/_+$/, '') || 'stage_' + s.order;
319      }
320    });
321    try {
322      await api('/coaches/' + coachId + '/funnel/stages', {
323        method: 'PUT',
324        headers: { 'Content-Type': 'application/json' },
325        body: JSON.stringify({ stages: modal._stages })
326      });
327      modal.remove();
328      loadMsgFunnel(coachId);
329    } catch (e) {
330      App.dialog.notice({ title: 'That was not saved', body: 'Please try again in a moment.' });
331    }
332  };
333
334  window._msgStageFilter = function(stageId) {
335    stageFilter = (stageFilter === stageId) ? null : stageId;
336    if (pipelineMode) {
337      renderPipelineList();
338      // Re-render funnel bar to update highlight
339      var fb = document.getElementById('msgFunnelBar');
340      if (fb && funnelData) {
341        var cId = coachFilterId || (coachesList.find(function(c) { return c.status === 'active'; }) || {}).id;
342        renderFunnelBarFromData(fb, funnelData, cId);
343      }
344    }
345  };
346
347  window.loadMessages = async function() {
348    var container = document.getElementById('page-messages-home');
349    console.log('[MSG-DIAG] loadMessages called, container:', !!container, 'currentUser:', currentUser ? { id: currentUser.id, customer_id: currentUser.customer_id, email: currentUser.email } : 'NULL');
350    if (!container) { console.warn('[MSG-DIAG] No #page-messages-home container — aborting'); return; }
351    container.innerHTML = '<div style="text-align:center;padding:60px;color:#999;"><i class="fas fa-spinner fa-spin"></i> Loading conversations...</div>';
352
353    try {
354      var cid = currentUser.customer_id || currentUser.id;
355      var inboxUrl = '/customers/' + cid + '/inbox?channel=all';
356      console.log('[MSG-DIAG] Fetching inbox:', inboxUrl);
357      var [items, coaches] = await Promise.all([api(inboxUrl), api('/coaches')]);
358      console.log('[MSG-DIAG] Inbox response:', Array.isArray(items) ? items.length + ' conversations' : typeof items, items && items.error ? 'ERROR: ' + items.error : '');
359      console.log('[MSG-DIAG] Coaches response:', Array.isArray(coaches) ? coaches.length + ' coaches' : typeof coaches);
360      if (Array.isArray(items) && items.length > 0) {
361        console.log('[MSG-DIAG] First conversation:', JSON.stringify({ profile_id: items[0].profile_id, name: items[0].display_name || items[0].profile_name, channel: items[0].channel, coach: items[0].coach_name, msgs: items[0].message_count }));
362        var channels = {};
363        items.forEach(function(c) { channels[c.channel] = (channels[c.channel] || 0) + 1; });
364        console.log('[MSG-DIAG] Channel breakdown:', JSON.stringify(channels));
365      }
366      coachesList = Array.isArray(coaches) ? coaches : [];
367      conversations = Array.isArray(items) ? items : [];
368    } catch (e) {
369      conversations = [];
370      console.error('[MSG-DIAG] loadMessages EXCEPTION:', e.message, e.stack);
371    }
372
373    console.log('[MSG-DIAG] Loaded', conversations.length, 'conversations, rendering inbox');
374    selectedConvId = null;
375    selectedMessages = [];
376    renderInbox();
377  };
378
379  var dashSocket = null;
380
381  function connectDashSocket() {
382    if (dashSocket) return;
383    /* The connection itself now lives in dash-socket.js — ONE socket per tab,
384       shared with the sidebar's live community-DM counts. It authenticates with
385       the same JWT the HTTP API uses (R-2) and emits the `join` on connect AND
386       on every reconnect, so there is no connect handler to write here.
387       Returns null when signed out or before socket.io loaded; the next render
388       simply tries again. */
389    dashSocket = (window.DashSocket && window.DashSocket.get()) || null;
390    if (!dashSocket) return;
391    if (dashSocket._cbInboxBound) return;
392    dashSocket._cbInboxBound = true;
393
394    // Real-time: new message from widget or AI
395    dashSocket.on('new_message', function(data) {
396      console.log('[Dashboard] New message:', data);
397
398      // Refresh inbox list
399      refreshInbox();
400
401      // If we're viewing this contact's thread, update it
402      if (selectedConvId === data.profile_id) {
403        hideTypingIndicator();
404        refreshThread(data.profile_id);
405      }
406    });
407
408    // Typing indicator
409    dashSocket.on('typing', function(data) {
410      if (selectedConvId === data.profile_id) {
411        showTypingIndicator(data.coach_name || 'AI Coach');
412      }
413    });
414    dashSocket.on('typing_stop', function(data) {
415      if (selectedConvId === data.profile_id) {
416        hideTypingIndicator();
417      }
418    });
419  }
420
421  async function refreshInbox() {
422    try {
423      var cid = currentUser.customer_id || currentUser.id;
424      var items = await api('/customers/' + cid + '/inbox?channel=all');
425      if (items) {
426        conversations = items;
427        renderInboxList();
428      }
429    } catch (e) {}
430  }
431
432  // ── Typing indicator ──
433  function showTypingIndicator(coachName) {
434    hideTypingIndicator();
435    var container = document.getElementById('msgThreadPane');
436    if (!container) return;
437    var dot = document.createElement('div');
438    dot.id = 'typingIndicator';
439    dot.style.cssText = 'display:flex;gap:10px;margin-bottom:12px;justify-content:flex-end;align-items:flex-end;';
440    dot.innerHTML = '<div style="max-width:70%;">' +
441      '<div style="font-size:10px;font-weight:600;color:#aaa;margin-bottom:3px;text-align:right;">' + escHtml(coachName) + '</div>' +
442      '<div style="background:#6c63ff;border-radius:12px;border-top-right-radius:4px;padding:12px 18px;display:flex;gap:4px;align-items:center;">' +
443        '<span class="typing-dot" style="width:6px;height:6px;background:rgba(255,255,255,0.7);border-radius:50%;animation:typingBounce 1.4s infinite ease-in-out both;animation-delay:0s;"></span>' +
444        '<span class="typing-dot" style="width:6px;height:6px;background:rgba(255,255,255,0.7);border-radius:50%;animation:typingBounce 1.4s infinite ease-in-out both;animation-delay:0.2s;"></span>' +
445        '<span class="typing-dot" style="width:6px;height:6px;background:rgba(255,255,255,0.7);border-radius:50%;animation:typingBounce 1.4s infinite ease-in-out both;animation-delay:0.4s;"></span>' +
446      '</div>' +
447    '</div>' +
448    '<div style="width:28px;height:28px;border-radius:50%;background:#6c63ff;display:flex;align-items:center;justify-content:center;font-size:11px;flex-shrink:0;">\uD83E\uDD16</div>';
449    container.appendChild(dot);
450    container.scrollTop = container.scrollHeight;
451  }
452
453  function hideTypingIndicator() {
454    var el = document.getElementById('typingIndicator');
455    if (el) el.remove();
456  }
457
458  // Add typing animation keyframes
459  if (!document.getElementById('typingAnimStyle')) {
460    var typingStyle = document.createElement('style');
461    typingStyle.id = 'typingAnimStyle';
462    typingStyle.textContent = '@keyframes typingBounce { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }';
463    document.head.appendChild(typingStyle);
464  }
465
466    async function refreshThread(contactId) {
467    try {
468      var msgs = await api('/contacts/' + contactId + '/messages');
469      if (msgs) {
470        selectedMessages = Array.isArray(msgs) ? msgs : [];
471        var conv = conversations.find(function(c) { return c.profile_id === contactId; });
472        var threadEl = document.getElementById('msgThread');
473        if (threadEl) {
474          threadEl.innerHTML = renderThread(msgs, conv);
475          threadEl.scrollTop = threadEl.scrollHeight;
476        }
477      }
478    } catch (e) {}
479  }
480
481  function startInboxPoll() {
482    connectDashSocket();
483  }
484
485  function renderInbox() {
486    var container = document.getElementById('page-messages-home');
487    if (!container) return;
488    startInboxPoll();
489
490    var filtered = conversations;
491    if (!document.getElementById('msg-row-style')) { var s = document.createElement('style'); s.id = 'msg-row-style'; s.textContent = '.msg-row:hover { background: #ebebeb !important; }'; document.head.appendChild(s); }
492    console.log('[MSG-DIAG] renderInbox — total:', conversations.length, 'channelFilter:', channelFilter, 'coachFilter:', coachFilterId);
493
494    // Channel filter
495    if (channelFilter !== 'all') {
496      var beforeFilter = filtered.length;
497      filtered = filtered.filter(function(c) { return c.channel === channelFilter; });
498      console.log('[MSG-DIAG] Channel filter "' + channelFilter + '": ' + beforeFilter + ' -> ' + filtered.length);
499      if (filtered.length === 0 && beforeFilter > 0) {
500        console.warn('[MSG-DIAG] Channel filter removed ALL conversations. Available channels:', conversations.map(function(c) { return c.channel; }).filter(function(v,i,a) { return a.indexOf(v) === i; }));
501      }
502    }
503
504    // Coach filter
505    if (coachFilterId) {
506      var beforeCoach = filtered.length;
507      filtered = filtered.filter(function(c) { return c.coach_id === coachFilterId; });
508      console.log('[MSG-DIAG] Coach filter ' + coachFilterId + ': ' + beforeCoach + ' -> ' + filtered.length);
509    }
510
511    // Status filter
512    if (currentFilter === 'unreviewed') {
513      filtered = filtered.filter(function(c) { return c.review_status !== 'reviewed'; });
514    } else if (currentFilter === 'reviewed') {
515      filtered = filtered.filter(function(c) { return c.review_status === 'reviewed'; });
516    }
517
518    var totalCount = conversations.length;
519    var waCount = conversations.filter(function(c) { return c.channel === 'whatsapp'; }).length;
520    var webCount = conversations.filter(function(c) { return c.channel === 'webchat'; }).length;
521    var smsCount = conversations.filter(function(c) { return c.channel === 'sms'; }).length;
522
523    container.innerHTML =
524      '<div style="position:relative;height:100%;display:flex;flex-direction:column;">' +
525
526        // Training mode banner
527        '<div style="background:#fff9e6;border-bottom:1px solid #ffe082;padding:14px 24px;display:flex;align-items:center;gap:12px;">' +
528          '<span style="font-size:20px;">\uD83D\uDCEC</span>' +
529          '<div style="flex:1;"><strong style="font-size:14px;">Messaging</strong> <span style="font-size:13px;color:#666;">\u2014 All conversations across channels. Review and train your AI coaches.</span></div>' +
530          '<div style="background:#6c63ff;color:#fff;border-radius:12px;padding:2px 10px;font-size:12px;font-weight:600;">' + totalCount + ' conversations</div>' +
531        '</div>' +
532
533        // Channel filter tabs
534        '<div style="display:flex;border-bottom:1px solid #e0e0e0;gap:0;">' +
535          channelTab('pipeline', '📋 Pipeline', '') + channelTab('activity', '📊 Activity', '') + channelTab('all', 'All', totalCount) +
536          channelTab('whatsapp', '\uD83D\uDFE2 WhatsApp', waCount) +
537          channelTab('webchat', '\uD83D\uDCAC Web Chat', webCount) +
538          (smsCount > 0 ? channelTab('sms', '\uD83D\uDCF1 SMS', smsCount) : '') +
539        '</div>' +
540
541        // Coach filter pills
542        (coachesList.length > 0 ? '<div style="display:flex;gap:6px;padding:8px 16px;border-bottom:1px solid #e0e0e0;flex-wrap:wrap;background:#fafafa;">' +
543          '<span onclick="window._msgCoachFilter(null)" style="padding:4px 12px;border-radius:16px;font-size:11px;font-weight:600;cursor:pointer;' + (!coachFilterId ? 'background:#4f46e5;color:#fff;' : 'background:#f0f0f0;color:#666;') + '">All Coaches</span>' +
544          coachesList.filter(function(c) { return c.status === 'active'; }).map(function(c) {
545            return '<span onclick="window._msgCoachFilter(' + c.id + ')" style="padding:4px 12px;border-radius:16px;font-size:11px;font-weight:600;cursor:pointer;' + (coachFilterId === c.id ? 'background:#4f46e5;color:#fff;' : 'background:#f0f0f0;color:#666;') + '">' + escHtml(c.name) + '</span>';
546          }).join('') +
547        '</div>' : '') +
548
549        // Funnel bar (shown when coach selected)
550        '<div id="msgFunnelBar"></div>' +
551
552        // Split pane
553        '<div style="display:flex;flex:1;min-height:0;overflow:hidden;">' +
554
555          // Left: conversation list
556          '<div style="width:380px;min-width:300px;border-right:1px solid #e0e0e0;overflow-y:auto;" id="msgListPane"></div>' +
557
558          // Right: preview
559          '<div style="flex:1;overflow-y:auto;background:#fafafa;" id="msgPreviewPane">' +
560            '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#ccc;font-size:14px;">' +
561              '<div style="text-align:center;"><div style="font-size:32px;margin-bottom:8px;">\uD83D\uDCAC</div>Select a conversation</div>' +
562            '</div>' +
563          '</div>' +
564
565        '</div>' +
566      '</div>';
567    renderInboxList();
568  }
569
570  function renderInboxList() {
571    var listPane = document.getElementById('msgListPane');
572    if (!listPane) return;
573
574    var filtered = conversations;
575    if (channelFilter !== 'all') {
576      filtered = filtered.filter(function(c) { return c.channel === channelFilter; });
577    }
578    if (tzFilter) {
579      filtered = filtered.filter(function(c) { return c.tz_color === tzFilter; });
580    }
581
582    // Timezone pill filters
583    var tzCounts = {green:0, amber:0, red:0};
584    var base = channelFilter !== 'all' ? conversations.filter(function(c) { return c.channel === channelFilter; }) : conversations;
585    base.forEach(function(c) { if (tzCounts.hasOwnProperty(c.tz_color)) tzCounts[c.tz_color]++; });
586    var pillsHtml = '<div style="padding:6px 16px;display:flex;gap:6px;flex-wrap:wrap;">';
587    var pillDefs = [{key:'green',label:'\u{1F7E2} Active',color:'#22c55e',tip:'Available now \u2014 it\u2019s between 10am and 5pm in their timezone'},{key:'amber',label:'\u{1F7E1} Closing',color:'#f59e0b',tip:'Closing soon \u2014 it\u2019s between 5pm and 6pm in their timezone, last chance today'},{key:'red',label:'\u{1F534} Outside',color:'#ef4444',tip:'Outside hours \u2014 it\u2019s before 10am or after 6pm in their timezone, not a good time to reach out'}];
588    pillDefs.forEach(function(p) {
589      var isOn = tzFilter === p.key;
590      pillsHtml += '<button onclick="setTzMsgFilter(\'' + p.key + '\')" title="' + p.tip + '" style="padding:3px 10px;border-radius:12px;font-size:11px;cursor:pointer;border:1px solid ' + p.color + ';background:' + (isOn ? p.color : 'transparent') + ';color:' + (isOn ? '#fff' : p.color) + ';font-family:Inter,sans-serif;">' + p.label + ' (' + tzCounts[p.key] + ')</button>';
591    });
592    if (tzFilter) pillsHtml += '<button onclick="setTzMsgFilter(null)" style="padding:3px 10px;border-radius:12px;font-size:11px;cursor:pointer;border:1px solid #ccc;background:transparent;color:#888;font-family:Inter,sans-serif;">\u2715 Clear</button>';
593    pillsHtml += '</div>';
594
595    if (filtered.length === 0) {
596      listPane.innerHTML = pillsHtml + '<div style="text-align:center;padding:60px 20px;color:#999;font-size:13px;"><div style="font-size:32px;margin-bottom:12px;">\uD83D\uDCED</div>No conversations match</div>';
597    } else {
598      listPane.innerHTML = pillsHtml + filtered.map(function(conv) { return renderConvRow(conv); }).join('');
599    }
600
601    // Update counts in channel tabs
602    var totalCount = conversations.length;
603    var tabBar = listPane.parentElement.parentElement.querySelector('[style*="border-bottom"]');
604    // Skip complex tab update - full re-render handles it
605  }
606
607  function channelTab(key, label, count) {
608    var active = channelFilter === key;
609    return '<button onclick="setChannelFilter(\'' + key + '\')" style="padding:10px 18px;font-size:13px;font-weight:' +
610      (active ? '600' : '400') + ';color:' + (active ? '#1a1a1a' : '#999') +
611      ';background:none;border:none;border-bottom:2px solid ' + (active ? '#1a1a1a' : 'transparent') +
612      ';cursor:pointer;font-family:Inter,sans-serif;transition:all 0.15s;">' + label +
613      (count > 0 ? ' <span style="font-size:11px;color:#999;">(' + count + ')</span>' : '') + '</button>';
614  }
615
616  function channelIcon(channel) {
617    if (channel === 'whatsapp') return '<i class="fab fa-whatsapp" style="color:#25D366;font-size:11px;"></i>';
618    if (channel === 'webchat') return '<i class="fas fa-comment-dots" style="color:#6366f1;font-size:11px;"></i>';
619    if (channel === 'sms') return '<i class="fas fa-sms" style="color:#f59e0b;font-size:11px;"></i>';
620    return '<i class="fas fa-envelope" style="color:#999;font-size:11px;"></i>';
621  }
622
623  function getContactName(conv) {
624    if (conv.first_name && conv.last_name) return conv.first_name + ' ' + conv.last_name;
625    if (conv.first_name) return conv.first_name;
626    if (conv.profile_name) return conv.profile_name;
627    if (conv.phone) return conv.phone;
628    return 'Unknown';
629  }
630
631  function getInitial(conv) {
632    var name = getContactName(conv);
633    return name.charAt(0).toUpperCase();
634  }
635
636  function timeAgo(dateStr) {
637    if (!dateStr) return '';
638    var diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
639    if (diff < 60) return 'just now';
640    if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
641    if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
642    if (diff < 604800) return Math.floor(diff / 86400) + 'd ago';
643    return App.Dates.short(dateStr);
644  }
645
646  // escHtml is defined at module scope (top of file) — see F5 hardening.
647
648  function renderConvRow(conv) {
649    var tzColors = {green:'#22c55e',amber:'#f59e0b',red:'#ef4444',grey:'#d1d5db'};
650    var tzBgs = {green:'rgba(34,197,94,0.08)',amber:'rgba(245,158,11,0.08)',red:'rgba(239,68,68,0.06)',grey:'#fff'};
651    var tzC = conv.tz_color || 'grey';
652    var isSelected = conv.profile_id === selectedConvId;
653    var baseBg = isSelected ? '#f0f0f0' : (tzBgs[tzC] || '#fff');
654    var borderLeft = tzC !== 'grey' ? 'border-left:3px solid ' + tzColors[tzC] + ';' : 'border-left:3px solid transparent;';
655    var name = getContactName(conv);
656    var preview = escHtml((conv.last_message || '').substring(0, 80));
657    var dirIcon = conv.last_direction === 'outbound' ? '<span style="color:#999;font-size:11px;">You: </span>' : '';
658
659    return '<div onclick="openConversation(' + conv.profile_id + ')" data-base-bg="' + baseBg + '" style="display:flex;align-items:center;padding:14px 20px;border-bottom:1px solid #f0f0f0;cursor:pointer;transition:background 0.1s;background:' + baseBg + ';' + borderLeft + ' msg-row">' +
660      '<div style="width:40px;height:40px;border-radius:50%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:600;color:#666;margin-right:14px;flex-shrink:0;">' + getInitial(conv) + '</div>' +
661      '<div style="flex:1;min-width:0;">' +
662        '<div style="display:flex;align-items:center;gap:8px;margin-bottom:3px;">' +
663          '<span style="font-weight:600;font-size:14px;color:#1e1b4b;">' + escHtml(name) + '</span>' +
664          '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;margin-left:6px;background:' + ({green:'#22c55e',amber:'#f59e0b',red:'#ef4444',grey:'#d1d5db'}[conv.tz_color||'grey']) + ';" title="' + escHtml(conv.tz_label || 'Timezone unknown') + '"></span>' +
665          channelIcon(conv.channel) +
666          (conv.coach_name ? ' <span data-coach-list="' + conv.profile_id + '" style="font-size:10px;padding:1px 6px;border-radius:8px;background:#e3f2fd;color:#1565c0;">\uD83E\uDD16 ' + escHtml(conv.coach_name) + '</span>' : '') +
667        '</div>' +
668        '<div style="font-size:13px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + dirIcon + preview + '</div>' +
669      '</div>' +
670      '<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;margin-left:14px;flex-shrink:0;">' +
671        '<span style="font-size:11px;color:#bbb;">' + timeAgo(conv.last_message_at) + '</span>' +
672        '<span style="font-size:11px;color:#999;">' + (conv.message_count || 0) + ' msgs</span>' +
673      '</div>' +
674    '</div>';
675  }
676
677  window.setTzMsgFilter = function(f) {
678    tzFilter = (tzFilter === f) ? null : f;
679    renderInboxList();
680  };
681
682  window.setChannelFilter = function(filter) {
683    if (filter === 'pipeline') {
684      window._switchTab('pipeline');
685      return;
686    }
687    window._switchTab(filter);
688  };
689
690  window.openConversation = async function(contactId) {
691    console.log('[MSG-DIAG] openConversation called, contactId:', contactId);
692    selectedConvId = contactId;
693
694    // Update list selection
695    var listPane = document.getElementById('msgListPane');
696    if (listPane) {
697      listPane.querySelectorAll('div[onclick]').forEach(function(r) {
698        r.dataset.selected = '';
699        r.style.background = r.dataset.baseBg || '#fff';
700        if (r.getAttribute('onclick') && r.getAttribute('onclick').indexOf('(' + contactId + ')') !== -1) {
701          r.dataset.selected = '1';
702          r.style.background = '#e8e8e8';
703        }
704      });
705    }
706
707    var previewPane = document.getElementById('msgPreviewPane');
708    if (!previewPane) return;
709    previewPane.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
710
711    try {
712      console.log('[MSG-DIAG] Fetching messages for contact', contactId);
713      var msgs = await api('/contacts/' + contactId + '/messages');
714      console.log('[MSG-DIAG] Messages response:', Array.isArray(msgs) ? msgs.length + ' messages' : typeof msgs, msgs && msgs.error ? 'ERROR: ' + msgs.error : '');
715      selectedMessages = msgs || [];
716    } catch (e) {
717      selectedMessages = [];
718      console.error('[MSG-DIAG] openConversation EXCEPTION:', e.message);
719    }
720
721    var conv = conversations.find(function(c) { return c.profile_id === contactId; });
722    console.log('[MSG-DIAG] Conv found:', !!conv, conv ? { profile_id: conv.profile_id, channel: conv.channel, coach: conv.coach_name } : 'NOT FOUND');
723    var name = conv ? getContactName(conv) : 'Unknown';
724    var channel = conv ? conv.channel : '';
725
726    previewPane.innerHTML =
727      // Header
728      '<div style="padding:16px 24px;border-bottom:1px solid #e0e0e0;display:flex;align-items:center;gap:10px;background:#fff;position:sticky;top:0;z-index:5;">' +
729        '<div style="width:36px;height:36px;border-radius:50%;background:#f0f0f0;
729display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;color:#666;">' + (conv ? getInitial(conv) : '?') + '</div>' +
730        '<div style="flex:1;">' +
731          '<div style="font-weight:600;font-size:15px;color:#1e1b4b;">' + escHtml(name) + '</div>' +
732          '<div style="font-size:12px;color:#999;">' + channelIcon(channel) + ' ' + (channel || 'unknown') + (conv && conv.phone ? ' \u00B7 ' + escHtml(conv.phone) : '') + (conv && conv.tz_label ? ' \u00B7 <span style="color:' + ({green:'#22c55e',amber:'#f59e0b',red:'#ef4444',grey:'#999'}[conv.tz_color||'grey']) + ';">' + escHtml(conv.tz_label) + '</span>' : '') + '</div>' +
733        '</div>' +
734        '<div style="display:flex;gap:8px;align-items:center;">' +
735          '<span id="aiStatusBadge" style="font-size:12px;padding:4px 12px;border-radius:12px;' + (conv.agent_paused ? 'background:#fff3cd;color:#856404;' : 'background:#e8f5e9;color:#2e7d32;') + '">' + (conv.agent_paused ? '\u23F8 AI Paused' : '\u2713 AI Active') + '</span>' +
736          (conv.coach_name ? '<span id="coachPill" style="font-size:11px;padding:3px 10px;border-radius:12px;background:#e3f2fd;color:#1565c0;">\uD83E\uDD16 ' + escHtml(conv.coach_name) + '</span>' : '') +
737          (conv.agent_paused ? '<button onclick="resumeAI(' + contactId + ')" style="padding:6px 14px;background:#10b981;color:#fff;border:none;border-radius:8px;font-size:12px;cursor:pointer;font-family:Inter,sans-serif;">Resume AI</button>' : '') +
738          '<button onclick="restartAI(' + contactId + ')" style="padding:6px 14px;background:#6366f1;color:#fff;border:none;border-radius:8px;font-size:12px;cursor:pointer;font-family:Inter,sans-serif;">\u{1F504} Restart AI</button>' +
739          '<button onclick="approveConversation(' + contactId + ')" style="padding:8px 16px;background:#fff;color:#1e1b4b;border:1px solid #e0e0e0;border-radius:8px;font-size:13px;cursor:pointer;font-family:Inter,sans-serif;">\u2713 Approve</button>' +
740          '<button onclick="feedbackConversation(' + contactId + ')" style="padding:8px 16px;background:#6c63ff;color:#fff;border:none;border-radius:8px;font-size:13px;cursor:pointer;font-family:Inter,sans-serif;">\uD83C\uDFA4 Feedback</button>' +
741        '</div>' +
742      '</div>' +
743
744      // Messages
745      '<div style="padding:20px 24px;max-height:calc(100vh - 250px);overflow-y:auto;" id="msgThread">' +
746        renderThread(selectedMessages, conv) +
747      '</div>' +
748
749      // Reply box
750      '<div style="padding:12px 24px;border-top:1px solid #e0e0e0;background:#fff;display:flex;gap:8px;align-items:center;">' +
751        '<input type="text" id="replyInput" placeholder="Type a reply..." onkeydown="if(event.key===\'Enter\'&&!event.shiftKey){event.preventDefault();sendReply(' + contactId + ');}" style="flex:1;padding:10px 14px;border:1px solid #e0e0e0;border-radius:8px;font-size:14px;font-family:Inter,sans-serif;outline:none;">' +
752        '<button onclick="sendReply(' + contactId + ')" style="padding:10px 20px;background:#6c63ff;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;font-family:Inter,sans-serif;">Send</button>' +
753      '</div>';
754
755    // Scroll to bottom
756    var thread = document.getElementById('msgThread');
757    if (thread) thread.scrollTop = thread.scrollHeight;
758
759    // Socket will handle real-time updates via new_message event
760  };
761
762  function renderThread(messages, conv) {
763    if (!messages || messages.length === 0) {
764      return '<div style="text-align:center;padding:40px;color:#999;font-size:13px;">No messages yet</div>';
765    }
766
767    var contactName = conv ? getContactName(conv) : 'Contact';
768    var lastDate = '';
769
770    return messages.map(function(msg) {
771      var html = '';
772
773      // Date separator
774      var msgDate = msg.sent_at || msg.created_at;
775      var dateStr = msgDate ? App.Dates.weekday(msgDate, { year: 'auto' }) : '';
776      if (dateStr && dateStr !== lastDate) {
777        lastDate = dateStr;
778        html += '<div style="text-align:center;margin:16px 0 12px;"><span style="font-size:11px;color:#bbb;background:#f5f5f5;padding:2px 12px;border-radius:10px;">' + dateStr + '</span></div>';
779      }
780
781      // Skip system messages
782      if (msg.direction === 'system') {
783        html += '<div style="text-align:center;margin:8px 0;font-size:12px;color:#bbb;font-style:italic;">' + escHtml(msg.message) + '</div>';
784        return html;
785      }
786
787      if (msg.direction === 'inbound') {
788        html += '<div style="display:flex;gap:10px;margin-bottom:12px;">' +
789          '<div style="width:28px;height:28px;border-radius:50%;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;color:#666;flex-shrink:0;">' + contactName.charAt(0) + '</div>' +
790          '<div style="max-width:70%;">' +
791            '<div style="font-size:10px;font-weight:600;color:#999;margin-bottom:3px;">' + escHtml(contactName) + '</div>' +
792            '<div style="background:#f5f5f5;border-radius:12px;border-top-left-radius:4px;padding:10px 14px;">' +
793              '<div style="font-size:13px;line-height:1.5;color:#1e1b4b;">' + escHtml(msg.message) + '</div>' +
794            '</div>' +
795            (function() {
796              if (!msg.media_url) return '';
797              var isAudio = (msg.media_type || '').startsWith('audio/');
798              var transcription = msg.metadata && msg.metadata.voice_transcription;
799              if (isAudio) {
800                var html = '<div style="margin-top:8px">';
801                html += '<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px"><span style="font-size:11px;background:#6366f1;color:#fff;padding:1px 6px;border-radius:4px">🎤 Voice Note</span></div>';
802                html += '<audio controls preload="none" style="width:100%;max-width:280px;height:32px"><source src="' + escHtml(msg.media_url) + '" type="' + escHtml(msg.media_type || 'audio/ogg') + '"></audio>';
803                if (transcription) {
804                  html += '<details style="margin-top:6px"><summary style="font-size:11px;color:#94a3b8;cursor:pointer">📝 Transcription</summary>';
805                  html += '<div style="font-size:12px;color:#cbd5e1;background:rgba(255,255,255,0.04);padding:8px;border-radius:6px;margin-top:4px;font-style:italic">' + escHtml(transcription) + '</div>';
806                  html += '</details>';
807                }
808                html += '</div>';
809                return html;
810              }
811              return '<div style="margin-top:6px;"><img src="' + escHtml(msg.media_url) + '" style="max-width:200px;border-radius:8px;cursor:pointer" onclick="window.open(this.src)"></div>';
812            })() +
813            '<div style="font-size:10px;color:#ccc;margin-top:3px;">' + formatTime(msgDate) + '</div>' +
814          '</div>' +
815        '</div>';
816      } else if (msg.direction === 'outbound') {
817        var isAdmin = msg.metadata && msg.metadata.source === 'admin';
818        var senderLabel = isAdmin ? 'You' : 'AI Coach';
819        var senderColor = isAdmin ? '#10b981' : '#aaa';
820        html += '<div style="display:flex;gap:10px;margin-bottom:12px;justify-content:flex-end;">' +
821          '<div style="max-width:70%;">' +
822            '<div style="font-size:10px;font-weight:600;color:' + senderColor + ';margin-bottom:3px;text-align:right;">' + senderLabel + '</div>' +
823            (function() {
824              var isT = (msg.message || '').indexOf('[template:') === 0 || (msg.metadata && msg.metadata.is_template) || msg.is_template;
825              var displayText = msg.message || '';
826              // Template SIDs now resolved by backend
827              return '<div style="' + (isT ? 'background:#dbeafe;color:#1e40af;border:1px solid #93c5fd;' : (isAdmin ? 'background:#059669;color:#fff;' : 'background:#6c63ff;color:#fff;')) + 'border-radius:12px;border-top-right-radius:4px;padding:10px 14px;"' + (isT ? ' title="Sent via WhatsApp template"' : '') + '>' +
828                '<div style="font-size:13px;line-height:1.5;">' + escHtml(displayText) + '</div>' +
829                (isT ? '<div style="font-size:10px;margin-top:4px;opacity:0.6;">📋 Template</div>' : '') +
830              '</div>';
831            })() +
832            '<div style="font-size:10px;color:#ccc;margin-top:3px;text-align:right;">' + formatTime(msgDate) + '</div>' +
833          '</div>' +
834          '<div style="width:28px;height:28px;border-radius:50%;background:' + (isAdmin ? '#059669' : '#6c63ff') + ';display:flex;align-items:center;justify-content:center;font-size:11px;flex-shrink:0;">
834' + (isAdmin ? '\uD83D\uDC64' : '\uD83E\uDD16') + '</div>' +
835        '</div>';
836      }
837
838      return html;
839    }).join('');
840
841    // Event delegation for pipeline action buttons
842    list.addEventListener('click', function(e) {
843      var btn = e.target.closest('button[data-action]');
844      if (btn) {
845        e.stopPropagation();
846        window._pipeAction(parseInt(btn.dataset.aid), btn.dataset.action);
847      }
848    });
849  }
850
851  function formatTime(dateStr) {
852    if (!dateStr) return '';
853    var d = new Date(dateStr);
854    return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
855  }
856
857  window.sendReply = async function(contactId) {
858    var input = document.getElementById('replyInput');
859    if (!input || !input.value.trim()) return;
860    var msg = input.value.trim();
861    input.value = '';
862
863    var conv = conversations.find(function(c) { return c.profile_id === contactId; });
864    if (!conv) { showToast('Conversation not found', 'error'); return; }
865
866    try {
867      var cid = currentUser.customer_id || currentUser.id;
868      await api('/contacts/' + contactId + '/reply', {
869        method: 'POST',
870        headers: { 'Content-Type': 'application/json' },
871        body: JSON.stringify({ message: msg, channel: conv.channel || 'webchat', customer_id: cid })
872      });
873      // Mark locally so the header updates immediately
874      if (conv) conv.agent_paused = true;
875      showToast('Sent — AI paused for this contact');
876      openConversation(contactId);
877    } catch (e) {
878      showToast('Failed to send: ' + e.message, 'error');
879    }
880  };
881
882  window.approveConversation = async function(contactId) {
883    // Find which coach handled this conversation and save as approved training
884    var conv = conversations.find(function(c) { return c.profile_id === contactId; });
885    var msgs = selectedMessages.filter(function(m) { return m.direction !== 'system'; });
886
887    var trainingData = msgs.map(function(m) {
888      return { role: m.direction === 'inbound' ? 'user' : 'coach', content: m.message };
889    });
890
891    try {
892      // Try to save to the first active coach's training
893      var cid = currentUser.customer_id || currentUser.id;
894      var coaches = await api('/coaches');
895      var activeCoach = coaches.find(function(c) { return c.status === 'active'; });
896
897      if (activeCoach) {
898        await api('/coaches/' + activeCoach.id + '/training', {
899          method: 'POST',
900          headers: { 'Content-Type': 'application/json' },
901          body: JSON.stringify({ conversation: trainingData, feedback: 'Approved — good conversation', corrections: [] })
902        });
903      }
904      showToast('Conversation approved \u2713');
905    } catch (e) {
906      showToast('Approved (training save failed)', 'error');
907    }
908  };
909
910  window.feedbackConversation = function(contactId) {
911    var overlay = document.createElement('div');
912    overlay.id = 'feedbackOverlay';
913    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:100000;display:flex;align-items:center;justify-content:center;';
914    overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };
915
916    overlay.innerHTML =
917      '<div style="background:#fff;border-radius:14px;width:500px;max-width:90vw;box-shadow:0 8px 32px rgba(0,0,0,0.2);overflow:hidden;">' +
918        '<div style="padding:20px 24px;border-bottom:1px solid #e0e0e0;display:flex;align-items:center;justify-content:space-between;">' +
919          '<h3 style="margin:0;font-size:16px;font-weight:600;">Give Feedback</h3>' +
920          '<button onclick="document.getElementById(\'feedbackOverlay\').remove()" style="border:none;background:none;font-size:18px;cursor:pointer;color:#999;">\u2715</button>' +
921        '</div>' +
922        '<div style="padding:24px;">' +
923          '<p style="margin:0 0 12px;font-size:13px;color:#666;">How should the AI coach improve? What would you change about this conversation?</p>' +
924          '<textarea id="feedbackTextarea" rows="5" placeholder="e.g. The coach was too pushy, should have asked about their budget first..." style="width:100%;padding:12px;border:1px solid #e0e0e0;border-radius:8px;font-size:14px;font-family:Inter,sans-serif;resize:vertical;box-sizing:border-box;outline:none;"></textarea>' +
925        '</div>' +
926        '<div style="padding:16px 24px;border-top:1px solid #e0e0e0;display:flex;gap:10px;justify-content:flex-end;">' +
927          '<button onclick="document.getElementById(\'feedbackOverlay\').remove()" style="padding:10px 20px;background:#f0f0f0;color:#666;border:none;border-radius:8px;font-size:13px;
927cursor:pointer;font-family:Inter,sans-serif;">Cancel</button>' +
928          '<button onclick="submitConvFeedback(' + contactId + ')" style="padding:10px 20px;background:#6c63ff;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;font-family:Inter,sans-serif;">Submit Feedback</button>' +
929        '</div>' +
930      '</div>';
931
932    document.body.appendChild(overlay);
933    setTimeout(function() { document.getElementById('feedbackTextarea').focus(); }, 100);
934  };
935
936  window.submitConvFeedback = async function(contactId) {
937    var ta = document.getElementById('feedbackTextarea');
938    if (!ta || !ta.value.trim()) { showToast('Please provide feedback first'); return; }
939    var feedback = ta.value.trim();
940
941    var msgs = selectedMessages.filter(function(m) { return m.direction !== 'system'; });
942    var trainingData = msgs.map(function(m) {
943      return { role: m.direction === 'inbound' ? 'user' : 'coach', content: m.message };
944    });
945
946    try {
947      var coaches = await api('/coaches');
948      var activeCoach = coaches.find(function(c) { return c.status === 'active'; });
949
950      if (activeCoach) {
951        await api('/coaches/' + activeCoach.id + '/training', {
952          method: 'POST',
953          headers: { 'Content-Type': 'application/json' },
954          body: JSON.stringify({ conversation: trainingData, feedback: feedback, corrections: [] })
955        });
956      }
957      document.getElementById('feedbackOverlay').remove();
958      showToast('Feedback submitted \u2014 your coach is learning!');
959    } catch (e) {
960      document.getElementById('feedbackOverlay').remove();
961      showToast('Feedback saved locally (sync failed)', 'error');
962    }
963  };
964
965  function showToast(msg, type) {
966    var tc = document.getElementById('toastContainer');
967    if (!tc) return;
968    var t = document.createElement('div');
969    t.style.cssText = 'background:' + (type === 'error' ? '#ef4444' : '#1a1a1a') + ';color:#fff;padding:10px 20px;border-radius:8px;font-size:13px;margin-top:8px;animation:fadeIn 0.3s;';
970    t.textContent = msg;
971    tc.appendChild(t);
972    setTimeout(function() { t.remove(); }, 3000);
973  }
974
975  
976// ── Restart AI — send template/message and activate coach ──
977window.restartAI = async function(contactId) {
978  try {
979    // Fetch brain data and window status in parallel
980    var [statusR, brainResp] = await Promise.all([
981      api('/contacts/' + contactId + '/window-status'),
982      api('/contacts/' + contactId + '/brain-data')
983    ]);
984    window._restartBrainData = (brainResp && brainResp.data) || {};
985    window._restartContactName = (brainResp && brainResp.displayName) || '';
986    var within24h = statusR.within24h;
987    var savedMessages = statusR.savedMessages || [];
988    var lastAt = statusR.lastInboundAt ? App.Dates.shortDateTime(statusR.lastInboundAt) : 'never';
989
990    // Build modal
991    var modal = document.createElement('div');
992    modal.id = 'restartAIModal';
993    modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000;';
994
995    var windowBadge = within24h
996      ? '<span style="background:#e8f5e9;color:#2e7d32;padding:3px 10px;border-radius:12px;font-size:12px;">\u2705 Within 24h window</span>'
997      : '<span style="background:#fce4ec;color:#c62828;padding:3px 10px;border-radius:12px;font-size:12px;">\u26A0\uFE0F Outside 24h — template required</span>';
998
999    var savedOptions = '';
1000    // Build templates dynamically based on brain data
1001    var websiteUrl = (window._restartBrainData || {}).website_url || '';
1002    var firstName = (window._restartContactName || 'there').split(' ')[0];
1003    var defaultMessages = websiteUrl ? [
1004      { name: 'Confirm URL & Start', body: "Hi " + firstName + ", great news \u2014 we're ready to start building your website! Just want to confirm you'd like us to work with " + websiteUrl + ". Reply YES to get started! \uD83D\uDE80", coachId: 10, coachName: 'Page Builder Coach', templateKey: 'page_builder_confirm_url' },
1005      { name: 'Follow up - general', body: "Hey! Just checking in \u2014 we'd love to get started on your website. Let me know when you're ready and we'll take it from there \uD83D\uDC4D", coachId: 10, coachName: 'Page Builder Coach', templateKey: 'coach_beta_welcome' },
1006      { name: 'Welcome back', body: "Welcome back! \uD83D\uDC4B Ready to pick up where we left off? Just let me know what you need.", coachId: 11, coachName: 'Client Support', templateKey: 'coach_beta_welcome' }
1007    ] : [
1008      { name: 'Ready to build - need URL', body: "Great news! We're ready to start building your website. If you've got a URL you'd like us to use as inspiration, send it over and we'll get started right away! \uD83D\uDE80", coachId: 10, coachName: 'Page Builder Coach', templateKey: 'coach_beta_welcome' },
1009      { name: 'Follow up - URL needed', body: "Hey! Just checking in \u2014 we'd love to get started on your website. Do you have a URL of a site you like that we can use as inspiration? Just drop it here and we'll take it from there \uD83D\uDC4D", coachId: 10, coachName: 'Page Builder Coach', templateKey: 'coach_beta_welcome' },
1010      { name: 'Welcome back', body: "Welcome back! \uD83D\uDC4B Ready to pick up where we left off? Just let me know what you need.", coachId: 11, coachName: 'Client Support', templateKey: 'coach_beta_welcome' }
1011    ]
1012    var allMessages = defaultMessages.concat(savedMessages);
1013    for (var i = 0; i < allMessages.length; i++) {
1014      var m = allMessages[i];
1015      var coachTag = m.coachName ? ' <span style="background:#eef2ff;color:#4f46e5;padding:1px 6px;border-radius:4px;font-size:10px;font-weight:500;margin-left:6px;">' + escHtml(m.coachName) + '</span>' : '';
1016      savedOptions += '<div onclick="selectRestartMsg(this)" data-body="' + escHtml(m.body || '') + '" data-coachid="' + (m.coachId || '') + '" data-coachname="' + escHtml(m.coachName || '') + '" data-template
1016key="' + escHtml(m.templateKey || '') + '" style="padding:10px 14px;border:1px solid #e0e0e0;border-radius:8px;cursor:pointer;margin-bottom:6px;font-size:13px;transition:all 0.15s;" onmouseover="this.style.borderColor=\'#6366f1\'" onmouseout="if(!this.classList.contains(\'selected\'))this.style.borderColor=\'#e0e0e0\'">' +
1017        '<div style="font-weight:600;font-size:13px;color:#1e1b4b;">' + escHtml(m.name || 'Message') + coachTag + '</div>' +
1018        '<div style="color:#666;font-size:12px;margin-top:2px;">' + escHtml((m.body || '').substring(0, 80)) + '...</div>' +
1019      '</div>';
1020    }
1021
1022    modal.innerHTML =
1023      '<div style="background:#fff;border-radius:16px;padding:24px;max-width:500px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,0.3);">' +
1024        '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">' +
1025          '<h3 style="margin:0;font-size:18px;color:#1e1b4b;">\uD83D\uDD04 Restart AI Coach</h3>' +
1026          '<button onclick="document.getElementById(\'restartAIModal\').remove()" style="background:none;border:none;font-size:20px;cursor:pointer;color:#999;">\u2715</button>' +
1027        '</div>' +
1028        '<div style="margin-bottom:12px;">' + windowBadge + ' <span style="font-size:11px;color:#999;margin-left:8px;">Last message: ' + lastAt + '</span></div>' +
1029        '<div id="restartCoachBadge" style="margin-bottom:12px;min-height:22px;"></div>' +
1030        '<div id="restartCoachSwitcher" style="margin-bottom:12px;"></div>' +
1031
1032        '<div style="margin-bottom:12px;font-weight:600;font-size:13px;color:#444;">Pick a message to send:</div>' +
1033        '<div id="restartMsgList">' + savedOptions + '</div>' +
1034        '<div id="restartMsgPreview" style="display:none;margin-top:12px;padding:12px;background:#f9fafb;border-radius:10px;border:1px solid #e5e7eb;"></div>' +
1035
1036        (within24h ? '<div style="margin-top:12px;"><div style="font-weight:600;font-size:13px;color:#444;margin-bottom:6px;">Or write a custom message:</div>' +
1037          '<textarea id="restartCustomMsg" rows="3" style="width:100%;padding:10px;border:1px solid #e0e0e0;border-radius:8px;font-size:13px;font-family:Inter,sans-serif;resize:vertical;" placeholder="Type your message..."></textarea></div>' : '') +
1038
1039        (!within24h ? '<div style="margin-top:12px;padding:10px;background:#fff3cd;border-radius:8px;font-size:12px;color:#856404;">\u26A0\uFE0F This contact hasn\'t messaged in over 24 hours. WhatsApp requires sending an approved template first. The AI will activate once they reply.</div>' : '') +
1040
1041        '<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end;">' +
1042          '<button onclick="document.getElementById(\'restartAIModal\').remove()" style="padding:10px 20px;background:#f5f5f5;color:#666;border:none;border-radius:8px;font-size:13px;cursor:pointer;">Cancel</button>' +
1043          '<button data-send-btn onclick="sendRestartAI(' + contactId + ',' + within24h + ')" style="padding:10px 20px;background:#6366f1;color:#fff;border:none;border-radius:8px;font-size:13px;cursor:pointer;font-weight:600;transition:all 0.2s;">Send & Activate AI</button>' +
1044        '</div>' +
1045      '</div>';
1046
1047    document.body.appendChild(modal);
1048
1049    // Populate current coach info + coach switcher
1050    try {
1051      var coachList = await api('/coaches');
1052      var currentAssign = conversations.find(function(c) { return c.profile_id === contactId; });
1053      var currentCoachName = (currentAssign && currentAssign.coach_name) || 'Unknown';
1054      var switcherEl = document.getElementById('restartCoachSwitcher');
1055      if (switcherEl && coachList && coachList.length > 0) {
1056        var coachBadgeHtml = '<div style="background:#eef2ff;border-radius:8px;padding:10px 14px;display:flex;align-items:center;justify-content:space-between;">' +
1057          '<div style="display:flex;align-items:center;gap:8px;">' +
1058            '<span style="font-size:14px;">\uD83E\uDD16</span>' +
1059            '<span style="font-size:12px;font-weight:600;color:#4f46e5;">Current coach: ' + escHtml(currentCoachName) + '</span>' +
1060          '</div>' +
1061        '</div>';
1062        switcherEl.innerHTML = coachBadgeHtml;
1063      }
1064    } catch(e) { console.warn('[Restart] Coach list fetch failed:', e); }
1065
1066  } catch (err) {
1067    App.dialog.notice({ title: 'That didn’t work', body: 'Please try again in a moment. If it keeps happening, tell us and we will sort it out.\n\nDetails: ' + (err.message) });
1068  }
1069}
1070
1071window.selectRestartMsg = function(el) {
1072  // Reset send button confirmation state
1073  var sendBtn = document.querySelector('#restartAIModal [data-send-btn]');
1074  if (sendBtn) { sendBtn.dataset.confirmed = ''; sendBtn.textContent = 'Send & Activate AI'; sendBtn.style.background = '#6366f1'; }
1075
1076  var items = document.querySelectorAll('#restartMsgList > div');
1077  items.forEach(function(item) { item.style.borderColor = '#e0e0e0'; item.style.background = ''; item.classList.remove('selected'); });
1078  el.style.borderColor = '#6366f1';
1079  el.style.background = '#f0f0ff';
1080  el.classList.add('selected');
1081  var ta = document.getElementById('restartCustomMsg');
1082  if (ta) ta.value = el.dataset.body;
1083  // Show coach assignment info
1084  var coachBadge = document.getElementById('restartCoachBadge');
1085  if (coachBadge && el.dataset.coachname) {
1086    coachBadge.innerHTML = '<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 10px;background:#eef2ff;color:#4f46e5;border-radius:6px;font-size:11px;font-weight:600;">\u2192 Will assign to: ' + escHtml(el.dataset.coachname) + '</span>';
1087  }
1088  // Show message preview
1089  var preview = document.getElementById('restartMsgPreview');
1090  if (preview) {
1091    preview.style.display = 'block';
1092    preview.innerHTML = '<div style="font-weight:600;font-size:11px;color:#666;margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px;">Message preview</div>' +
1093      '<div style="background:#dcf8c6;padding:10px 14px;border-radius:0 12px 12px 12px;font-size:13px;line-height:1.5;color:#1e1b4b;max-width:85%;white-space:pre-wrap;">' + escHtml(el.dataset.body || '') + '</div>';
1094  }
1095}
1096
1097window.sendRestartAI = async function(contactId, within24h) {
1098  var customMsg = document.getElementById('restartCustomMsg');
1099  var selectedEl = document.querySelector('#restartMsgList .selected');
1100  var messageText = customMsg ? customMsg.value.trim() : (selectedEl ? selectedEl.dataset.body : '');
1101
1102  var selectedEl2 = document.querySelector('#restartMsgList .selected');
1103  var coachId = selectedEl2 ? selectedEl2.dataset.coachid : '';
1104  var coachName = selectedEl2 ? selectedEl2.dataset.coachname : '';
1105  var templateKey = selectedEl2 ? (selectedEl2.dataset.templatekey || 'coach_beta_welcome') : 'coach_beta_welcome';
1106
1107  if (!within24h) {
1108    if (!messageText) { showToast('Please select a message template', 'warning'); return; }
1109    // Confirmation with coach assignment
1110    // Show inline confirmation instead of browser dialog
1111    var sendBtn = document.querySelector('#restartAIModal [data-send-btn]');
1112    if (sendBtn && !sendBtn.dataset.confirmed) {
1113      sendBtn.dataset.confirmed = 'true';
1114      sendBtn.textContent = coachName ? '\u2705 Confirm — assign to ' + coachName + ' & send' : '\u2705 Confirm & Send';
1115      sendBtn.style.background = '#059669';
1116      showToast('Click again to confirm', 'info');
1117      return;
1118    }
1119    try {
1120      var result = await api('/contacts/' + contactId + '/restart-ai', {
1121        method: 'POST',
1122        body: JSON.stringify({ messageType: 'template', templateKey: templateKey, bodyText: messageText, coachId: coachId ? parseInt(coachId) : null })
1123      });
1124      document.getElementById('restartAIModal').remove();
1125      showToast('Template sent! AI will activate when they reply.', 'success');
1126      if (result && result.coach) { refreshCoachBadge(result.coach.coach_name); }
1127      // Update coach badge in header
1128      if (result && result.coach) {
1129        var badge = document.querySelector('.coach-badge, [data-coach-badge]');
1130        if (badge) { badge.textContent = result.coach.coach_name; badge.style.background = '#eef2ff'; badge.style.color = '#4f46e5'; }
1131      }
1132      if (typeof openConversation === 'function') openConversation(contactId);
1133    } catch (err) {
1134      showToast('Error: ' + (err.message || 'Send failed'), 'error');
1135    }
1136    return;
1137  }
1138
1139  if (!messageText) {
1140    showToast('Please select or type a message', 'warning');
1141    return;
1142  }
1143
1144  try {
1145    var result2 = await api('/contacts/' + contactId + '/restart-ai', {
1146      method: 'POST',
1147      body: JSON.stringify({ messageType: 'freeform', messageText: messageText, coachId: coachId ? parseInt(coachId) : null })
1148    });
1149    document.getElementById('restartAIModal').remove();
1150    showToast('Message sent! AI coach activated.', 'success');
1151    if (result2 && result2.coach) { refreshCoachBadge(result2.coach.coach_name); }
1152    if (result2 && result2.coach) {
1153      var badge2 = document.querySelector('.coach-badge, [data-coach-badge]');
1154      if (badge2) { badge2.textContent = result2.coach.coach_name; badge2.style.background = '#eef2ff'; badge2.style.color = '#4f46e5'; }
1155    }
1156    if (typeof openConversation === 'function') openConversation(contactId);
1157  } catch (err) {
1158    showToast('Error: ' + (err.message || 'Send failed'), 'error');
1159  }
1160}
1161
1162window.resumeAI = async function(contactId) {
1163    var overlay = document.createElement('div');
1164    overlay.id = 'resumeAIOverlay';
1165    overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;';
1166    
1167    var lastInbound = selectedMessages.filter(function(m) { return m.direction === 'inbound'; }).pop();
1168    var preview = lastInbound ? '\u201c' + escHtml((lastInbound.message || '').substring(0, 80)) + '\u201d' : 'No pending message';
1169
1170    // Fetch current coach assignment + coach list
1171    var resumeCoachInfo = '';
1172    var resumeCoachSelect = '';
1173    try {
1174      var aiStatus = await api('/contacts/' + contactId + '/ai-status');
1175      var coachList = await api('/coaches');
1176      var currentAssign = conversations.find(function(c) { return c.profile_id === contactId; });
1177      var currentCoachName = (currentAssign && currentAssign.coach_name) || (aiStatus && aiStatus.coach_name) || 'Unknown';
1178      var currentCoachId = (currentAssign && currentAssign.coach_id) || '';
1179      resumeCoachInfo = '<div style="background:#eef2ff;border-radius:8px;padding:10px 14px;margin-bottom:16px;display:flex;align-items:center;gap:8px;">' +
1180        '<span style="font-size:14px;">\uD83E\uDD16</span>' +
1181        '<span style="font-size:12px;font-weight:600;color:#4f46e5;">Current coach: ' + escHtml(currentCoachName) + '</span>' +
1182      '</div>';
1183      if (coachList && coachList.length > 0) {
1184        resumeCoachSelect = '<div style="margin-bottom:16px;">' +
1185          '<label style="font-size:11px;font-weight:600;color:#666;display:block;margin-bottom:4px;">Change coach (optional):</label>' +
1186          '<select id="resumeCoachSelect" style="width:100%;padding:8px 10px;border:1px solid #e0e0e0;border-radius:8px;font-size:13px;font-family:Inter,sans-serif;background:#fff;">' +
1187            '<option value="">Keep current (' + escHtml(currentCoachName) + ')</option>';
1188        for (var ci = 0; ci < coachList.length; ci++) {
1189          resumeCoachSelect += '<option value="' + coachList[ci].id + '">' + escHtml(coachList[ci].name) + '</option>';
1190        }
1191        resumeCoachSelect += '</select></div>';
1192      }
1193    } catch(e) { console.warn('[Resume] Could not fetch coach info:', e); }
1194
1195    overlay.innerHTML = '<div style="background:#fff;border-radius:16px;padding:28px 32px;max-width:440px;width:90%;box-shadow:0 20px 60px rgba(0,0,0,0.3);">
1195' +
1196      '<h3 style="margin:0 0 8px;font-size:18px;color:#1e1b4b;">Resume AI Coach</h3>' +
1197      '<p style="margin:0 0 12px;font-size:13px;color:#666;">How should the AI coach resume?</p>' +
1198      resumeCoachInfo +
1199      resumeCoachSelect +
1200      '<div style="background:#f8f9fa;border-radius:10px;padding:14px 16px;margin-bottom:16px;border:1px solid #e0e0e0;">' +
1201        '<div style="font-size:11px;color:#999;margin-bottom:4px;">Last customer message:</div>' +
1202        '<div style="font-size:13px;color:#312e81;font-style:italic;">' + preview + '</div>' +
1203      '</div>' +
1204      '<button data-cid="' + contactId + '" data-mode="reply_now" class="resumeBtn" style="width:100%;padding:12px;background:#6c63ff;color:#fff;border:none;border-radius:10px;font-size:14px;font-weight:500;cursor:pointer;margin-bottom:10px;font-family:Inter,sans-serif;">Reply Now \u2014 Answer their last message</button>' +
1205      '<button data-cid="' + contactId + '" data-mode="wait" class="resumeBtn" style="width:100%;padding:12px;background:#fff;color:#1e1b4b;border:1px solid #e0e0e0;border-radius:10px;font-size:14px;font-weight:500;cursor:pointer;margin-bottom:10px;font-family:Inter,sans-serif;">Wait \u2014 Respond to next message only</button>' +
1206      '<button class="resumeCancelBtn" style="width:100%;padding:10px;background:transparent;color:#999;border:none;font-size:13px;cursor:pointer;font-family:Inter,sans-serif;">Cancel</button>' +
1207    '</div>';
1208    
1209    document.body.appendChild(overlay);
1210    overlay.addEventListener('click', function(e) {
1211      if (e.target === overlay) overlay.remove();
1212      if (e.target.classList.contains('resumeCancelBtn')) overlay.remove();
1213      if (e.target.classList.contains('resumeBtn')) {
1214        var cid = parseInt(e.target.dataset.cid);
1215        var mode = e.target.dataset.mode;
1216        // Read coach select BEFORE removing overlay from DOM
1217        var coachSelect = document.getElementById('resumeCoachSelect');
1218        var selectedCoachId = (coachSelect && coachSelect.value) ? parseInt(coachSelect.value) : null;
1219        overlay.remove();
1220        doResumeAI(cid, mode, selectedCoachId);
1221      }
1222    });
1223  };
1224
1225  window.doResumeAI = async function(contactId, mode, newCoachId) {
1226    try {
1227      await api('/contacts/' + contactId + '/resume-ai', { 
1228        method: 'POST', 
1229        headers: { 'Content-Type': 'application/json' }, 
1230        body: JSON.stringify({ mode: mode, coachId: newCoachId }) 
1231      });
1232      var rConv = conversations.find(function(c) { return c.profile_id === contactId; });
1233      if (rConv) rConv.agent_paused = false;
1234      showToast(mode === 'reply_now' ? 'AI coach resumed \u2014 replying now...' : 'AI coach resumed \u2014 waiting for next message');
1235      setTimeout(function() { openConversation(contactId); }, mode === 'reply_now' ? 3000 : 500);
1236    } catch(e) { showToast('Failed: ' + e.message, 'error'); }
1237  };
1238
1239
1240
1241
1242  // ── Pipeline Mode ──
1243  window._switchTab = function(tab) {
1244    if (tab === 'pipeline') {
1245      pipelineMode = true;
1246      channelFilter = 'pipeline';
1247      loadPipelineView();
1248    } else if (tab === 'activity') {
1249      pipelineMode = true;
1250      channelFilter = 'activity';
1251      loadActivityView();
1252    } else {
1253      pipelineMode = false;
1254      if (pipelinePollTimer) { clearInterval(pipelinePollTimer); pipelinePollTimer = null; }
1255      channelFilter = tab;
1256      console.log('[MSG-DIAG] _switchTab to "' + tab + '", conversations loaded:', conversations.length);
1257      renderInbox();
1258    }
1259  };
1260
1261  async function loadPipelineView() {
1262    var container = document.getElementById('page-messages-home');
1263    if (!container) return;
1264
1265    var coachParam = coachFilterId ? '?coach_id=' + coachFilterId : '';
1266    try {
1267      var viewData = await api('/pipeline/view' + coachParam);
1268      pipelineData = (viewData && viewData.contacts) || [];
1269      pipelineMetrics = { stages: (viewData && viewData.stages) || [], in_hours_now: viewData && viewData.metrics ? viewData.metrics.in_hours_now : 0 };
1270      // Also update funnelData so the funnel bar renders from same source
1271      if (viewData && viewData.stages) {
1272        funnelData = { stages: viewData.stages, totalContacts: viewData.metrics ? viewData.metrics.total : 0, unassessed: viewData.metrics ? viewData.metrics.unassessed : 0 };
1273      }
1274    } catch (e) {
1275      pipelineData = [];
1276      pipelineMetrics = {};
1277    }
1278    renderPipelineView(container);
1279
1280    // Render funnel bar with action buttons from same data
1281    var funnelEl = document.getElementById('msgFunnelBar');
1282    if (funnelEl && funnelData && funnelData.stages && funnelData.stages.length > 0) {
1283      renderFunnelBarFromData(funnelEl, funnelData, coachFilterId || (coachesList.find(function(c) { return c.status === 'active'; }) || {}).id);
1284    }
1285
1286    // Auto-refresh every 30s from same endpoint
1287    if (pipelinePollTimer) clearInterval(pipelinePollTimer);
1288    pipelinePollTimer = setInterval(async function() {
1289      try {
1290        var r = await api('/pipeline/view' + coachParam);
1291        if (r && r.contacts) {
1292          pipelineData = r.contacts;
1293          pipelineMetrics = { stages: r.stages || [], in_hours_now: r.metrics ? r.metrics.in_hours_now : 0 };
1294          if (r.stages) funnelData = { stages: r.stages, totalContacts: r.metrics ? r.metrics.total : 0, unassessed: r.metrics ? r.metrics.unassessed : 0 };
1295          renderPipelineList();
1296          // Also update health bar and funnel bar
1297          var hb = document.querySelector('[style*="fff9e6"]');
1298          if (hb) hb.outerHTML = renderHealthBar();
1299          var fb = document.getElementById('msgFunnelBar');
1300          if (fb && funnelData) renderFunnelBarFromData(fb, funnelData, coachFilterId || (coachesList.find(function(c) { return c.status === 'active'; }) || {}).id);
1301        }
1302      } catch(e) {}
1303    }, 30000);
1304  }
1305
1306  // Render funnel bar from pre-fetched data (no separate API call)
1307  function renderFunnelBarFromData(el, data, coachId) {
1308    if (!data || !data.stages || data.stages.length === 0) { el.innerHTML = ''; return; }
1309    var stages = data.stages;
1310    var maxCount = Math.max(1, Math.max.apply(null, stages.map(function(s) { return s.count; })));
1311    var html = '<div style="padding:12px 16px;border-bottom:1px solid #e0e0e0;background:#fafafa;">' +
1312      '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">' +
1313        '<span style="font-size:13px;font-weight:600;">Pipeline</span>';
1314    if (coachId) {
1315      html += ' <button onclick="window._suggestFunnelSteps(' + coachId + ')" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 8px;font-size:10px;color:#666;cursor:pointer;">Update Steps</button>' +
1316        ' <button onclick="window._reassessFunnel(' + coachId + ')" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 8px;font-size:10px;color:#666;cursor:pointer;">Reassess</button>' +
1317        ' <button onclick="window._editFunnelSteps(' + coachId + ')" style="background:none;border:1px solid #ddd;border-radius:4px;padding:2px 8px;font-size:10px;color:#666;cursor:pointer;">Edit</button>';
1318    }
1319    html += '</div><div style="display:flex;align-items:flex-end;gap:2px;height:60px;">';
1320    stages.forEach(function(stage) {
1321      var barH = Math.max(6, Math.round((stage.count / maxCount) * 40));
1322      var isFiltered = (stageFilter === stage.id);
1323      html += '<div style="flex:1;text-align:center;cursor:pointer;' + (isFiltered ? 'background:#f0f0ff;border-radius:6px;' : '') + '" onclick="window._msgStageFilter(\'' + stage.id + '\')" title="' + escHtml(stage.name) + '">' +
1324        '<div style="font-size:10px;font-weight:700;color:' + stage.color + ';">' + stage.count + '</div>' +
1325        '<div style="height:' + barH + 'px;background:' + stage.color + ';border-radius:3px 3px 0 0;margin:0 2px;"></div>' +
1326        '<div style="font-size:8px;color:#888;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + escHtml(stage.name) + '</div>' +
1327      '</div>';
1328    });
1329    html += '</div></div>';
1330    el.innerHTML = html;
1331  }
1332
1333  function renderPipelineView(container) {
1334    var totalCount = conversations.length;
1335    var waCount = conversations.filter(function(c) { return c.channel === 'whatsapp'; }).length;
1336    var webCount = conversations.filter(function(c) { return c.channel === 'webchat'; }).length;
1337
1338    container.innerHTML =
1339      '<div style="position:relative;height:100%;display:flex;flex-direction:column;">' +
1340
1341        renderHealthBar() +
1342
1343        '<div style="display:flex;border-bottom:1px solid #e0e0e0;gap:0;">' +
1344          channelTab('pipeline', '📋 Pipeline', '') +
1345          channelTab('activity', '📊 Activity', '') +
1346          channelTab('all', 'All', totalCount) +
1347          channelTab('whatsapp', '🟢 WhatsApp', waCount) +
1348          channelTab('webchat', '💬 Web Chat', webCount) +
1349        '</div>' +
1350
1351        (coachesList.length > 0 ? '<div style="display:flex;gap:6px;padding:8px 16px;border-bottom:1px solid #e0e0e0;flex-wrap:wrap;background:#fafafa;">' +
1352          '<span onclick="window._msgCoachFilter(null)" style="padding:4px 12px;border-radius:16px;font-size:11px;font-weight:600;cursor:pointer;' + (!coachFilterId ? 'background:#4f46e5;color:#fff;' : 'background:#f0f0f0;color:#666;') + '">All Coaches</span>' +
1353          coachesList.filter(function(c) { return c.status === 'active'; }).map(function(c) {
1354            return '<span onclick="window._msgCoachFilter(' + c.id + ')" style="padding:4px 12px;border-radius:16px;font-size:11px;font-weight:600;cursor:pointer;' + (coachFilterId === c.id ? 'background:#4f46e5;color:#fff;' : 'background:#f0f0f0;color:#666;') + '">' + escHtml(c.name) + '</span>';
1355          }).join('') +
1356        '</div>' : '') +
1357
1358        '<div id="msgFunnelBar"></div>' +
1359        '<div style="display:flex;flex:1;min-height:0;overflow:hidden;">' +
1360          '<div style="width:420px;min-width:340px;border-right:1px solid #e0e0e0;overflow-y:auto;" id="pipelineListPane">' +
1361          '</div>' +
1362          '<div style="flex:1;overflow-y:auto;background:#fafafa;" id="msgPreviewPane">' +
1363            '<div style="text-align:center;padding:60px;color:#999;">Select a contact to view conversation</div>' +
1364          '</div>' +
1365        '</div>' +
1366      '</div>';
1367
1368    renderPipelineList();
1369  }
1370
1371  var priorityColors = {
1372    'PENDING_APPROVAL': '#3b82f6', 'OVERDUE': '#ef4444', 'DUE_SOON': '#f59e0b',
1373    'SLEEPING': '#8b5cf6', 'WAITING': '#9ca3af', 'STALE': '#4b5563', 'TAKEN_OVER': '#f97316'
1374  };
1375
1376  var tzDots = { 'green': '🟢', 'amber': '🟡', 'red': '🔴', 'grey': '⚪' };
1377
1378  function renderPipelineList() {
1379    var list = document.getElementById('pipelineListPane');
1380    if (!list) return;
1381
1382    var displayData = pipelineData;
1383    if (stageFilter) {
1384      displayData = pipelineData.filter(function(item) { return item.funnel_stage === stageFilter; });
1385    }
1386
1387    if (displayData.length === 0) {
1388      var msg = stageFilter ? 'No contacts in this stage' : 'No active pipeline items';
1389      list.innerHTML = (stageFilter ? '<div style="padding:8px 16px;"><button onclick="window._msgStageFilter(null)" style="padding:4px 12px;border:1px solid #ccc;border-radius:12px;font-size:11px;cursor:pointer;background:transparent;">✕ Clear filter</button></div>' : '') +
1390        '<div style="text-align:center;padding:60px 20px;color:#999;"><div style="font-size:32px;margin-bottom:12px;">📋</div>' + msg + '</div>';
1391      return;
1392    }
1393
1394    list.innerHTML = (stageFilter ? '<div style="padding:8px 16px;"><button onclick="window._msgStageFilter(null)" style="padding:4px 12px;border:1px solid #ccc;border-radius:12px;font-size:11px;cursor:pointer;background:transparent;">✕ Clear filter</button></div>' : '') + displayData.map(function(item) {
1395      var silenceText = item.silence_hours !== null
1396        ? (item.silence_hours < 1 ? Math.round(item.silence_hours * 60) + 'm ago' : Math.round(item.silence_hours) + 'h ago')
1397        : 'No messages';
1398      var windowText = item.wa_window_open
1399        ? '<span style="color:#22c55e;">🟢 Window open (' + item.wa_window_remaining_hours + 'h)</span>'
1400        : '<span style="color:#ef4444;">🔴 Template only</span>';
1401      var pColor = priorityColors[item.priority] || '#999';
1402
1403      return '<div data-assignment="' + item.assignment_id + '" data-profile="' + item.profile_id + '" onclick="window._selectPipelineItem(this)" style="padding:12px 16px;
1403border-bottom:1px solid #f0f0f0;cursor:pointer;transition:background 0.15s;" onmouseenter="this.style.background=\"#f5f5f5\"" onmouseleave="this.style.background=\"\"" >' +
1404        '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">' +
1405          '<span>' + (tzDots[item.tz_status] || '⚪') + '</span>' +
1406          '<strong style="font-size:13px;">' + escHtml(item.display_name || 'Unknown') + '</strong>' +
1407          '<span style="font-size:11px;color:#888;">' + escHtml(item.primary_phone || '') + '</span>' +
1408          (item.coach_name ? '<span style="font-size:10px;background:#f0f0f0;padding:1px 6px;border-radius:8px;color:#666;">' + escHtml(item.coach_name) + '</span>' : '') +
1409        '</div>' +
1410        '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">' +
1411          '<span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;color:#fff;background:' + (item.stage_color || '#999') + ';">' + escHtml(item.stage_name) + '</span>' +
1412          '<span style="font-size:11px;color:#888;">Last heard: ' + silenceText + '</span>' +
1413          '<span style="font-size:10px;">' + windowText + '</span>' +
1414        '</div>' +
1415        '<div style="display:flex;align-items:center;gap:8px;">' +
1416          '<span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;color:#fff;background:' + pColor + ';">' + item.priority.replace('_', ' ') + '</span>' +
1417          '<span style="font-size:11px;color:#666;">' + escHtml(item.action) + '</span>' +
1418        '</div>' +
1419        '<div style="display:flex;gap:6px;margin-top:6px;">' +
1420          '<button data-aid="' + item.assignment_id + '" data-action="takeover" style="padding:3px 8px;border:1px solid #ddd;border-radius:4px;background:#fff;font-size:10px;cursor:pointer;">⏸️ Take over</button>' +
1421          (item.manager_override ? '<button data-aid="' + item.assignment_id + '" data-action="handback" style="padding:3px 8px;border:1px solid #ddd;border-radius:4px;background:#fff;font-size:10px;cursor:pointer;">🔁 Hand back</button>' : '') +
1422          '<button data-aid="' + item.assignment_id + '" data-action="archive" style="padding:3px 8px;border:1px solid #ddd;border-radius:4px;background:#fff;font-size:10px;cursor:pointer;">🗑️ Archive</button>' +
1423        '</div>' +
1424      '</div>';
1425    }).join('');
1426  }
1427
1428  // _selectPipelineItem defined below with full summary card
1429
1430  window._pipeAction = async function(assignmentId, action) {
1431    try {
1432      await api('/pipeline/' + assignmentId + '/' + action, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
1433      loadPipelineView();
1434    } catch (e) {
1435      console.error('[Pipeline] Action failed:', e.message);
1436    }
1437  };
1438
1439
1440// ── Activity Feed ──
1441  var activityFilter = 'all';
1442
1443
1444  // Template cache for follow-up selector
1445  var _templateCache = null;
1446  async function loadTemplates() {
1447    if (_templateCache) return _templateCache;
1448    try {
1449      var result = await api('/pipeline/templates');
1450      _templateCache = (result && result.templates) || [];
1451      return _templateCache;
1452    } catch (e) {
1453      return [];
1454    }
1455  }
1456
1457  async function loadActivityView() {
1458    var container = document.getElementById('page-messages-home');
1459    if (!container) return;
1460
1461    var filterParam = activityFilter !== 'all' ? '&filter=' + activityFilter : '';
1462    var statsResult, actResult, pendingResult;
1463    try {
1464      statsResult = await api('/pipeline/stats');
1465      actResult = await api('/pipeline/activity?limit=100' + filterParam);
1466    } catch (e) {
1467      statsResult = { stats: {} };
1468      actResult = { activity: [] };
1469    }
1470
1471    // Fetch pending follow-ups for approval section
1472    try {
1473      pendingResult = await api('/pipeline/follow-ups/pending');
1474    } catch (e) {
1475      pendingResult = { pending: [] };
1476    }
1477
1478    var st = statsResult.stats || {};
1479    var items = (actResult && actResult.activity) || [];
1480    var pendingItems = (pendingResult && pendingResult.pending) || [];
1481
1482    container.innerHTML =
1483      '<div style="position:relative;height:100%;display:flex;flex-direction:column;">' +
1484
1485        '<div style="background:#f0f4ff;border-bottom:1px solid #c7d2fe;padding:14px 24px;display:flex;align-items:center;gap:16px;">' +
1486          '<span style="font-size:20px;">📊</span>' +
1487          '<div style="display:flex;gap:16px;font-size:13px;">' +
1488            '<span><strong>' + (st.active || 0) + '</strong> active</span>' +
1489            '<span><strong>' + pendingItems.length + '</strong> follow-ups pending</span>' +
1490            '<span><strong>' + (st.stale || 0) + '</strong> stale</span>' +
1491            '<span><strong>' + (st.stopped || 0) + '</strong> stopped</span>' +
1492          '</div>' +
1493        '</div>' +
1494
1495        '<div style="display:flex;border-bottom:1px solid #e0e0e0;gap:0;">' +
1496          channelTab('pipeline', '📋 Pipeline', '') +
1497          channelTab('activity', '📊 Activity', '') +
1498          channelTab('all', 'All', conversations.length) +
1499        '</div>' +
1500
1501        (pendingItems.length > 0 ? renderPendingApprovals(pendingItems) : '') +
1502
1503        '<div style="display:flex;gap:6px;padding:8px 16px;border-bottom:1px solid #e0e0e0;background:#fafafa;">
1503' +
1504          actFilterBtn('all', 'All') +
1505          actFilterBtn('followups', 'Follow-ups') +
1506          actFilterBtn('stages', 'Stage changes') +
1507          actFilterBtn('escalations', 'Escalations') +
1508        '</div>' +
1509
1510        '<div style="flex:1;overflow-y:auto;padding:0;" id="activityList">' +
1511          renderActivityItems(items) +
1512        '</div>' +
1513      '</div>';
1514  }
1515
1516  function renderPendingApprovals(items) {
1517    var html = '<div style="border-bottom:2px solid #f59e0b;background:#fffbeb;">' +
1518      '<div style="padding:10px 16px;display:flex;align-items:center;gap:8px;border-bottom:1px solid #fde68a;">' +
1519        '<span style="font-size:16px;">⏳</span>' +
1520        '<span style="font-size:13px;font-weight:700;color:#92400e;">Pending Approvals (' + items.length + ')</span>' +
1521      '</div>';
1522
1523    items.forEach(function(item) {
1524      var silenceText = item.silence_hours ? Math.round(item.silence_hours) + 'h silent' : '';
1525      var stageText = item.funnel_stage ? item.funnel_stage.replace(/_/g, ' ') : '';
1526      var attemptText = 'Attempt ' + item.attempt_number + '/' + item.max_attempts;
1527      var typeIcon = item.message_type === 'freeform' ? '💬' : '📋';
1528      var d = new Date(item.created_at);
1529      var timeStr = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
1530
1531      html += '<div style="border-bottom:1px solid #fde68a;" data-fq-id="' + item.id + '">' +
1532        '<div style="padding:12px 16px;cursor:pointer;" data-expand-pending="' + item.profile_id + '" data-fq-row="' + item.id + '">' +
1533          '<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">' +
1534            '<div>' +
1535              '<strong style="font-size:13px;">' + escHtml(item.display_name || 'Unknown') + '</strong> ' +
1536              '<span style="font-size:11px;color:#92400e;background:#fef3c7;padding:2px 6px;border-radius:8px;margin-left:4px;">' + attemptText + '</span>' +
1537              '<span style="font-size:11px;color:#666;margin-left:6px;">' + typeIcon + ' ' + (item.message_type || '') + '</span>' +
1538            '</div>' +
1539            '<span style="font-size:11px;color:#999;">' + timeStr + '</span>' +
1540          '</div>' +
1541          '<div style="font-size:11px;color:#666;margin-bottom:8px;">' +
1542            (stageText ? '📈 ' + stageText + ' · ' : '') + (silenceText ? '🕐 ' + silenceText : '') +
1543            (item.coach_name ? ' · 🤖 ' + escHtml(item.coach_name) : '') +
1544          '</div>' +
1545          '<div style="background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:10px 12px;margin-bottom:10px;font-size:13px;color:#374151;line-height:1.5;font-style:italic;">' +
1546            '"' + escHtml(item.draft_message || '[template message]') + '"' +
1547          '</div>' +
1548        '</div>' +
1549        '<div id="convo-preview-' + item.id + '" style="display:none;"></div>' +
1550        '<div style="display:flex;gap:8px;align-items:center;padding:0 16px 12px;">' +
1551          '<button data-fq-approve="' + item.id + '" style="padding:6px 14px;border:none;border-radius:6px;background:#059669;color:#fff;cursor:pointer;font-size:12px;font-weight:600;">✓ Send</button>' +
1552          '<button data-fq-skip="' + item.id + '" style="padding:6px 14px;border:none;border-radius:6px;background:#f0f0f0;color:#666;cursor:pointer;font-size:12px;font-weight:600;">Skip</button>' +
1553          '<button data-fq-edit="' + item.id + '" data-draft="' + encodeURIComponent(item.draft_message || '') + '" style="padding:6px 14px;border:none;border-radius:6px;background:#f0f0f0;color:#666;cursor:pointer;font-size:12px;font-weight:600;">✏️ Edit & Send</button>' +
1554          '<button data-fq-template="' + item.id + '" style="padding:6px 14px;border:none;border-radius:6px;background:#dbeafe;color:#1e40af;cursor:pointer;font-size:12px;font-weight:600;">📋 Use Template</button>' +
1555          '<div style="flex:1;"></div>' +
1556          '<button data-fq-voice="' + item.id + '" data-profile-id="' + item.profile_id + '" data-coach-id="' + (item.coach_id || '') + '" style="padding:6px 10px;border:none;border-radius:6px;background:#f0f0f0;color:#666;cursor:pointer;font-size:14px;" title="Voice feedback to AI">🎤</button>' +
1557        '</div>' +
1558      '</div>';
1559    });
1560
1561    html += '</div>';
1562    return html;
1563  }
1564
1565  function renderConvoPreview(messages) {
1566    if (!messages || messages.length === 0) {
1567      return '<div style="padding:12px 16px;color:#999;font-size:12px;font-style:italic;">No conversation history</div>';
1568    }
1569    var html = '<div style="padding:8px 16px 12px;max-height:250px;overflow-y:auto;background:#f9fafb;border-top:1px solid #e5e7eb;border-bottom:1px solid #e5e7eb;">';
1570    messages.forEach(function(m) {
1571      var isInbound = m.direction === 'inbound';
1572      var d = new Date(m.sent_at);
1573      var time = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
1574      var date = App.Dates.short(d, { year: false });
1575      var isTemplate = (m.message || '').indexOf('[template:') === 0 || (m.metadata && m.metadata.is_template);
1576      var displayMsg = m.message || '';
1577      // Template SIDs now resolved by backend
1578      html += '<div style="display:flex;flex-direction:column;align-items:' + (isInbound ? 'flex-start' : 'flex-end') + ';margin-bottom:6px;">' +
1579        '<div style="max-width:80%;padding:8px 12px;border-radius:12px;font-size:12px;line-height:1.4;' +
1580          (isInbound ? 'background:#e5e7eb;color:#1f2937;' : 
1581            (isTemplate ? 'background:#dbeafe;color:#1e40af;border:1px solid #93c5fd;' : 'background:#4f46e5;color:#fff;')) + '"' +
1582          (isTemplate ? ' title="Sent via WhatsApp template"' : '') + '>' +
1583          escHtml(displayMsg) +
1584          (isTemplate ? '<div style="font-size:10px;margin-top:4px;opacity:0.7;">📋 Template</div>' : '') +
1585        '</div>' +
1586        '<span style="font-size:10px;color:#999;margin-top:2px;">' + date + ' ' + time + '</span>' +
1587      '</div>';
1588    });
1589    html += '</div>';
1590    return html;
1591  }
1592
1593  // Expand/collapse conversation preview
1594  document.addEventListener('click', async function(e) {
1595    var expandBtn = e.target.closest('[data-expand-pending]');
1596    if (expandBtn) {
1597      var profileId = expandBtn.dataset.expandPending;
1598      var fqId = expandBtn.dataset.fqRow;
1599      var preview = document.getElementById('convo-preview-' + fqId);
1600      if (!preview) return;
1601      
1602      if (preview.style.display !== 'none') {
1603        preview.style.display = 'none';
1604        return;
1605      }
1606      
1607      preview.innerHTML = '<div style="padding:12px 16px;color:#999;font-size:12px;">Loading conversation...</div>';
1608      preview.style.display = 'block';
1609      
1610      try {
1611        var result = await api('/pipeline/conversation-preview/' + profileId);
1612        preview.innerHTML = renderConvoPreview(result.messages);
1613      } catch (err) {
1614        preview.innerHTML = '<div style="padding:12px 16px;color:#ef4444;font-size:12px;">Failed to load</div>';
1615      }
1616    }
1617  });
1618
1619  // Voice feedback recording
1620  var _voiceRecorder = null;
1621  document.addEventListener('click', async function(e) {
1622    var voiceBtn = e.target.closest('[data-fq-voice]');
1623    if (!voiceBtn) return;
1624    
1625    var fqId = voiceBtn.dataset.fqVoice;
1626    var profileId = voiceBtn.dataset.profileId;
1627    var coachId = voiceBtn.dataset.coachId;
1628    
1629    if (_voiceRecorder) {
1630      // Stop recording
1631      _voiceRecorder.stop();
1632      voiceBtn.textContent = '🎤';
1633      voiceBtn.style.background = '#f0f0f0';
1634      return;
1635    }
1636    
1637    try {
1638      var stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1639      var mediaRecorder = new MediaRecorder(stream);
1640      var chunks = [];
1641      
1642      mediaRecorder.ondataavailable = function(e) { chunks.push(e.data); };
1643      mediaRecorder.onstop = async function() {
1644        _voiceRecorder = null;
1645        stream.getTracks().forEach(function(t) { t.stop(); });
1646        voiceBtn.textContent = '⏳';
1647        
1648        var blob = new Blob(chunks, { type: 'audio/webm' });
1649        var formData = new FormData();
1650        formData.append('file', blob, 'feedback.webm');
1651        
1652        try {
1653          // Transcribe via Whisper
1654          var transcribeResult = await fetch('/api/builder/transcribe', { 
1655            method: 'POST', 
1656            body: formData,
1657            headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('token') || '') }
1658          });
1659          var transcription = await transcribeResult.json();
1660          var text = transcription.text || transcription.transcript || '';
1661          
1662          if (text) {
1663            // Save as feedback event
1664            await api('/pipeline/feedback', {
1665              method: 'POST',
1666              headers: { 'Content-Type': 'application/json' },
1667              body: JSON.stringify({
1668                profile_id: parseInt(profileId),
1669                coach_id: parseInt(coachId),
1670                follow_up_id: parseInt(fqId),
1671                feedback_text: text
1672              })
1673            });
1674            showToast('🎤 Feedback saved: "' + text.substring(0, 60) + '..."');
1675          } else {
1676            showToast('❌ Could not transcribe audio');
1677          }
1678        } catch (err) {
1679          showToast('❌ Transcription failed: ' + (err.message || 'error'));
1680        }
1681        
1682        voiceBtn.textContent = '🎤';
1683        voiceBtn.style.background = '#f0f0f0';
1684      };
1685      
1686      mediaRecorder.start();
1687      _voiceRecorder = mediaRecorder;
1688      voiceBtn.textContent = '⏹️';
1689      voiceBtn.style.background = '#fee2e2';
1690      showToast('🎤 Recording... click again to stop');
1691    } catch (err) {
1692      showToast('❌ Microphone access denied');
1693    }
1694  });
1695
1696
1697  // Event delegation for follow-up approval actions
1698  document.addEventListener('click', async function(e) {
1699    var approveBtn = e.target.closest('[data-fq-approve]');
1700    var skipBtn = e.target.closest('[data-fq-skip]');
1701    var editBtn = e.target.closest('[data-fq-edit]');
1702
1703    if (approveBtn) {
1704      var id = approveBtn.dataset.fqApprove;
1705      approveBtn.disabled = true;
1706      approveBtn.textContent = 'Sending...';
1707      try {
1708        var result = await api('/pipeline/follow-up/' + id + '/approve', { method: 'POST' });
1709        if (result.status === 'expired') {
1710          showToast('⌛ ' + (result.reason || 'Follow-up expired'));
1711        } else {
1712          showToast('✅ Sent to ' + (result.contact || 'contact'));
1713        }
1714        loadActivityView();
1715      } catch (err) {
1716        showToast('❌ ' + (err.message || 'Failed'));
1717        approveBtn.disabled = false;
1718        approveBtn.textContent = '✓ Send';
1719      }
1720    }
1721
1722    if (skipBtn) {
1723      var id = skipBtn.dataset.fqSkip;
1724      skipBtn.disabled = true;
1725      skipBtn.textContent = 'Skipping...';
1726      try {
1727        await api('/pipeline/follow-up/' + id + '/skip', { method: 'POST' });
1728        showToast('⏭️ Skipped');
1729        loadActivityView();
1730      } catch (err) {
1731        showToast('❌ ' + (err.message || 'Failed'));
1732        skipBtn.disabled = false;
1733        skipBtn.textContent = 'Skip';
1734      }
1735    }
1736
1737    if (editBtn) {
1738      var id = editBtn.dataset.fqEdit;
1739      var draft = decodeURIComponent(editBtn.dataset.draft || '');
1740      var newMsg = await App.dialog.ask({ title: 'Edit the follow-up', body: 'Change the message, then send it.', label: 'Message', value: draft, multiline: true, confirmLabel: 'Send this message' });
1741      if (newMsg && newMsg.trim()) {
1742        editBtn.disabled = true;
1743        editBtn.textContent = 'Sending...';
1744        try {
1745          var result = await api('/pipeline/follow-up/' + id + '/approve', {
1746            method: 'POST',
1747            headers: { 'Content-Type': 'application/json' },
1748            body: JSON.stringify({ message: newMsg.trim() })
1749          });
1750          showToast('✅ Custom message sent to ' + (result.contact || 'contact'));
1751          loadActivityView();
1752        } catch (err) {
1753          showToast('❌ ' + (err.message || 'Failed'));
1754          editBtn.disabled = false;
1755          editBtn.textContent = '✏️ Edit & Send';
1756        }
1757      }
1758    }
1759  });
1760
1761
1762  function actFilterBtn(id, label) {
1763    var active = activityFilter === id;
1764    return '<span data-actfilter="' + id + '" style="padding:4px 12px;border-radius:16px;font-size:11px;font-weight:600;cursor:pointer;' +
1765      (active ? 'background:#4f46e5;color:#fff;' : 'background:#f0f0f0;color:#666;') +
1766      '">' + label + '</span>';
1767  }
1768
1769  var eventIcons = {
1770    'reply': '🟢', 'ai_response': '🤖', 'follow_up_sent': '📱', 'follow_up_queued': '⏳',
1771    'follow_up_skipped': '⏭️', 'follow_up_expired': '⌛', 'stage_change': '📈',
1772    'stop_signal': '🛑', 'escalation': '⚠️', 'manager_takeover': '👤', 'manager_handback': '🔁',
1773    'pipeline_stale': '💤', 'signup': '🆕'
1774  };
1775
1776  function renderActivityItems(items) {
1777    if (items.length === 0) {
1778      return '<div style="text-align:center;padding:60px;color:#999;"><div style="font-size:32px;margin-bottom:12px;">📊</div>No activity in the last 7 days</div>';
1779    }
1780
1781    var lastDate = '';
1782    return items.map(function(item) {
1783      var d = new Date(item.created_at);
1784      var dateStr = App.Dates.weekdayShort(d);
1785      var timeStr = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
1786      var icon = eventIcons[item.event_type] || eventIcons[item.event] || '📌';
1787      var summary = item.summary || item.event_type || item.event || '';
1788      var profileId = item.profile_id || '';
1789
1790      var dateDivider = '';
1791      if (dateStr !== lastDate) {
1792        lastDate = dateStr;
1793        dateDivider = '<div style="padding:8px 16px;background:#f5f5f5;font-size:11px;font-weight:600;color:#888;border-bottom:1px solid #e0e0e0;">' + dateStr + '</div>';
1794      }
1795
1796      var uniqueId = 'evt-' + (item.id || Math.random().toString(36).substr(2, 6));
1797
1798      return dateDivider +
1799        '<div style="cursor:pointer;
1799border-bottom:1px solid #f0f0f0;" data-expand-event="' + profileId + '" data-evt-id="' + uniqueId + '">' +
1800          '<div style="padding:10px 16px;display:flex;align-items:flex-start;gap:10px;">' +
1801            '<span style="font-size:14px;margin-top:1px;">' + icon + '</span>' +
1802            '<div style="flex:1;min-width:0;">' +
1803              '<div style="font-size:13px;">' +
1804                '<strong>' + escHtml(item.display_name || 'Unknown') + '</strong> ' +
1805                '<span style="color:#666;">' + escHtml(summary) + '</span>' +
1806              '</div>' +
1807            '</div>' +
1808            '<span style="font-size:11px;color:#999;white-space:nowrap;">' + timeStr + '</span>' +
1809          '</div>' +
1810          '<div id="' + uniqueId + '" style="display:none;"></div>' +
1811        '</div>';
1812    }).join('');
1813  }
1814
1815  // Expand/collapse activity event conversation
1816  document.addEventListener('click', async function(e) {
1817    var eventRow = e.target.closest('[data-expand-event]');
1818    if (!eventRow) return;
1819    // Don't trigger if clicking a button inside
1820    if (e.target.closest('button') || e.target.closest('[data-fq-approve]') || e.target.closest('[data-fq-skip]')) return;
1821    
1822    var profileId = eventRow.dataset.expandEvent;
1823    if (!profileId) return;
1824    var evtId = eventRow.dataset.evtId;
1825    var preview = document.getElementById(evtId);
1826    if (!preview) return;
1827    
1828    if (preview.style.display !== 'none') {
1829      preview.style.display = 'none';
1830      return;
1831    }
1832    
1833    preview.innerHTML = '<div style="padding:12px 16px;color:#999;font-size:12px;">Loading conversation...</div>';
1834    preview.style.display = 'block';
1835    
1836    try {
1837      var result = await api('/pipeline/conversation-preview/' + profileId);
1838      preview.innerHTML = renderConvoPreview(result.messages);
1839    } catch (err) {
1840      preview.innerHTML = '<div style="padding:12px 16px;color:#ef4444;font-size:12px;">Failed to load</div>';
1841    }
1842  });
1843
1844
1845  // Delegation for activity filter buttons
1846  document.addEventListener('click', function(e) {
1847    var btn = e.target.closest('[data-actfilter]');
1848    if (btn) {
1849      activityFilter = btn.dataset.actfilter;
1850      loadActivityView();
1851    }
1852  });
1853
1854
1855
1856  // Template selector for follow-ups
1857  document.addEventListener('click', async function(e) {
1858    var tplBtn = e.target.closest('[data-fq-template]');
1859    if (!tplBtn) return;
1860    
1861    var fqId = tplBtn.dataset.fqTemplate;
1862    tplBtn.disabled = true;
1863    tplBtn.textContent = 'Loading...';
1864    
1865    var templates = await loadTemplates();
1866    if (templates.length === 0) {
1867      showToast('No templates available');
1868      tplBtn.disabled = false;
1869      tplBtn.textContent = '📋 Use Template';
1870      return;
1871    }
1872    
1873    // Build template picker modal
1874    var overlay = document.createElement('div');
1875    overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center;';
1876    
1877    var modal = document.createElement('div');
1878    modal.style.cssText = 'background:#fff;border-radius:12px;max-width:500px;width:90%;max-height:80vh;overflow-y:auto;padding:0;';
1879    
1880    var html = '<div style="padding:16px;border-bottom:1px solid #e0e0e0;">' +
1881      '<div style="display:flex;justify-content:space-between;align-items:center;">' +
1882        '<h3 style="margin:0;font-size:16px;">Select Template</h3>' +
1883        '<button id="close-tpl-modal" style="background:none;border:none;font-size:20px;cursor:pointer;color:#666;">×</button>' +
1884      '</div>' +
1885    '</div>';
1886    
1887    templates.forEach(function(t) {
1888      html += '<div data-select-tpl="' + t.sid + '" data-fq-target="' + fqId + '" style="padding:12px 16px;border-bottom:1px solid #f0f0f0;cursor:pointer;transition:background 0.15s;" onmouseover="this.style.background=\'#f5f5ff\'" onmouseout="this.style.background=\'\'">'+
1889        '<div style="font-size:13px;font-weight:600;color:#1e40af;margin-bottom:4px;">' + escHtml(t.name || t.sid) + '</div>' +
1890        '<div style="font-size:12px;color:#666;line-height:1.4;">' + escHtml(t.body || '[no preview]') + '</div>' +
1891      '</div>';
1892    });
1893    
1894    modal.innerHTML = html;
1895    overlay.appendChild(modal);
1896    document.body.appendChild(overlay);
1897    
1898    tplBtn.disabled = false;
1899    tplBtn.textContent = '📋 Use Template';
1900    
1901    // Close modal
1902    overlay.addEventListener('click', function(ev) {
1903      if (ev.target === overlay || ev.target.id === 'close-tpl-modal') {
1904        document.body.removeChild(overlay);
1905      }
1906    });
1907    document.getElementById('close-tpl-modal').addEventListener('click', function() {
1908      document.body.removeChild(overlay);
1909    });
1910    
1911    // Select template
1912    overlay.addEventListener('click', async function(ev) {
1913      var sel = ev.target.closest('[data-select-tpl]');
1914      if (!sel) return;
1915      var sid = sel.dataset.selectTpl;
1916      var targetFqId = sel.dataset.fqTarget;
1917      
1918      sel.style.background = '#dbeafe';
1919      sel.innerHTML = '<div style="padding:8px;text-align:center;color:#1e40af;font-weight:600;">Sending...</div>';
1920      
1921      try {
1922        var result = await api('/pipeline/follow-up/' + targetFqId + '/send-template', {
1923          method: 'POST',
1924          headers: { 'Content-Type': 'application/json' },
1925          body: JSON.stringify({ template_sid: sid })
1926        });
1927        document.body.removeChild(overlay);
1928        showToast('✅ Template sent to ' + (result.contact || 'contact'));
1929        loadActivityView();
1930      } catch (err) {
1931        showToast('❌ ' + (err.message || 'Failed'));
1932        sel.innerHTML = '<div style="padding:8px;text-align:center;color:#ef4444;">Failed — try again</div>';
1933      }
1934    });
1935  });
1936
1937
1938  // ── Health Bar ──
1939  function renderHealthBar() {
1940    var m = pipelineMetrics || {};
1941    var stages = m.stages || [];
1942    var total = stages.reduce(function(sum, s) { return sum + s.count; }, 0) || 1;
1943
1944    var statsLine = '<div style="display:flex;gap:16px;font-size:12px;color:#666;">' +
1945      '<span><strong>' + pipelineData.length + '</strong> active</span>' +
1946      '<span><strong>' + (m.in_hours_now || 0) + '</strong> in hours now</span>' +
1947      (m.follow_up_success_pct !== null ? '<span>Follow-up success: <strong>' + m.follow_up_success_pct + '%</strong></span>' : '') +
1948    '</div>';
1949
1950    var bar = stages.length > 0 ? '<div style="display:flex;height:24px;border-radius:6px;overflow:hidden;margin-top:8px;cursor:pointer;">' +
1951      stages.map(function(s) {
1952        var pct = Math.max((s.count / total) * 100, 3);
1953        return '<div title="' + escHtml(s.name) + ': ' + s.count + '" style="width:' + pct + '%;background:' + (s.color || '#999') + ';display:flex;align-items:center;justify-content:center;color:#fff;font-size:10px;font-weight:600;min-width:20px;">' +
1954          (pct > 8 ? s.count : '') + '</div>';
1955      }).join('') +
1956    '</div>' +
1957    '<div style="display:flex;gap:12px;margin-top:4px;flex-wrap:wrap;">' +
1958      stages.map(function(s) {
1959        return '<span style="font-size:10px;color:#666;"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + (s.color || '#999') + ';margin-right:3px;"></span>' + escHtml(s.name) + ' (' + s.count + ')</span>';
1960      }).join('') +
1961    '</div>' : '';
1962
1963    return '<div style="background:#fff9e6;border-bottom:1px solid #ffe082;padding:12px 24px;">' +
1964      '<div style="display:flex;align-items:center;gap:12px;margin-bottom:4px;">' +
1965        '<span style="font-size:18px;">📋</span>' +
1966        '<strong style="font-size:14px;">Pipeline</strong>' +
1967        statsLine +
1968      '</div>' +
1969      bar +
1970    '</div>';
1971  }
1972
1973  // ── Contact Summary Card ──
1974  window._selectPipelineItem = async function(el) {
1975    var profileId = parseInt(el.dataset.profile);
1976    var assignmentId = parseInt(el.dataset.assignment);
1977    var preview = document.getElementById('msgPreviewPane');
1978    if (!preview) return;
1979
1980    // Highlight selected row
1981    var rows = document.querySelectorAll('#pipelineListPane > div[data-assignment]');
1982    rows.forEach(function(r) { r.style.background = ''; });
1983    el.style.background = '#e8f4ff';
1984
1985    preview.innerHTML = '<div style="text-align:center;padding:40px;color:#999;">Loading...</div>';
1986
1987    try {
1988      var [summaryResult, convoResult] = await Promise.all([
1989        api('/pipeline/' + assignmentId + '/summary'),
1990        api('/contacts/' + profileId + '/messages?limit=20').catch(function() { return { messages: [] }; })
1991      ]);
1992
1993      var a = (summaryResult && summaryResult.assignment) || {};
1994      var events = (summaryResult && summaryResult.events) || [];
1995      var followUps = (summaryResult && summaryResult.follow_ups) || [];
1996      var messages = Array.isArray(convoResult) ? convoResult : (convoResult && convoResult.messages) || [];
1997
1998      var tzDot = { green: '🟢', amber: '🟡', red: '🔴', grey: '⚪' };
1999
2000      // Header card
2001      var header = '<div style="padding:16px 20px;border-bottom:1px solid #e0e0e0;background:#fff;">' +
2002        '<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">' +
2003          '<strong style="font-size:16px;">' + escHtml(a.display_name || 'Unknown') + '</strong>' +
2004          '<span style="font-size:12px;color:#888;">' + escHtml(a.primary_phone || '') + '</span>' +
2005          (a.primary_email ? '<span style="font-size:12px;color:#888;">' + escHtml(a.primary_email) + '</span>' : '') +
2006        '</div>' +
2007        '<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">' +
2008          '<span style="padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;color:#fff;background:' + (a.stage_color || '#999') + ';">' + escHtml(a.stage_name || '?') + '</span>' +
2009          '<span style="font-size:12px;">' + (tzDot[a.tz_status] || '⚪') + ' ' + (a.tz_local_time || '') + '</span>' +
2010          (a.wa_window_open ? '<span style="font-size:11px;color:#22c55e;">🟢 WA window open</span>' : '<span style="font-size:11px;color:#ef4444;">🔴 Template only</span>') +
2011          '<span style="font-size:11px;color:#888;">Silent ' + (a.silence_hours || '?') + 'h</span>' +
2012          '<span style="font-size:11px;color:#888;">Nudges: ' + (a.nudge_count || 0) + '/3</span>' +
2013          '<span style="font-size:11px;color:#888;">Coach: ' + escHtml(a.coach_name || '?') + '</span>' +
2014        '</div>' +
2015      '</div>';
2016
2017      // Follow-up history
2018      var fuHtml = '';
2019      if (followUps.length > 0) {
2020        fuHtml = '<div style="padding:12px 20px;border-bottom:1px solid #e0e0e0;background:#fefce8;">' +
2021          '<strong style="font-size:12px;">Follow-up History</strong>' +
2022          '<div style="margin-top:6px;">' +
2023          followUps.map(function(fu) {
2024            var statusColors = { sent: '#22c55e', pending: '#3b82f6', skipped: '#9ca3af', expired: '#f59e0b' };
2025            var d = new Date(fu.created_at);
2026            return '<div style="font-size:11px;padding:3px 0;color:#555;">' +
2027              '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + (statusColors[fu.status] || '#999') + ';margin-right:4px;"></span>' +
2028              'Attempt ' + fu.attempt_number + ' — ' + fu.status + ' (' + fu.message_type + ') ' +
2029              App.Dates.short(d, { year: false }) + ' ' +
2030              d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) +
2031            '</div>';
2032          }).join('') +
2033          '</div></div>';
2034      }
2035
2036      // Recent activity timeline
2037      var actHtml = '';
2038      if (events.length > 0) {
2039        var eIcons = { reply: '🟢', ai_response: '🤖', follow_up_sent: '📱', follow_up_queued: '⏳', stage_change: '📈', stop_signal: '🛑', manager_takeover: '👤' };
2040        actHtml = '<div style="padding:12px 20px;border-bottom:1px solid #e0e0e0;">' +
2041          '<strong style="font-size:12px;">Recent Activity</strong>' +
2042          '<div style="margin-top:6px;">' +
2043          events.slice(0, 8).map(function(ev) {
2044            var d = new Date(ev.created_at);
2045            return '<div style="font-size:11px;padding:2px 0;color:#555;">' +
2046              (eIcons[ev.event_type] || '📌') + ' ' +
2047              escHtml(ev.summary || ev.event_type) + ' — ' +
2048              d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) +
2049            '</div>';
2050          }).join('') +
2051          '</div></div>';
2052      }
2053
2054      // Action buttons
2055      var actions = '<div style="padding:10px 20px;border-bottom:1px solid #e0e0e0;display:flex;gap:8px;background:#fafafa;">
2055' +
2056        '<button data-aid="' + assignmentId + '" data-action="takeover" style="padding:5px 12px;border:1px solid #ddd;border-radius:6px;background:#fff;font-size:11px;cursor:pointer;">⏸️ Take over</button>' +
2057        '<button data-aid="' + assignmentId + '" data-action="handback" style="padding:5px 12px;border:1px solid #ddd;border-radius:6px;background:#fff;font-size:11px;cursor:pointer;">🔁 Hand back</button>' +
2058        '<button data-aid="' + assignmentId + '" data-action="archive" style="padding:5px 12px;border:1px solid #ddd;border-radius:6px;background:#fff;font-size:11px;cursor:pointer;">🗑️ Archive</button>' +
2059        '<button onclick="loadConvo(' + profileId + ')" style="padding:5px 12px;border:1px solid #4f46e5;border-radius:6px;background:#4f46e5;color:#fff;font-size:11px;cursor:pointer;">💬 Open Chat</button>' +
2060      '</div>';
2061
2062      preview.innerHTML = header + fuHtml + actHtml + actions +
2063        '<div style="padding:12px 20px;overflow-y:auto;flex:1;">' +
2064          '<strong style="font-size:12px;">Conversation</strong>' +
2065          '<div style="margin-top:8px;">' +
2066          (messages.length > 0 ? messages.map(function(m) {
2067            var isIn = m.direction === 'inbound';
2068            var statusIcon = '';
2069            if (!isIn) {
2070              var st = (m.status || '').toLowerCase();
2071              if (st === 'read') statusIcon = ' <span title="Read" style="color:#34b7f1;">\u2713\u2713</span>';
2072              else if (st === 'delivered') statusIcon = ' <span title="Delivered" style="color:#999;">\u2713\u2713</span>';
2073              else if (st === 'sent') statusIcon = ' <span title="Sent" style="color:#999;">\u2713</span>';
2074              else if (st === 'failed' || st === 'undelivered') statusIcon = ' <span title="Failed" style="color:#ef4444;">\u2715</span>';
2075              else if (st === 'queued') statusIcon = ' <span title="Queued" style="color:#f59e0b;">\u23F3</span>';
2076              else statusIcon = ' <span title="' + st + '" style="color:#ccc;">\u2713</span>';
2077            }
2078            return '<div style="padding:6px 10px;margin:4px 0;border-radius:8px;max-width:80%;font-size:12px;' +
2079              (isIn ? 'background:#e8f4ff;margin-right:auto;' : 'background:#f0f0f0;margin-left:auto;') + '">' +
2080              escHtml(m.message || '') +
2081              '<div style="font-size:9px;color:#999;margin-top:2px;">' +
2082              new Date(m.sent_at || m.created_at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) +
2083              statusIcon +
2084              '</div></div>';
2085          }).join('') : '<div style="color:#999;font-size:12px;">No messages yet</div>') +
2086          '</div>' +
2087        '</div>';
2088
2089      // Wire action button delegation on preview pane
2090      preview.addEventListener('click', function handler(e) {
2091        var btn = e.target.closest('button[data-action]');
2092        if (btn) {
2093          e.stopPropagation();
2094          window._pipeAction(parseInt(btn.dataset.aid), btn.dataset.action);
2095        }
2096      });
2097
2098    } catch (err) {
2099      preview.innerHTML = '<div style="text-align:center;padding:40px;color:#c00;">Error loading summary</div>';
2100      console.error('[Pipeline] Summary load error:', err);
2101    }
2102  };
2103
2104})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.