ligbox-ops-platform/projects/ops-desk/frontend/assets/tickets-detail-panel.js
Ligbox Spec Hub acaacce705 Fix ticket detail next-action box crushed text in narrow panel.
Prevent flex shrink on ASM action buttons; widen tickets grid detail column; style assist panel in tickets-workspace.css.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 22:54:23 +00:00

467 lines
21 KiB
JavaScript

/**
* Tickets Detail Panel — abas Resumo | Ao vivo | Funil + próxima acção contextual (P1)
*/
const TicketsDetailPanel = {
activeTab: 'resumo',
lastTicketId: null,
mirrorTimer: null,
mirrorSessionId: null,
stopMirrorPoll() {
if (this.mirrorTimer) {
clearInterval(this.mirrorTimer);
this.mirrorTimer = null;
}
this.mirrorSessionId = null;
},
parseUiState(presence, uiState) {
if (uiState && typeof uiState === 'object' && Object.keys(uiState).length) return uiState;
if (presence?.ui_state) {
try { return JSON.parse(presence.ui_state); } catch { return {}; }
}
return {};
},
mirrorHtml(observer) {
if (!observer) {
return '<p class="loading">Carregando espelho do wizard…</p>';
}
if (observer.is_assisting) {
return '<p class="ticket-mirror-hint">ASM ativo — o cliente está pausado. Use <strong>Reabrir wizard ASM</strong> para continuar no modo técnico.</p>';
}
const ui = this.parseUiState(observer.presence, observer.ui_state);
const step = observer.step_label || ui.step_label || observer.presence?.wizard_step || observer.funnel_stage || '—';
const domain = observer.domain || ui.domain || '—';
const errRaw = observer.client_error || ui.error;
const err = errRaw && (observer.is_assisting || !String(errRaw).includes('controle interno do suporte'))
? errRaw
: null;
const modals = observer.modals || ui.modals || {};
const modalLines = [];
if (modals.support) modalLines.push('Modal <strong>Ajuda do Suporte</strong> aberto');
if (modals.dns) modalLines.push('Modal <strong>DNS</strong> aberto');
if (modals.dns_advanced) modalLines.push('Modal <strong>DNS avançado</strong> aberto');
const activity = (observer.activity_log || []).slice(-12).map((e) => {
const lvl = (e.level || 'info').toLowerCase();
return `<li class="ticket-mirror-log ticket-mirror-log--${esc(lvl)}"><time>${esc((e.ts || e.created_at || '').slice(11, 19) || '—')}</time> ${esc(e.message || e.msg || '')}</li>`;
}).join('');
const infraPending = (observer.infra_status?.steps || []).filter((s) => !s.ok).slice(0, 4)
.map((s) => esc(s.label || s.id)).join(' · ');
return `
<div class="ticket-mirror">
<p class="ticket-mirror-lead">O técnico lê aqui o mesmo contexto do cliente <strong>antes</strong> de assumir a sessão.</p>
<div class="ticket-mirror-screen">
<div class="ticket-mirror-screen__bar">
<span class="ticket-signal ticket-signal--live is-live"><i aria-hidden="true"></i>Cliente no wizard</span>
${observer.presence?.wizard_step || step !== '—' ? `<span class="ticket-mirror-step">${esc(step)}</span>` : ''}
</div>
<dl class="kv kv--compact ticket-mirror-kv">
<dt>Passo</dt><dd><strong>${esc(step)}</strong></dd>
<dt>Domínio</dt><dd>${esc(domain)}</dd>
${observer.wizard_ticket_id ? `<dt>Chamado wizard</dt><dd><code class="session-hash">${esc(observer.wizard_ticket_id)}</code></dd>` : ''}
${observer.client_note ? `<dt>Nota do cliente</dt><dd>${esc(observer.client_note)}</dd>` : ''}
</dl>
${err ? `<div class="ticket-mirror-error" role="alert"><strong>Erro na tela do cliente</strong><p>${esc(err)}</p></div>` : ''}
${modalLines.length ? `<div class="ticket-mirror-modals">${modalLines.join(' · ')}</div>` : ''}
${infraPending ? `<p class="ticket-mirror-infra"><span>Infra pendente:</span> ${infraPending}</p>` : ''}
<div class="ticket-mirror-terminal">
<div class="ticket-mirror-terminal__head">Terminal / activity log (cliente)</div>
<ol class="ticket-mirror-log-list">${activity || '<li class="ticket-meta">Sem linhas recentes</li>'}</ol>
</div>
</div>
<p class="ticket-meta ticket-mirror-updated">Atualizado ${fmtDate(observer.presence?.last_seen_at || new Date().toISOString())}</p>
</div>`;
},
async fetchObserverView(sessionId) {
return api(`/v1/assist/sessions/${encodeURIComponent(sessionId)}/observer-view`);
},
async refreshMirrorPane(detailEl, sessionId) {
const pane = detailEl.querySelector('[data-ticket-pane="espelho"]');
if (!pane || pane.hidden) return;
try {
const observer = await this.fetchObserverView(sessionId);
pane.innerHTML = this.mirrorHtml(observer);
} catch (e) {
pane.innerHTML = `<p class="loading">Espelho indisponível: ${esc(e.message)}</p>`;
}
},
startMirrorPoll(detailEl, sessionId) {
this.stopMirrorPoll();
if (!sessionId) return;
this.mirrorSessionId = sessionId;
const tick = () => this.refreshMirrorPane(detailEl, sessionId);
tick();
this.mirrorTimer = setInterval(tick, 4000);
},
espelhoPaneHtml() {
return `
<div class="ticket-detail-pane" data-ticket-pane="espelho"${this.activeTab !== 'espelho' ? ' hidden' : ''}>
<p class="loading">Carregando espelho do wizard…</p>
</div>`;
},
computeNextAction(t, assistMeta, carbonioBlock) {
const enriched = window.TicketsWorkspace?.enrichTicket
? TicketsWorkspace.enrichTicket(t)
: t;
if (carbonioBlock?.status === 'pending') {
return {
tone: 'warn',
icon: '🔒',
title: 'Bloqueio Carbonio',
text: 'Remover conta órfã para o cliente repetir o passo',
cta: 'scroll-carbonio',
};
}
const assistStatus = normalizeAssistStatus(assistMeta?.assist_status || assistMeta?.ticket_status || t.status);
if (assistStatus === 'assisting' && typeof canAssist === 'function' && canAssist()) {
return {
tone: 'brand',
icon: '🖥️',
title: 'ASM ativo',
text: 'Fechou o wizard por engano? Reabra a sessão sem devolver ao cliente',
cta: 'resume-wizard',
};
}
if (enriched._live && typeof canAssist === 'function' && canAssist()
&& assistMeta?.can_escalate && assistStatus !== 'assisting') {
return {
tone: 'live',
icon: '🟢',
title: 'Cliente online',
text: enriched._livePath
? `Em ${enriched._livePath} — abra Espelho cliente e depois assuma`
: 'Abra a aba Espelho cliente para ver a mesma tela antes de assumir',
cta: 'takeover',
};
}
if (enriched._stale) {
return {
tone: 'stale',
icon: '⏸',
title: 'Sessão parada',
text: `Sem progresso há ~${enriched._idleHours}h — escalar ou contactar`,
cta: 'escalate',
};
}
if (enriched._unassigned && enriched.status === 'open') {
return {
tone: 'info',
icon: '👤',
title: 'Sem dono',
text: 'Ticket aberto sem técnico atribuído',
cta: 'takeover',
};
}
if (enriched._billing) {
return {
tone: 'billing',
icon: '💳',
title: 'Billing pendente',
text: 'Validar pagamento antes de prosseguir onboarding',
cta: null,
};
}
if (enriched._wazuh && (enriched.severity == null || enriched.severity >= 10)) {
return {
tone: 'danger',
icon: '⚠️',
title: 'Alerta Wazuh',
text: 'Investigar evento de segurança',
cta: null,
};
}
if (enriched.status === 'escalated') {
return {
tone: 'danger',
icon: '🚨',
title: 'Escalado',
text: 'Prioridade operacional — assumir ou resolver',
cta: 'takeover',
};
}
return null;
},
nextActionHtml(action) {
if (!action) return '';
const cta = action.cta === 'takeover'
? '<button type="button" class="btn btn-primary btn-sm" data-next-action="takeover">Assumir</button>'
: action.cta === 'resume-wizard'
? '<button type="button" class="btn btn-primary btn-sm" data-next-action="resume-wizard">Reabrir wizard</button>'
: action.cta === 'escalate'
? '<button type="button" class="btn btn-ghost btn-sm" data-next-action="escalate">Escalar</button>'
: action.cta === 'scroll-carbonio'
? '<button type="button" class="btn btn-primary btn-sm" data-next-action="scroll-carbonio">Resolver</button>'
: '';
return `
<div class="ticket-next-action ticket-next-action--${action.tone}">
<span class="ticket-next-action-icon" aria-hidden="true">${action.icon}</span>
<div class="ticket-next-action-body">
<strong>${esc(action.title)}</strong>
<span>${esc(action.text)}</span>
</div>
${cta ? `<div class="ticket-next-action-cta">${cta}</div>` : ''}
</div>`;
},
tabsHtml({ t, sessionId, hasLive, hasFunil, hasEspelho }) {
const tabs = [
{ id: 'resumo', label: 'Resumo' },
];
if (hasEspelho) tabs.push({ id: 'espelho', label: 'Espelho cliente' });
if (hasLive) tabs.push({ id: 'live', label: 'Ao vivo' });
if (hasFunil) tabs.push({ id: 'funil', label: 'Funil' });
if (!tabs.some((x) => x.id === this.activeTab)) this.activeTab = 'resumo';
return `
<nav class="ticket-detail-tabs" role="tablist">
${tabs.map((tab) => `
<button type="button" role="tab"
class="ticket-detail-tab${this.activeTab === tab.id ? ' active' : ''}"
data-ticket-tab="${tab.id}"
aria-selected="${this.activeTab === tab.id}">
${esc(tab.label)}
</button>`).join('')}
</nav>`;
},
resumoHtml(t, { sessionId, assistMeta, carbonioBlock, timeline, timing }) {
const closeStatuses = ['open', 'escalated', 'assisting', 'resolved'];
return `
<div class="ticket-detail-pane" data-ticket-pane="resumo"${this.activeTab !== 'resumo' ? ' hidden' : ''}>
<dl class="kv">
<dt>Origem</dt><dd>${sourceBadge(t.source)}</dd>
<dt>Domínio/Agente</dt><dd>${esc(t.domain || t.agent || '—')}</dd>
<dt>Email</dt><dd>${esc(t.email || '—')}</dd>
${typeof ticketFunnelKvHtml === 'function' ? ticketFunnelKvHtml(t) : `<dt>Evento</dt><dd>${esc(t.event || '—')}</dd>`}
${t.assigned_to ? `<dt>Atribuído</dt><dd>${esc(t.assigned_to)}</dd>` : ''}
${t.assisted_by ? `<dt>Assistido por</dt><dd>${esc(t.assisted_by)}</dd>` : ''}
${t.client_paused ? '<dt>Cliente</dt><dd><span class="badge assisting">pausado</span></dd>' : ''}
${t.ready_for_ops ? '<dt>Ops</dt><dd><span class="badge ok">ready for ops</span></dd>' : ''}
${t.severity != null ? `<dt>Severidade</dt><dd>${severityBadge(t.severity)}</dd>` : ''}
${t.rule_id ? `<dt>Regra</dt><dd>${esc(t.rule_id)}</dd>` : ''}
${t.description ? `<dt>Descrição</dt><dd>${esc(t.description)}</dd>` : ''}
${t.desk_message ? `<dt>Nota</dt><dd>${esc(t.desk_message)}</dd>` : ''}
${t.registration_role ? `<dt>Perfil</dt><dd>${esc(roleLabel(t.registration_role))}</dd>` : ''}
${t.activation_url ? `<dt>Ativar conta</dt><dd><a class="btn btn-primary btn-sm" href="${esc(t.activation_url)}" target="_blank" rel="noopener">Abrir link de ativação</a></dd>` : ''}
<dt>Sessão/Alert ID</dt><dd><code>${esc(t.session_id || '—')}</code></dd>
${t.wizard_ticket_id ? `<dt>Chamado wizard</dt><dd><code class="session-hash">${esc(t.wizard_ticket_id)}</code></dd>` : ''}
${t.wizard_client_note ? `<dt>Nota cliente</dt><dd>${esc(t.wizard_client_note)}</dd>` : ''}
<dt>Verificado</dt><dd>${t.account_verified ? 'Sim' : 'Não'}</dd>
<dt>Revisão</dt><dd>${t.needs_review ? 'Necessária' : 'Não'}</dd>
<dt>Criado</dt><dd>${fmtDate(t.created_at)}</dd>
</dl>
${sessionId && t.source === 'vm112-onboard' ? assistActionsHtml(sessionId, {
can_escalate: assistMeta?.can_escalate,
assist_status: assistMeta?.assist_status || assistMeta?.ticket_status,
ticket_status: assistMeta?.ticket_status || t.status,
client_paused: assistMeta?.ticket?.client_paused ?? t.client_paused,
assisted_by: assistMeta?.assisted_by,
actions: assistMeta?.actions,
}, assistMeta?._console || {}) : ''}
${carbonioBlock ? `<div id="ticket-carbonio-block">${carbonioBlockPanelHtml(carbonioBlock)}</div>` : ''}
<div class="actions">
${typeof canPatchTickets === 'function' && canPatchTickets()
? (closeStatuses.includes(t.status)
? '<button type="button" class="btn btn-primary" data-action="close">Fechar ticket</button>'
: '<button type="button" class="btn btn-ghost" data-action="open">Reabrir ticket</button>')
: ''}
</div>
<details class="ticket-payload-details">
<summary>Payload técnico</summary>
<pre class="raw">${esc(JSON.stringify(t.payload, null, 2))}</pre>
</details>
</div>`;
},
livePaneHtml(sessionId) {
const enriched = window.TicketsWorkspace?.context?.liveBySession?.[sessionId];
return `
<div class="ticket-detail-pane" data-ticket-pane="live"${this.activeTab !== 'live' ? ' hidden' : ''}>
${enriched ? `
<div class="ticket-live-presence-card">
<span class="ticket-signal ticket-signal--live is-live"><i aria-hidden="true"></i>LIVE</span>
<dl class="kv kv--compact">
<dt>Path</dt><dd><code>${esc(enriched.path || '—')}</code></dd>
<dt>Passo wizard</dt><dd>${esc(enriched.wizard_step || '—')}</dd>
<dt>IP</dt><dd>${esc(enriched.ip || '—')}</dd>
<dt>Último sinal</dt><dd>${fmtDate(enriched.last_seen_at || enriched.updated_at)}</dd>
</dl>
</div>` : '<p class="ticket-meta">Cliente offline neste momento.</p>'}
<div id="ticket-detail-live-trail"></div>
<button type="button" class="btn btn-ghost btn-sm" data-open-live-trail="${esc(sessionId)}">Trail completo (modal)</button>
</div>`;
},
funilPaneHtml(timeline, timing) {
return `
<div class="ticket-detail-pane" data-ticket-pane="funil"${this.activeTab !== 'funil' ? ' hidden' : ''}>
${timeline?.length
? `${phaseTimingCardHtml(timing, timeline)}${timelineHtml(timeline, timing, { compact: false })}`
: '<p class="ticket-meta">Sem eventos de funil para esta sessão.</p>'}
</div>`;
},
bindTabs(detailEl) {
detailEl.querySelectorAll('[data-ticket-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
this.activeTab = btn.dataset.ticketTab;
detailEl.querySelectorAll('[data-ticket-tab]').forEach((b) => {
b.classList.toggle('active', b === btn);
b.setAttribute('aria-selected', b === btn ? 'true' : 'false');
});
detailEl.querySelectorAll('[data-ticket-pane]').forEach((pane) => {
pane.hidden = pane.dataset.ticketPane !== this.activeTab;
});
if (this.activeTab === 'live') {
const sid = detailEl.dataset.sessionId;
const trail = detailEl.querySelector('#ticket-detail-live-trail');
if (sid && trail && window.DeskLive?.renderNavigationTab) {
DeskLive.renderNavigationTab(sid, trail);
}
}
if (this.activeTab === 'espelho') {
const sid = detailEl.dataset.sessionId;
if (sid) this.startMirrorPoll(detailEl, sid);
} else {
this.stopMirrorPoll();
}
if (this.activeTab === 'funil') bindLiveTimingClock(detailEl);
});
});
},
bindNextActions(detailEl, sessionId) {
detailEl.querySelector('[data-next-action="takeover"]')?.addEventListener('click', async (e) => {
const btn = e.currentTarget;
btn.disabled = true;
try {
await runAssistAction('takeover', sessionId);
await renderTickets();
} catch (err) {
alert(err.message || 'Falha ao assumir sessão');
} finally {
btn.disabled = false;
}
});
detailEl.querySelector('[data-next-action="resume-wizard"]')?.addEventListener('click', async (e) => {
const btn = e.currentTarget;
btn.disabled = true;
try {
await runAssistAction('resume-wizard', sessionId);
await renderTickets();
} catch (err) {
alert(err.message || 'Falha ao reabrir wizard ASM');
} finally {
btn.disabled = false;
}
});
detailEl.querySelector('[data-next-action="escalate"]')?.addEventListener('click', async (e) => {
const btn = e.currentTarget;
btn.disabled = true;
try {
await runAssistAction('escalate', sessionId);
await renderTickets();
} catch (err) {
alert(err.message || 'Falha ao escalar');
} finally {
btn.disabled = false;
}
});
detailEl.querySelector('[data-next-action="scroll-carbonio"]')?.addEventListener('click', () => {
this.activeTab = 'resumo';
detailEl.querySelector('[data-ticket-tab="resumo"]')?.click();
detailEl.querySelector('#ticket-carbonio-block')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
detailEl.querySelector('[data-open-live-trail]')?.addEventListener('click', (e) => {
if (window.DeskLive?.openTrail) DeskLive.openTrail(e.currentTarget.dataset.openLiveTrail);
});
},
async render(ticketId, detailEl) {
if (!ticketId || !detailEl) return;
this.stopMirrorPoll();
const isFreshTicket = this.lastTicketId !== ticketId;
if (isFreshTicket) {
this.activeTab = 'resumo';
this.lastTicketId = ticketId;
}
detailEl.innerHTML = '<div class="card detail-panel"><p class="loading">Carregando…</p></div>';
try {
const t = await api(`/v1/desk/tickets/${ticketId}`);
const sessionId = t.session_id || state.selectedSessionId;
const assistMeta = sessionId && t.source === 'vm112-onboard'
? await loadAssistMeta(sessionId)
: null;
if (sessionId) state.selectedSessionId = sessionId;
let carbonioBlock = null;
if (t.source === 'vm112-onboard' && window.DeskModules?.isEnabled('carbonio-release')) {
try {
const byTicket = await api(`/v1/carbonio-blocks?ticket_id=${t.id}&status=pending&limit=1`);
carbonioBlock = byTicket.blocks?.[0] || null;
if (!carbonioBlock && sessionId) {
const bySession = await api(`/v1/carbonio-blocks?session_id=${encodeURIComponent(sessionId)}&status=pending&limit=1`);
carbonioBlock = bySession.blocks?.[0] || null;
}
} catch {
carbonioBlock = null;
}
}
const timeline = assistMeta?.timeline?.length
? assistMeta.timeline
: (t.timeline || t.related_events || []);
const timing = assistMeta?.timing || t.timing;
const hasLive = Boolean(sessionId && t.source === 'vm112-onboard' && window.DeskLive?.enabled());
const hasEspelho = Boolean(sessionId && t.source === 'vm112-onboard');
const hasFunil = timeline.length > 0;
const assistStatus = normalizeAssistStatus(assistMeta?.assist_status || assistMeta?.ticket_status || t.status);
if (isFreshTicket && hasEspelho && hasLive && assistStatus !== 'assisting') {
this.activeTab = 'espelho';
}
const nextAction = this.computeNextAction(t, assistMeta, carbonioBlock);
detailEl.innerHTML = `
<div class="card detail-panel ticket-detail-shell" data-session-id="${esc(sessionId || '')}">
<div class="ticket-detail-header">
<div>
<h3 style="margin:0">Ticket #${t.id}${t.wizard_ticket_id ? ` · <code class="session-hash">${esc(t.wizard_ticket_id)}</code>` : ''}</h3>
<p class="ticket-meta">${esc(t.domain || t.subject || t.agent || '')}</p>
</div>
<span class="badge ${t.status}">${esc(statusLabel(t.status))}</span>
</div>
${this.nextActionHtml(nextAction)}
${this.tabsHtml({ t, sessionId, hasLive, hasFunil, hasEspelho })}
${this.resumoHtml(t, { sessionId, assistMeta, carbonioBlock, timeline, timing })}
${hasEspelho ? this.espelhoPaneHtml() : ''}
${hasLive ? this.livePaneHtml(sessionId) : ''}
${hasFunil ? this.funilPaneHtml(timeline, timing) : ''}
</div>`;
this.bindTabs(detailEl);
this.bindNextActions(detailEl, sessionId);
if (sessionId && t.source === 'vm112-onboard') bindAssistActions(detailEl, sessionId);
bindCarbonioResolveForms(detailEl);
bindLiveTimingClock(detailEl);
if (this.activeTab === 'espelho' && sessionId) {
this.startMirrorPoll(detailEl, sessionId);
}
if (this.activeTab === 'live' && hasLive) {
const trail = detailEl.querySelector('#ticket-detail-live-trail');
if (trail) await DeskLive.renderNavigationTab(sessionId, trail);
}
detailEl.querySelector('[data-action="close"]')?.addEventListener('click', () => updateTicketStatus('closed'));
detailEl.querySelector('[data-action="open"]')?.addEventListener('click', () => updateTicketStatus('open'));
} catch (e) {
detailEl.innerHTML = `<div class="card"><p class="loading">Erro: ${esc(e.message)}</p></div>`;
}
},
};
window.TicketsDetailPanel = TicketsDetailPanel;