/** * Tickets Workspace — P0 lista (8 KPIs + 3 sinais) · P1 filas · P2 KPI click + softRefresh * Arquitectura separada; app.js delega render aqui. */ const TicketsWorkspace = { context: null, searchQuery: '', queueFilter: null, _pageReady: false, _listFingerprint: '', QUEUE_CHIPS: [ { key: 'live', label: 'Live agora', icon: '🟢' }, { key: 'stale', label: 'Parados', icon: '⏸' }, { key: 'unassigned', label: 'Sem dono', icon: '👤' }, { key: 'billing', label: 'Billing', icon: '💳' }, { key: 'wazuh', label: 'Wazuh', icon: '⚠️' }, { key: 'escalated', label: 'Escalados', icon: '🚨' }, ], async loadContext() { const user = typeof getUser === 'function' ? getUser() : null; const [presence, funnel, summary] = await Promise.all([ window.DeskLive?.enabled() ? api('/v1/live/presence').catch(() => ({ sessions: [] })) : Promise.resolve({ sessions: [] }), api('/v1/onboard/funnel?window_hours=48').catch(() => ({ active_sessions: [] })), api('/v1/desk/summary').catch(() => ({})), ]); const liveBySession = {}; for (const s of presence.sessions || []) { if (s.session_id) liveBySession[s.session_id] = s; } const funnelBySession = {}; for (const s of funnel.active_sessions || []) { if (s.session_id) funnelBySession[s.session_id] = s; } this.context = { liveBySession, funnelBySession, staleHours: summary.onboard_stale_hours ?? 24, user, }; return this.context; }, stripEnrichment(t) { const out = { ...t }; for (const k of Object.keys(out)) { if (k.startsWith('_')) delete out[k]; } return out; }, enrichTicket(t) { const ctx = this.context || { liveBySession: {}, funnelBySession: {}, staleHours: 24 }; const sid = (t.session_id || '').trim(); const live = sid ? ctx.liveBySession[sid] : null; const funnel = sid ? ctx.funnelBySession[sid] : null; const isActive = ['open', 'escalated', 'assisting', 'resolved'].includes(t.status); const lastAt = funnel?.last_event_at || t.created_at; let idleHours = 0; if (lastAt) { idleHours = (Date.now() - new Date(lastAt).getTime()) / 3600000; } const stale = funnel?.stale || (isActive && idleHours >= ctx.staleHours); const stage = funnel?.current_stage || t.lead_funnel_stage || t.event; return { ...t, _live: Boolean(live), _livePath: (live?.path && !String(live.path).startsWith('/api/')) ? live.path : (funnel?.current_path && !String(funnel.current_path).startsWith('/api/') ? funnel.current_path : null), _stage: stage, _stageLabel: typeof FUNNEL_LABELS !== 'undefined' ? (FUNNEL_LABELS[stage] || stage) : stage, _idleHours: Math.round(idleHours), _stale: stale && isActive, _unassigned: isActive && !t.assigned_to && !t.assisted_by, _billing: Boolean(t.billing_state) || (t.subject || '').includes('[billing'), _wazuh: t.source === 'wazuh' || t.event === 'wazuh.alert', _carbonioHint: (t.subject || '').toLowerCase().includes('carbonio') || (t.subject || '').toLowerCase().includes('account_exists'), }; }, computeIndicators(tickets) { const enriched = tickets.map((t) => this.enrichTicket(t)); const active = enriched.filter((t) => ['open', 'escalated', 'assisting', 'resolved'].includes(t.status)); return { open: active.filter((t) => t.status === 'open').length, assisting: enriched.filter((t) => t.status === 'assisting').length, escalated: enriched.filter((t) => t.status === 'escalated').length, live: enriched.filter((t) => t._live).length, unassigned: active.filter((t) => t._unassigned).length, stale: active.filter((t) => t._stale).length, billing: enriched.filter((t) => t._billing).length, wazuh: enriched.filter((t) => t._wazuh).length, total: enriched.length, }; }, indicatorsHtml(ind) { const items = [ { key: 'open', label: 'Abertos', value: ind.open, tone: 'info' }, { key: 'assisting', label: 'Assistindo', value: ind.assisting, tone: 'brand' }, { key: 'escalated', label: 'Escalados', value: ind.escalated, tone: 'danger' }, { key: 'live', label: 'Live agora', value: ind.live, tone: 'live' }, { key: 'unassigned', label: 'Sem dono', value: ind.unassigned, tone: 'warn' }, { key: 'stale', label: 'Parados', value: ind.stale, tone: 'muted' }, { key: 'billing', label: 'Billing', value: ind.billing, tone: 'billing', icon: '💳' }, { key: 'wazuh', label: 'Wazuh', value: ind.wazuh, tone: 'security', icon: '⚠️' }, ]; return `
${items.map((it) => ` `).join('')}
`; }, queueChipsHtml() { return `
Filas: ${this.QUEUE_CHIPS.map((c) => ` `).join('')} ${this.queueFilter ? '' : ''}
`; }, phaseSignal(t) { if (t._stale) return { text: `parado ${t._idleHours}h`, cls: 'stale' }; if (t._stageLabel && t._stageLabel !== t.event) { const short = String(t._stageLabel).replace(/validado|aplicado|criada/gi, '').trim() || t._stageLabel; return { text: short.slice(0, 28), cls: 'phase' }; } if (t.event) return { text: String(t.event).replace('onboarding.', '').replace('.', ' '), cls: 'phase' }; return { text: '—', cls: 'muted' }; }, ticketCardHtml(t) { const phase = this.phaseSignal(t); const isOnboard = t.source === 'vm112-onboard' || t.event?.startsWith?.('onboarding'); const title = t.event === 'wazuh.alert' ? esc(t.description || t.agent || t.subject) : t.domain ? esc(t.domain) : isOnboard ? `Onboarding · ${esc(t._stageLabel || 'VM112')}` : esc(t.subject || `Ticket #${t.id}`); const metaParts = []; metaParts.push(`#${t.id}`); if (t.wizard_ticket_id) metaParts.push(esc(t.wizard_ticket_id)); if (t.session_id) metaParts.push(sessionHashHtml(t.session_id, { full: false })); if (t.email) metaParts.push(esc(t.email)); if (t.assigned_to || t.assisted_by) metaParts.push(esc(t.assisted_by || t.assigned_to)); metaParts.push(fmtDate(t.created_at)); const icons = [ t._billing ? '💳' : '', t._carbonioHint ? '🔒' : '', t._wazuh ? `${severityBadge(t.severity) || '⚠️'}` : '', t.crm_track === 'lead' ? 'LEAD' : '', ].filter(Boolean).join(''); const selected = state.selectedTicketId === t.id; return ` `; }, filterByQueue(tickets) { if (!this.queueFilter) return tickets; const rules = { open: (t) => t.status === 'open', assisting: (t) => t.status === 'assisting', escalated: (t) => t.status === 'escalated', live: (t) => t._live, unassigned: (t) => t._unassigned, stale: (t) => t._stale, billing: (t) => t._billing, wazuh: (t) => t._wazuh, }; const fn = rules[this.queueFilter]; return fn ? tickets.filter(fn) : tickets; }, filterBySearch(tickets) { const raw = (this.searchQuery || '').trim(); if (!raw) return tickets; const q = raw.toLowerCase(); const ticketIdQuery = q.replace(/^ticket\s*#?/, '').replace(/^#/, '').trim(); return tickets.filter((t) => { if (/^\d+$/.test(ticketIdQuery) && String(t.id) === ticketIdQuery) return true; const wiz = (t.wizard_ticket_id || '').toLowerCase(); if (wiz && (wiz === q || wiz.includes(q))) return true; const liveIp = this.context?.liveBySession?.[t.session_id]?.client_ip; const hay = [ t.id, `#${t.id}`, `ticket ${t.id}`, t.subject, t.domain, t.email, t.session_id, t.agent, t.description, t.assigned_to, t.assisted_by, t.wizard_ticket_id, liveIp, ].filter(Boolean).join(' ').toLowerCase(); return hay.includes(q); }); }, applyFilters(tickets) { return this.filterBySearch(this.filterByQueue(tickets)); }, listFingerprint(tickets) { return tickets.map((t) => [ t.id, t.status, t._live ? 1 : 0, t._stale ? 1 : 0, t._stage, t._idleHours, t._livePath || '', ].join(':')).join('|'); }, setQueueFilter(key) { this.queueFilter = this.queueFilter === key ? null : key; const bar = document.getElementById('tickets-queue-bar'); if (bar) { bar.innerHTML = this.queueChipsHtml(); this.bindQueueBar(bar); } this.syncQueueUi(); this.renderListOnly(); }, syncQueueUi() { document.querySelectorAll('[data-ticket-kpi]').forEach((el) => { el.classList.toggle('active', el.dataset.ticketKpi === this.queueFilter); }); document.querySelectorAll('[data-ticket-queue]').forEach((el) => { el.classList.toggle('active', el.dataset.ticketQueue === this.queueFilter); }); const clearBtn = document.querySelector('[data-ticket-queue-clear]'); if (clearBtn) clearBtn.hidden = !this.queueFilter; }, bindKpiStrip(strip) { strip.querySelectorAll('[data-ticket-kpi]').forEach((el) => { el.addEventListener('click', () => this.setQueueFilter(el.dataset.ticketKpi)); }); }, bindQueueBar(bar) { bar.querySelectorAll('[data-ticket-queue]').forEach((el) => { el.addEventListener('click', () => this.setQueueFilter(el.dataset.ticketQueue)); }); bar.querySelector('[data-ticket-queue-clear]')?.addEventListener('click', () => { this.queueFilter = null; this.syncQueueUi(); this.renderListOnly(); }); }, bindList(listEl) { listEl.querySelectorAll('.ticket-card').forEach((btn) => { btn.addEventListener('click', () => { state.selectedTicketId = Number(btn.dataset.id); state.selectedSessionId = btn.dataset.session || null; listEl.querySelectorAll('.ticket-card').forEach((r) => r.classList.remove('selected')); btn.classList.add('selected'); if (window.TicketsDetailPanel) TicketsDetailPanel.render(state.selectedTicketId, document.getElementById('ticket-detail')); else if (typeof renderTicketDetail === 'function') renderTicketDetail(); }); }); }, mountSearchToolbar() { let bar = document.getElementById('tickets-search-bar'); if (!bar) { const toolbar = document.querySelector('#view-tickets .toolbar'); if (!toolbar) return; bar = document.createElement('div'); bar.id = 'tickets-search-bar'; bar.className = 'tickets-search-bar'; bar.innerHTML = ` Scan rápido · 3 sinais por card`; toolbar.parentNode.insertBefore(bar, toolbar); bar.querySelector('#tickets-search-input')?.addEventListener('input', (e) => { this.searchQuery = e.target.value; if (state.view === 'tickets') this.renderListOnly(); }); } }, mountQueueBar() { let bar = document.getElementById('tickets-queue-bar'); if (!bar) { const view = document.getElementById('view-tickets'); const toolbar = view?.querySelector('.toolbar'); if (!toolbar) return; bar = document.createElement('div'); bar.id = 'tickets-queue-bar'; toolbar.parentNode.insertBefore(bar, toolbar.nextSibling); } bar.innerHTML = this.queueChipsHtml(); this.bindQueueBar(bar); }, updateKpiStrip(ind) { const strip = document.getElementById('tickets-kpi-strip'); if (!strip) return; const map = { open: ind.open, assisting: ind.assisting, escalated: ind.escalated, live: ind.live, unassigned: ind.unassigned, stale: ind.stale, billing: ind.billing, wazuh: ind.wazuh, }; strip.querySelectorAll('[data-ticket-kpi]').forEach((el) => { const key = el.dataset.ticketKpi; const val = map[key]; if (val == null) return; const valueEl = el.querySelector('.tickets-kpi-value'); if (valueEl) { const icon = (key === 'billing' && val) ? '💳 ' : (key === 'wazuh' && val) ? '⚠️ ' : ''; valueEl.textContent = `${icon}${val}`; } }); }, patchLiveSignals(listEl, tickets) { const byId = {}; for (const t of tickets) byId[t.id] = t; listEl.querySelectorAll('.ticket-card').forEach((card) => { const t = byId[Number(card.dataset.id)]; if (!t) return; const wasLive = card.dataset.live === '1'; const isLive = t._live; if (wasLive !== isLive) { card.dataset.live = isLive ? '1' : '0'; card.classList.toggle('ticket-card--live', isLive); const sig = card.querySelector('.ticket-signal--live'); if (sig) { sig.classList.toggle('is-live', isLive); sig.classList.toggle('is-offline', !isLive); sig.lastChild.textContent = isLive ? 'LIVE' : 'offline'; } } card.dataset.stale = t._stale ? '1' : '0'; const phase = card.querySelector('.ticket-signal--phase'); if (phase) { const p = this.phaseSignal(t); phase.textContent = p.text; phase.className = `ticket-signal ticket-signal--phase ticket-signal--${p.cls}`; } const pathEl = card.querySelector('[data-live-path]'); if (t._live && t._livePath) { if (pathEl) pathEl.textContent = t._livePath; else { const main = card.querySelector('.ticket-card-main'); if (main) { const span = document.createElement('span'); span.className = 'ticket-card-livepath'; span.dataset.livePath = ''; span.textContent = t._livePath; main.appendChild(span); } } } else if (pathEl) pathEl.remove(); }); }, renderListHtml(tickets) { return tickets.length ? `
${tickets.map((t) => this.ticketCardHtml(t)).join('')}
` : '

Nenhum ticket neste filtro

'; }, async renderListOnly() { const listEl = document.getElementById('ticket-list'); if (!listEl || !state.tickets?.length) return; const enriched = state.tickets.map((t) => this.enrichTicket(this.stripEnrichment(t))); state.tickets = enriched; const filtered = this.applyFilters(enriched); const fp = this.listFingerprint(filtered); if (fp === this._listFingerprint && listEl.querySelector('.ticket-card')) { this.patchLiveSignals(listEl, filtered); this.updateKpiStrip(this.computeIndicators(enriched)); return; } this._listFingerprint = fp; listEl.innerHTML = this.renderListHtml(filtered); this.bindList(listEl, filtered); this.updateKpiStrip(this.computeIndicators(enriched)); }, async softRefresh() { if (!this._pageReady || state.view !== 'tickets') return; try { await this.loadContext(); const enriched = (state.tickets || []).map((t) => this.enrichTicket(this.stripEnrichment(t))); state.tickets = enriched; const ind = this.computeIndicators(enriched); this.updateKpiStrip(ind); const listEl = document.getElementById('ticket-list'); if (listEl) { const filtered = this.applyFilters(enriched); const fp = this.listFingerprint(filtered); if (fp === this._listFingerprint && listEl.querySelector('.ticket-card')) { this.patchLiveSignals(listEl, filtered); } else { this._listFingerprint = fp; const selectedId = state.selectedTicketId; listEl.innerHTML = this.renderListHtml(filtered); this.bindList(listEl, filtered); if (selectedId) { listEl.querySelector(`.ticket-card[data-id="${selectedId}"]`)?.classList.add('selected'); } } } if (state.selectedTicketId && TicketsDetailPanel?.activeTab === 'live') { const detailEl = document.getElementById('ticket-detail'); const sid = detailEl?.dataset?.sessionId || state.selectedSessionId; const trail = detailEl?.querySelector('#ticket-detail-live-trail'); if (sid && trail && window.DeskLive?.renderNavigationTab) { await DeskLive.renderNavigationTab(sid, trail); } } } catch { /* ignore poll errors */ } }, async renderPage({ listEl, detailEl, tickets }) { this.mountSearchToolbar(); this.mountQueueBar(); await this.loadContext(); const enriched = tickets.map((t) => this.enrichTicket(t)); state.tickets = enriched; const ind = this.computeIndicators(enriched); const filtered = this.applyFilters(enriched); this._listFingerprint = this.listFingerprint(filtered); let strip = document.getElementById('tickets-kpi-strip'); if (!strip) { strip = document.createElement('div'); strip.id = 'tickets-kpi-strip'; const view = document.getElementById('view-tickets'); const searchBar = document.getElementById('tickets-search-bar'); if (view && searchBar) view.insertBefore(strip, searchBar); else if (view) { const toolbar = view.querySelector('.toolbar'); if (toolbar) view.insertBefore(strip, toolbar); } } strip.innerHTML = this.indicatorsHtml(ind); this.bindKpiStrip(strip); listEl.innerHTML = this.renderListHtml(filtered); this.bindList(listEl, filtered); this._pageReady = true; if (state.selectedTicketId && window.TicketsDetailPanel) { await TicketsDetailPanel.render(state.selectedTicketId, detailEl); } else if (state.selectedTicketId && typeof renderTicketDetail === 'function') { await renderTicketDetail(); } else if (state.selectedSessionId && typeof renderSessionDetail === 'function') { await renderSessionDetail(); } else if (detailEl) { detailEl.innerHTML = '

Selecione um ticket para ver detalhes

'; } }, }; window.TicketsWorkspace = TicketsWorkspace;