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.
This commit is contained in:
parent
6189290ba8
commit
468e6bd573
9 changed files with 1412 additions and 22 deletions
|
|
@ -608,6 +608,28 @@ function billingTicketIcon(t) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAssistStatus(status) {
|
||||||
|
if (status === 'active') return 'assisting';
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketFunnelKvHtml(t) {
|
||||||
|
const latest = t.latest_funnel_event || t.event;
|
||||||
|
const opened = t.event_opened || t.event;
|
||||||
|
const showOpened = opened && latest && opened !== latest;
|
||||||
|
const outcome = t.onboarding_outcome;
|
||||||
|
const outcomeBadge = outcome === 'completed'
|
||||||
|
? '<span class="badge ok">concluído</span>'
|
||||||
|
: outcome === 'failed'
|
||||||
|
? '<span class="badge escalated">falhou</span>'
|
||||||
|
: '';
|
||||||
|
const label = latest ? (SOC_EVENT_LABELS[latest] || latest) : '—';
|
||||||
|
const sev = latest && typeof socEventSeverity === 'function' ? socEventSeverity(latest) : 'open';
|
||||||
|
return `
|
||||||
|
<dt>Estado funil</dt><dd><span class="badge ${sev}">${esc(label)}</span> ${outcomeBadge}</dd>
|
||||||
|
${showOpened ? `<dt>Abertura</dt><dd>${esc(SOC_EVENT_LABELS[opened] || opened)}</dd>` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
function ticketRowHtml(t) {
|
function ticketRowHtml(t) {
|
||||||
const review = t.needs_review ? '<span class="badge review">revisão</span>' : '';
|
const review = t.needs_review ? '<span class="badge review">revisão</span>' : '';
|
||||||
const verified = t.account_verified ? '<span class="badge ok">verificado</span>' : '';
|
const verified = t.account_verified ? '<span class="badge ok">verificado</span>' : '';
|
||||||
|
|
@ -1845,10 +1867,15 @@ async function renderLeads() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderTickets() {
|
async function renderTickets(options = {}) {
|
||||||
|
const { poll = false } = options;
|
||||||
stopLiveTimingClock();
|
stopLiveTimingClock();
|
||||||
const listEl = document.getElementById('ticket-list');
|
const listEl = document.getElementById('ticket-list');
|
||||||
const detailEl = document.getElementById('ticket-detail');
|
const detailEl = document.getElementById('ticket-detail');
|
||||||
|
if (poll && window.TicketsWorkspace?._pageReady) {
|
||||||
|
await TicketsWorkspace.softRefresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
listEl.innerHTML = '<p class="loading">Carregando tickets…</p>';
|
listEl.innerHTML = '<p class="loading">Carregando tickets…</p>';
|
||||||
try {
|
try {
|
||||||
let tickets = [];
|
let tickets = [];
|
||||||
|
|
@ -1864,20 +1891,30 @@ async function renderTickets() {
|
||||||
source: 'vm112-onboard',
|
source: 'vm112-onboard',
|
||||||
crm_track: 'lead',
|
crm_track: 'lead',
|
||||||
assigned_to: l.assigned_to,
|
assigned_to: l.assigned_to,
|
||||||
|
session_id: l.session_id,
|
||||||
|
lead_funnel_stage: l.funnel_stage,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
let q = '';
|
let q = '';
|
||||||
const params = [];
|
const params = [];
|
||||||
if (state.ticketFilter !== 'all') params.push(`status=${state.ticketFilter}`);
|
if (state.ticketFilter !== 'all' && state.ticketFilter !== 'active') {
|
||||||
|
params.push(`status=${state.ticketFilter}`);
|
||||||
|
}
|
||||||
if (state.sourceFilter !== 'all') params.push(`source=${state.sourceFilter}`);
|
if (state.sourceFilter !== 'all') params.push(`source=${state.sourceFilter}`);
|
||||||
if (params.length) q = '?' + params.join('&');
|
if (params.length) q = '?' + params.join('&');
|
||||||
const data = await api(`/v1/desk/tickets${q}`);
|
const data = await api(`/v1/desk/tickets${q}`);
|
||||||
tickets = data.tickets || [];
|
tickets = data.tickets || [];
|
||||||
|
if (state.ticketFilter === 'active') {
|
||||||
|
tickets = tickets.filter((t) => ['open', 'escalated', 'assisting', 'resolved'].includes(t.status));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (window.TicketsWorkspace) {
|
||||||
|
await TicketsWorkspace.renderPage({ listEl, detailEl, tickets });
|
||||||
|
} else {
|
||||||
state.tickets = tickets;
|
state.tickets = tickets;
|
||||||
listEl.innerHTML = state.tickets.length
|
listEl.innerHTML = state.tickets.length
|
||||||
? state.tickets.map(ticketRowHtml).join('')
|
? state.tickets.map(ticketRowHtml).join('')
|
||||||
: '<p class="loading">Nenhum ticket neste filtro</p>';
|
: '<p class="loading">Nenhum ticket nesto filtro</p>';
|
||||||
listEl.querySelectorAll('.ticket-row').forEach((btn) => {
|
listEl.querySelectorAll('.ticket-row').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
state.selectedTicketId = Number(btn.dataset.id);
|
state.selectedTicketId = Number(btn.dataset.id);
|
||||||
|
|
@ -1890,6 +1927,7 @@ async function renderTickets() {
|
||||||
if (state.selectedTicketId) await renderTicketDetail();
|
if (state.selectedTicketId) await renderTicketDetail();
|
||||||
else if (state.selectedSessionId) await renderSessionDetail();
|
else if (state.selectedSessionId) await renderSessionDetail();
|
||||||
else detailEl.innerHTML = '<div class="card detail-panel"><p class="empty">Selecione um ticket ou sessão do funil</p></div>';
|
else detailEl.innerHTML = '<div class="card detail-panel"><p class="empty">Selecione um ticket ou sessão do funil</p></div>';
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
listEl.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
listEl.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||||
}
|
}
|
||||||
|
|
@ -1929,6 +1967,10 @@ async function renderSessionDetail() {
|
||||||
async function renderTicketDetail() {
|
async function renderTicketDetail() {
|
||||||
const detailEl = document.getElementById('ticket-detail');
|
const detailEl = document.getElementById('ticket-detail');
|
||||||
if (!state.selectedTicketId) return;
|
if (!state.selectedTicketId) return;
|
||||||
|
if (window.TicketsDetailPanel) {
|
||||||
|
await TicketsDetailPanel.render(state.selectedTicketId, detailEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
detailEl.innerHTML = '<div class="card detail-panel"><p class="loading">Carregando…</p></div>';
|
detailEl.innerHTML = '<div class="card detail-panel"><p class="loading">Carregando…</p></div>';
|
||||||
try {
|
try {
|
||||||
const t = await api(`/v1/desk/tickets/${state.selectedTicketId}`);
|
const t = await api(`/v1/desk/tickets/${state.selectedTicketId}`);
|
||||||
|
|
@ -3720,7 +3762,10 @@ async function refresh(options = {}) {
|
||||||
if (state.view === 'overview') await renderOverview();
|
if (state.view === 'overview') await renderOverview();
|
||||||
if (state.view === 'overview-home') await renderOverviewHome({ poll });
|
if (state.view === 'overview-home') await renderOverviewHome({ poll });
|
||||||
if (state.view === 'leads') await renderLeads();
|
if (state.view === 'leads') await renderLeads();
|
||||||
if (state.view === 'tickets') await renderTickets();
|
if (state.view === 'tickets') {
|
||||||
|
if (poll && window.TicketsWorkspace?._pageReady) await TicketsWorkspace.softRefresh();
|
||||||
|
else await renderTickets({ poll: false });
|
||||||
|
}
|
||||||
if (state.view === 'events') await renderEvents();
|
if (state.view === 'events') await renderEvents();
|
||||||
if (state.view === 'tenants') await renderTenants();
|
if (state.view === 'tenants') await renderTenants();
|
||||||
if (state.view === 'infra') await renderInfra();
|
if (state.view === 'infra') await renderInfra();
|
||||||
|
|
|
||||||
8
projects/ops-desk/frontend/assets/desk-live-stub.js
Normal file
8
projects/ops-desk/frontend/assets/desk-live-stub.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/** DeskLive stub — tickets funcionam sem módulo wizard-live activo */
|
||||||
|
window.DeskLive = window.DeskLive || {
|
||||||
|
enabled() { return false; },
|
||||||
|
openTrail() {},
|
||||||
|
renderNavigationTab(_sid, el) {
|
||||||
|
if (el) el.innerHTML = '<p class="loading">Live Presence indisponível (módulo wizard-live).</p>';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -4226,3 +4226,126 @@ button.health-card {
|
||||||
.migration-gate-warning { color: #f59e0b; }
|
.migration-gate-warning { color: #f59e0b; }
|
||||||
.billing-modal-backdrop { position: fixed; inset: 0; background: #0009; z-index: 900; display: flex; align-items: center; justify-content: center; }
|
.billing-modal-backdrop { position: fixed; inset: 0; background: #0009; z-index: 900; display: flex; align-items: center; justify-content: center; }
|
||||||
.billing-modal { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; max-width: 480px; width: 90%; }
|
.billing-modal { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; max-width: 480px; width: 90%; }
|
||||||
|
.ticket-card-aside {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.65rem 0.75rem 0.65rem 0;
|
||||||
|
}
|
||||||
|
.tickets-kpi {
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.tickets-kpi:hover { border-color: var(--accent); }
|
||||||
|
.tickets-kpi.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.tickets-queue-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
.tickets-queue-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
.tickets-queue-chip {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.tickets-queue-chip:hover { border-color: var(--accent); }
|
||||||
|
.tickets-queue-chip.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.tickets-queue-clear {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.ticket-detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.ticket-detail-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin: 0.75rem 0 1rem;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.ticket-detail-tab {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.ticket-detail-tab:hover { color: #2a2520; }
|
||||||
|
.ticket-detail-tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
.ticket-detail-pane[hidden] { display: none !important; }
|
||||||
|
.ticket-next-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ticket-next-action--live { background: #ecfdf5; border-color: rgba(52, 211, 153, 0.4); }
|
||||||
|
.ticket-next-action--stale { background: #fef3e8; border-color: rgba(234, 179, 8, 0.35); }
|
||||||
|
.ticket-next-action--warn { background: #fef3e8; }
|
||||||
|
.ticket-next-action--danger { background: #fde8e8; }
|
||||||
|
.ticket-next-action--billing { background: #eff6ff; }
|
||||||
|
.ticket-next-action--info { background: #f5f2ed; }
|
||||||
|
.ticket-next-action-icon { font-size: 1.25rem; line-height: 1; }
|
||||||
|
.ticket-next-action-body { flex: 1; min-width: 0; }
|
||||||
|
.ticket-next-action-body strong { display: block; font-size: 0.85rem; }
|
||||||
|
.ticket-next-action-body span { display: block; font-size: 0.76rem; color: var(--muted); }
|
||||||
|
.ticket-live-presence-card {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid rgba(52, 211, 153, 0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f0fdf8;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.kv--compact dt { font-size: 0.72rem; }
|
||||||
|
.kv--compact dd { font-size: 0.82rem; }
|
||||||
|
.ticket-payload-details { margin-top: 1rem; }
|
||||||
|
.ticket-payload-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
467
projects/ops-desk/frontend/assets/tickets-detail-panel.js
Normal file
467
projects/ops-desk/frontend/assets/tickets-detail-panel.js
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
/**
|
||||||
|
* 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>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
497
projects/ops-desk/frontend/assets/tickets-workspace.js
Normal file
497
projects/ops-desk/frontend/assets/tickets-workspace.js
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
@ -414,9 +414,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/assets/auth.js?v=20260617servicos2"></script>
|
<script src="/assets/auth.js?v=20260619tickets3"></script>
|
||||||
<script src="/assets/modules.js?v=20260617servicos2"></script>
|
<script src="/assets/modules.js?v=20260619tickets3"></script>
|
||||||
<script src="/assets/servicos.js?v=20260617vm112fix"></script>
|
<script src="/assets/billing-ui.js?v=20260619tickets3"></script>
|
||||||
<script src="/assets/app.js?v=20260617vm112fix"></script>
|
<script src="/assets/desk-live-stub.js?v=20260619tickets3"></script>
|
||||||
|
<script src="/assets/tickets-workspace.js?v=20260619tickets3"></script>
|
||||||
|
<script src="/assets/tickets-detail-panel.js?v=20260619tickets3"></script>
|
||||||
|
<script src="/assets/servicos.js?v=20260619tickets3"></script>
|
||||||
|
<script src="/assets/app.js?v=20260619tickets3"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
165
specs/029-tickets-workspace/ARCHITECTURE.md
Normal file
165
specs/029-tickets-workspace/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
# Tickets Workspace — Arquitectura (P0 → P2)
|
||||||
|
|
||||||
|
**Data:** 2026-06-19
|
||||||
|
**Solicitado por:** Roger
|
||||||
|
**VM:** 122 — `desk.ligbox.com.br`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visão geral
|
||||||
|
|
||||||
|
O ecrã **Tickets** foi modularizado em três camadas frontend, inspirado em Site24x7 / Form Manager (leve, scan rápido). O `app.js` mantém-se como orquestrador global; a lógica de fila e detalhe vive em módulos dedicados.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ index.html │
|
||||||
|
│ auth.js → modules.js → live-presence.js │
|
||||||
|
│ tickets-workspace.js → tickets-detail-panel.js → app.js │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ app.js (orquestrador) │
|
||||||
|
│ renderTickets() ──delega──► TicketsWorkspace.renderPage() │
|
||||||
|
│ renderTicketDetail() ──delega──► TicketsDetailPanel.render()│
|
||||||
|
│ refresh({ poll }) ──poll──► TicketsWorkspace.softRefresh() │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────┐ ┌──────────────────────────────┐
|
||||||
|
│ tickets-workspace.js │ │ tickets-detail-panel.js │
|
||||||
|
│ P0: 8 KPIs + cards │ │ P1: abas + próxima acção │
|
||||||
|
│ P1: filas chips │ │ Resumo | Ao vivo | Funil │
|
||||||
|
│ P2: KPI click filter │ │ │
|
||||||
|
│ P2: softRefresh │ │ │
|
||||||
|
└──────────────────────┘ └──────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼ APIs (paralelo em loadContext)
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ GET /v1/desk/tickets — lista base │
|
||||||
|
│ GET /v1/live/presence — sinal LIVE 🟢 (Spec 027) │
|
||||||
|
│ GET /v1/onboard/funnel — fase funil + stale │
|
||||||
|
│ GET /v1/desk/summary — onboard_stale_hours (SLA) │
|
||||||
|
│ GET /v1/desk/tickets/{id} — detalhe (painel) │
|
||||||
|
│ GET /v1/assist/sessions/{id} — assist + timeline │
|
||||||
|
│ GET /v1/live/sessions/{id}/trail — aba Ao vivo │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulos
|
||||||
|
|
||||||
|
### 1. `tickets-workspace.js` — Lista e fila
|
||||||
|
|
||||||
|
| Função | Responsabilidade |
|
||||||
|
|--------|------------------|
|
||||||
|
| `loadContext()` | Fetch paralelo presence + funnel + summary |
|
||||||
|
| `enrichTicket(t)` | Adiciona `_live`, `_stale`, `_stage`, ícones |
|
||||||
|
| `computeIndicators()` | 8 KPIs do topo |
|
||||||
|
| `ticketCardHtml(t)` | Card com 3 sinais (estado, live, fase/SLA) |
|
||||||
|
| `filterByQueue()` / `filterBySearch()` | Filas inteligentes + busca |
|
||||||
|
| `renderPage()` | Montagem completa (P0) |
|
||||||
|
| `softRefresh()` | Poll sem flash — actualiza KPIs e sinais live (P2) |
|
||||||
|
| `setQueueFilter()` | KPI ou chip clicável filtra lista (P2) |
|
||||||
|
|
||||||
|
**Estado interno:** `context`, `queueFilter`, `searchQuery`, `_pageReady`, `_listFingerprint`
|
||||||
|
|
||||||
|
### 2. `tickets-detail-panel.js` — Painel direito
|
||||||
|
|
||||||
|
| Função | Responsabilidade |
|
||||||
|
|--------|------------------|
|
||||||
|
| `computeNextAction()` | Banner contextual (Carbonio, live, stale, billing…) |
|
||||||
|
| `tabsHtml()` | Abas Resumo \| Ao vivo \| Funil |
|
||||||
|
| `resumoHtml()` | KV + assist + carbonio + fechar ticket |
|
||||||
|
| `livePaneHtml()` | Presença + trail telemetria |
|
||||||
|
| `funilPaneHtml()` | Relógio por fase + timeline eventos |
|
||||||
|
| `render()` | Carrega ticket + meta assist + render tabbed UI |
|
||||||
|
|
||||||
|
**Dependências:** `assistActionsHtml`, `phaseTimingCardHtml`, `DeskLive.renderNavigationTab`, `DeskLive.openTrail`
|
||||||
|
|
||||||
|
### 3. `app.js` — Delegação mínima
|
||||||
|
|
||||||
|
- `renderTickets({ poll })` — se `poll && _pageReady` → `softRefresh`, senão `renderPage`
|
||||||
|
- `renderTicketDetail()` — delega a `TicketsDetailPanel` se existir
|
||||||
|
- `refresh()` — tickets usa soft refresh no intervalo global
|
||||||
|
|
||||||
|
### 4. `live-presence.js` (existente)
|
||||||
|
|
||||||
|
- Fornece `DeskLive.enabled()`, presence map, trail, modal
|
||||||
|
- Reutilizado na aba **Ao vivo** sem duplicar lógica
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entregas por prioridade
|
||||||
|
|
||||||
|
### P0 — Lista command center ✅
|
||||||
|
- 8 indicadores no topo
|
||||||
|
- Cards com 3 sinais (estado, live/offline, fase ou «parado Nh»)
|
||||||
|
- Ícones opcionais 💳 🔒 ⚠️ LEAD
|
||||||
|
- Busca client-side
|
||||||
|
- Módulo `tickets-workspace.js` separado
|
||||||
|
|
||||||
|
### P1 — Detalhe e filas ✅
|
||||||
|
- Abas **Resumo | Ao vivo | Funil**
|
||||||
|
- Banner **próxima acção** com CTA (assumir, escalar, resolver Carbonio)
|
||||||
|
- **Filas inteligentes** (chips): Live, Parados, Sem dono, Billing, Wazuh, Escalados
|
||||||
|
- Módulo `tickets-detail-panel.js` separado
|
||||||
|
|
||||||
|
### P2 — Interacção e tempo real ✅
|
||||||
|
- **KPIs clicáveis** — mesmo filtro que chips (toggle)
|
||||||
|
- **softRefresh** — poll ~30s actualiza live/stale sem re-render completo
|
||||||
|
- Fingerprint da lista evita flash quando estrutura não muda
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxo de dados (enrichment)
|
||||||
|
|
||||||
|
```
|
||||||
|
Ticket API (raw)
|
||||||
|
│
|
||||||
|
├─ session_id ──► liveBySession[path, ip, last_seen]
|
||||||
|
│ └──► _live, _livePath
|
||||||
|
│
|
||||||
|
├─ session_id ──► funnelBySession[current_stage, stale, last_event_at]
|
||||||
|
│ └──► _stage, _stale, _idleHours
|
||||||
|
│
|
||||||
|
└─ payload fields ──► _billing, _wazuh, _carbonioHint, _unassigned
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debug rápido
|
||||||
|
|
||||||
|
| Sintoma | Verificar |
|
||||||
|
|---------|-----------|
|
||||||
|
| LIVE sempre offline | `/v1/live/presence` + `session_id` no ticket |
|
||||||
|
| «parado Nh» errado | `/v1/onboard/funnel` + `onboard_stale_hours` em summary |
|
||||||
|
| Flash na lista | `softRefresh` vs `renderPage`; fingerprint |
|
||||||
|
| Aba Ao vivo vazia | Módulo `wizard-live` activo; trail em `/v1/live/sessions/{id}/trail` |
|
||||||
|
| Próxima acção não aparece | `computeNextAction` — prioridade Carbonio > live > stale |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ficheiros
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
index.html # scripts + cache bust
|
||||||
|
assets/
|
||||||
|
app.js # delegação renderTickets / refresh
|
||||||
|
tickets-workspace.js # P0 + P1 filas + P2 poll
|
||||||
|
tickets-detail-panel.js # P1 abas + próxima acção
|
||||||
|
live-presence.js # telemetria Spec 027
|
||||||
|
styles.css # .tickets-* .ticket-detail-*
|
||||||
|
specs/027-wizard-live-analytics/
|
||||||
|
TICKETS-WORKSPACE-ARCHITECTURE.md # este documento
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Próximo (fora P0–P2)
|
||||||
|
|
||||||
|
- Filtro server-side por fila (endpoint único enriquecido)
|
||||||
|
- Abas adicionais: Billing, Carbonio dedicado
|
||||||
|
- Notificação ntfy ao cruzar stale + live simultâneo
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Corretivo — Reconstrução Motor Tickets (2026-06-19)
|
||||||
|
|
||||||
|
**Reportado por:** Roger
|
||||||
|
**Causa:** Código implementado na sessão Cursor não estava no Git CT130 nem na VM122 produção.
|
||||||
|
|
||||||
|
## Acção
|
||||||
|
|
||||||
|
1. Extraído chat bruto transcript `59ee9646`
|
||||||
|
2. Reconstruídos `tickets-workspace.js`, `tickets-detail-panel.js`
|
||||||
|
3. Patch `app.js` — delegação + helpers `normalizeAssistStatus`, `ticketFunnelKvHtml`
|
||||||
|
4. CSS tickets + index.html scripts
|
||||||
|
5. Commit Forgejo CT130 — **pendente deploy VM122** (SSH recusado 19/06 ~19:20 UTC)
|
||||||
|
|
||||||
|
## Validar pós-deploy VM122
|
||||||
|
|
||||||
|
- [ ] `/assets/tickets-workspace.js` HTTP 200
|
||||||
|
- [ ] Vista Tickets: 8 KPIs visíveis
|
||||||
|
- [ ] Clicar ticket → abas Resumo | Funil
|
||||||
|
- [ ] Filtro chips Live / Parados funciona
|
||||||
|
- [ ] Ctrl+Shift+R após deploy
|
||||||
|
|
||||||
|
## Script deploy
|
||||||
|
|
||||||
|
`/opt/ligbox-spec-hub/scripts/deploy-tickets-vm122.sh`
|
||||||
57
specs/029-tickets-workspace/spec.md
Normal file
57
specs/029-tickets-workspace/spec.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Spec 029 — Tickets Workspace (Motor de Tickets)
|
||||||
|
|
||||||
|
**Status:** ✅ Reconstruído 2026-06-19
|
||||||
|
**Prioridade:** P0 — antes de Live Presence / ASM
|
||||||
|
**VM:** VM122 Desk · `desk.ligbox.com.br`
|
||||||
|
|
||||||
|
## Resumo
|
||||||
|
|
||||||
|
Motor de tickets modular — command center com KPIs, filas inteligentes, painel de detalhe com abas e integração ASM (quando Spec 010 activa).
|
||||||
|
|
||||||
|
## Módulos frontend
|
||||||
|
|
||||||
|
| Ficheiro | Função |
|
||||||
|
|----------|--------|
|
||||||
|
| `tickets-workspace.js` | Lista P0-P2: 8 KPIs, cards, filas, busca, softRefresh |
|
||||||
|
| `tickets-detail-panel.js` | Detalhe P1: abas Resumo \| Ao vivo \| Funil \| Espelho |
|
||||||
|
| `desk-live-stub.js` | Fallback se módulo `wizard-live` inactivo |
|
||||||
|
| `app.js` | Delega `renderTickets` / `renderTicketDetail` |
|
||||||
|
|
||||||
|
## Entregas P0 → P2
|
||||||
|
|
||||||
|
### P0 — Lista
|
||||||
|
- 8 KPIs: Abertos, Assistindo, Escalados, Live, Sem dono, Parados, Billing, Wazuh
|
||||||
|
- Cards com 3 sinais: estado · live/offline · fase/SLA
|
||||||
|
- Busca por #ticket, domínio, e-mail, sessão, OB-
|
||||||
|
|
||||||
|
### P1 — Detalhe + filas
|
||||||
|
- Abas: Resumo, Ao vivo, Funil, Espelho cliente (pré-ASM)
|
||||||
|
- Banner próxima acção (assumir, escalar, reabrir ASM…)
|
||||||
|
- Chips de fila: Live, Parados, Sem dono, Billing, Wazuh, Escalados
|
||||||
|
|
||||||
|
### P2 — Interacção
|
||||||
|
- KPIs clicáveis (filtram lista)
|
||||||
|
- `softRefresh` no poll global (~30s) sem flash
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# VM122 (quando SSH activo)
|
||||||
|
bash /opt/ligbox-spec-hub/scripts/deploy-tickets-vm122.sh
|
||||||
|
|
||||||
|
# Ou manual
|
||||||
|
cd /opt/ligbox-ops-platform
|
||||||
|
git pull
|
||||||
|
cp -a projects/ops-desk/frontend/assets/tickets*.js frontend/assets/
|
||||||
|
docker compose -f docker-compose.mvp.yml build frontend && docker compose -f docker-compose.mvp.yml up -d frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependências
|
||||||
|
|
||||||
|
- Spec 003 (auth/RBAC) — `canAssist()`, roles
|
||||||
|
- Spec 010 (ASM) — abas espelho, resume-wizard (backend)
|
||||||
|
- Spec 012 (ABANDONADO) — rails stale/lead nos cards
|
||||||
|
- Spec 015 (módulos) — `DeskModules`
|
||||||
|
- Spec 027 wizard-live (opcional) — substitui `desk-live-stub.js`
|
||||||
|
|
||||||
|
→ Ver [ARCHITECTURE.md](./ARCHITECTURE.md)
|
||||||
Loading…
Reference in a new issue