ligbox-ops-platform/projects/ops-desk/frontend/assets/tickets-workspace.js
Ligbox Spec Hub 468e6bd573 Add Spec 029 Tickets Workspace — motor de tickets P0-P2
Rebuilt from Cursor transcript: tickets-workspace.js, tickets-detail-panel.js,
app.js delegation, CSS, desk-live-stub. VM122 deploy pending SSH.
2026-06-19 19:20:23 +00:00

497 lines
20 KiB
JavaScript

/**
* 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 `
<div class="tickets-kpi-strip" role="group" aria-label="Indicadores da fila">
${items.map((it) => `
<button type="button"
class="tickets-kpi tickets-kpi--${it.tone}${this.queueFilter === it.key ? ' active' : ''}"
data-ticket-kpi="${it.key}"
title="Filtrar: ${esc(it.label)}">
<span class="tickets-kpi-value">${it.icon && it.value ? `${it.icon} ` : ''}${it.value}</span>
<span class="tickets-kpi-label">${esc(it.label)}</span>
</button>`).join('')}
</div>`;
},
queueChipsHtml() {
return `
<div class="tickets-queue-bar" role="group" aria-label="Filas inteligentes">
<span class="tickets-queue-label">Filas:</span>
${this.QUEUE_CHIPS.map((c) => `
<button type="button"
class="tickets-queue-chip${this.queueFilter === c.key ? ' active' : ''}"
data-ticket-queue="${c.key}">
${c.icon} ${esc(c.label)}
</button>`).join('')}
${this.queueFilter ? '<button type="button" class="tickets-queue-clear" data-ticket-queue-clear">Limpar fila</button>' : ''}
</div>`;
},
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 ? '<span class="ticket-icon-chip" title="Billing">💳</span>' : '',
t._carbonioHint ? '<span class="ticket-icon-chip" title="Carbonio">🔒</span>' : '',
t._wazuh ? `<span class="ticket-icon-chip" title="Wazuh">${severityBadge(t.severity) || '⚠️'}</span>` : '',
t.crm_track === 'lead' ? '<span class="ticket-icon-chip ticket-icon-chip--lead">LEAD</span>' : '',
].filter(Boolean).join('');
const selected = state.selectedTicketId === t.id;
return `
<button type="button"
class="ticket-card ticket-card--${t.status}${selected ? ' selected' : ''}${t._live ? ' ticket-card--live' : ''}"
data-id="${t.id}"${t.session_id ? ` data-session="${esc(t.session_id)}"` : ''}
data-live="${t._live ? '1' : '0'}" data-stale="${t._stale ? '1' : '0'}">
<span class="ticket-card-rail ticket-card-rail--${t.status}" aria-hidden="true"></span>
<span class="ticket-card-main">
<span class="ticket-card-signals">
<span class="ticket-signal ticket-signal--status ticket-signal--${t.status}">${esc(statusLabel(t.status))}</span>
<span class="ticket-signal ticket-signal--live ${t._live ? 'is-live' : 'is-offline'}">
<i aria-hidden="true"></i>${t._live ? 'LIVE' : 'offline'}</span>
<span class="ticket-signal ticket-signal--phase ticket-signal--${phase.cls}">${esc(phase.text)}</span>
</span>
<span class="ticket-card-title">${title}</span>
<span class="ticket-card-meta">${metaParts.join(' · ')}</span>
${t._live && t._livePath ? `<span class="ticket-card-livepath" data-live-path>${esc(t._livePath)}</span>` : ''}
${icons ? `<span class="ticket-card-icons">${icons}</span>` : ''}
</span>
<span class="ticket-card-aside">${sourceBadge(t.source)}</span>
</button>`;
},
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 = `
<input type="search" id="tickets-search-input" class="tickets-search-input"
placeholder="Buscar ticket #, domínio, e-mail, sessão, OB-…, IP…" autocomplete="off" />
<span class="tickets-search-hint ticket-meta">Scan rápido · 3 sinais por card</span>`;
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
? `<div class="ticket-card-list">${tickets.map((t) => this.ticketCardHtml(t)).join('')}</div>`
: '<p class="loading">Nenhum ticket neste filtro</p>';
},
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 = '<div class="card detail-panel"><p class="empty">Selecione um ticket para ver detalhes</p></div>';
}
},
};
window.TicketsWorkspace = TicketsWorkspace;