diff --git a/projects/ops-desk/frontend/assets/app.js b/projects/ops-desk/frontend/assets/app.js index 0af3104..71a6fe4 100644 --- a/projects/ops-desk/frontend/assets/app.js +++ b/projects/ops-desk/frontend/assets/app.js @@ -608,6 +608,28 @@ function billingTicketIcon(t) { 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' + ? 'concluído' + : outcome === 'failed' + ? 'falhou' + : ''; + const label = latest ? (SOC_EVENT_LABELS[latest] || latest) : '—'; + const sev = latest && typeof socEventSeverity === 'function' ? socEventSeverity(latest) : 'open'; + return ` +
Estado funil
${esc(label)} ${outcomeBadge}
+ ${showOpened ? `
Abertura
${esc(SOC_EVENT_LABELS[opened] || opened)}
` : ''}`; +} + function ticketRowHtml(t) { const review = t.needs_review ? 'revisão' : ''; const verified = t.account_verified ? 'verificado' : ''; @@ -1845,10 +1867,15 @@ async function renderLeads() { } } -async function renderTickets() { +async function renderTickets(options = {}) { + const { poll = false } = options; stopLiveTimingClock(); const listEl = document.getElementById('ticket-list'); const detailEl = document.getElementById('ticket-detail'); + if (poll && window.TicketsWorkspace?._pageReady) { + await TicketsWorkspace.softRefresh(); + return; + } listEl.innerHTML = '

Carregando tickets…

'; try { let tickets = []; @@ -1864,32 +1891,43 @@ async function renderTickets() { source: 'vm112-onboard', crm_track: 'lead', assigned_to: l.assigned_to, + session_id: l.session_id, + lead_funnel_stage: l.funnel_stage, })); } else { let q = ''; 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 (params.length) q = '?' + params.join('&'); const data = await api(`/v1/desk/tickets${q}`); tickets = data.tickets || []; + if (state.ticketFilter === 'active') { + tickets = tickets.filter((t) => ['open', 'escalated', 'assisting', 'resolved'].includes(t.status)); + } } - state.tickets = tickets; - listEl.innerHTML = state.tickets.length - ? state.tickets.map(ticketRowHtml).join('') - : '

Nenhum ticket neste filtro

'; - listEl.querySelectorAll('.ticket-row').forEach((btn) => { - btn.addEventListener('click', () => { - state.selectedTicketId = Number(btn.dataset.id); - state.selectedSessionId = null; - renderTicketDetail(); - listEl.querySelectorAll('.ticket-row').forEach((r) => r.classList.remove('selected')); - btn.classList.add('selected'); + if (window.TicketsWorkspace) { + await TicketsWorkspace.renderPage({ listEl, detailEl, tickets }); + } else { + state.tickets = tickets; + listEl.innerHTML = state.tickets.length + ? state.tickets.map(ticketRowHtml).join('') + : '

Nenhum ticket nesto filtro

'; + listEl.querySelectorAll('.ticket-row').forEach((btn) => { + btn.addEventListener('click', () => { + state.selectedTicketId = Number(btn.dataset.id); + state.selectedSessionId = null; + renderTicketDetail(); + listEl.querySelectorAll('.ticket-row').forEach((r) => r.classList.remove('selected')); + btn.classList.add('selected'); + }); }); - }); - if (state.selectedTicketId) await renderTicketDetail(); - else if (state.selectedSessionId) await renderSessionDetail(); - else detailEl.innerHTML = '

Selecione um ticket ou sessão do funil

'; + if (state.selectedTicketId) await renderTicketDetail(); + else if (state.selectedSessionId) await renderSessionDetail(); + else detailEl.innerHTML = '

Selecione um ticket ou sessão do funil

'; + } } catch (e) { listEl.innerHTML = `

Erro: ${esc(e.message)}

`; } @@ -1929,6 +1967,10 @@ async function renderSessionDetail() { async function renderTicketDetail() { const detailEl = document.getElementById('ticket-detail'); if (!state.selectedTicketId) return; + if (window.TicketsDetailPanel) { + await TicketsDetailPanel.render(state.selectedTicketId, detailEl); + return; + } detailEl.innerHTML = '

Carregando…

'; try { 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-home') await renderOverviewHome({ poll }); 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 === 'tenants') await renderTenants(); if (state.view === 'infra') await renderInfra(); diff --git a/projects/ops-desk/frontend/assets/desk-live-stub.js b/projects/ops-desk/frontend/assets/desk-live-stub.js new file mode 100644 index 0000000..42c41a7 --- /dev/null +++ b/projects/ops-desk/frontend/assets/desk-live-stub.js @@ -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 = '

Live Presence indisponível (módulo wizard-live).

'; + }, +}; diff --git a/projects/ops-desk/frontend/assets/styles.css b/projects/ops-desk/frontend/assets/styles.css index 5dc10f0..1a4b8f7 100644 --- a/projects/ops-desk/frontend/assets/styles.css +++ b/projects/ops-desk/frontend/assets/styles.css @@ -4226,3 +4226,126 @@ button.health-card { .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 { 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) { \ No newline at end of file diff --git a/projects/ops-desk/frontend/assets/tickets-detail-panel.js b/projects/ops-desk/frontend/assets/tickets-detail-panel.js new file mode 100644 index 0000000..9fbaacc --- /dev/null +++ b/projects/ops-desk/frontend/assets/tickets-detail-panel.js @@ -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 '

Carregando espelho do wizard…

'; + } + if (observer.is_assisting) { + return '

ASM ativo — o cliente está pausado. Use Reabrir wizard ASM para continuar no modo técnico.

'; + } + 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 Ajuda do Suporte aberto'); + if (modals.dns) modalLines.push('Modal DNS aberto'); + if (modals.dns_advanced) modalLines.push('Modal DNS avançado aberto'); + const activity = (observer.activity_log || []).slice(-12).map((e) => { + const lvl = (e.level || 'info').toLowerCase(); + return `
  • ${esc(e.message || e.msg || '')}
  • `; + }).join(''); + const infraPending = (observer.infra_status?.steps || []).filter((s) => !s.ok).slice(0, 4) + .map((s) => esc(s.label || s.id)).join(' · '); + return ` +
    +

    O técnico lê aqui o mesmo contexto do cliente antes de assumir a sessão.

    +
    +
    + Cliente no wizard + ${observer.presence?.wizard_step || step !== '—' ? `${esc(step)}` : ''} +
    +
    +
    Passo
    ${esc(step)}
    +
    Domínio
    ${esc(domain)}
    + ${observer.wizard_ticket_id ? `
    Chamado wizard
    ${esc(observer.wizard_ticket_id)}
    ` : ''} + ${observer.client_note ? `
    Nota do cliente
    ${esc(observer.client_note)}
    ` : ''} +
    + ${err ? `` : ''} + ${modalLines.length ? `
    ${modalLines.join(' · ')}
    ` : ''} + ${infraPending ? `

    Infra pendente: ${infraPending}

    ` : ''} +
    +
    Terminal / activity log (cliente)
    +
      ${activity || '
    1. Sem linhas recentes
    2. '}
    +
    +
    +

    Atualizado ${fmtDate(observer.presence?.last_seen_at || new Date().toISOString())}

    +
    `; + }, + + 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 = `

    Espelho indisponível: ${esc(e.message)}

    `; + } + }, + + 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 ` +
    +

    Carregando espelho do wizard…

    +
    `; + }, + + 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' + ? '' + : action.cta === 'resume-wizard' + ? '' + : action.cta === 'escalate' + ? '' + : action.cta === 'scroll-carbonio' + ? '' + : ''; + return ` +
    + +
    + ${esc(action.title)} + ${esc(action.text)} +
    + ${cta} +
    `; + }, + + 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 ` + `; + }, + + resumoHtml(t, { sessionId, assistMeta, carbonioBlock, timeline, timing }) { + const closeStatuses = ['open', 'escalated', 'assisting', 'resolved']; + return ` +
    +
    +
    Origem
    ${sourceBadge(t.source)}
    +
    Domínio/Agente
    ${esc(t.domain || t.agent || '—')}
    +
    Email
    ${esc(t.email || '—')}
    + ${typeof ticketFunnelKvHtml === 'function' ? ticketFunnelKvHtml(t) : `
    Evento
    ${esc(t.event || '—')}
    `} + ${t.assigned_to ? `
    Atribuído
    ${esc(t.assigned_to)}
    ` : ''} + ${t.assisted_by ? `
    Assistido por
    ${esc(t.assisted_by)}
    ` : ''} + ${t.client_paused ? '
    Cliente
    pausado
    ' : ''} + ${t.ready_for_ops ? '
    Ops
    ready for ops
    ' : ''} + ${t.severity != null ? `
    Severidade
    ${severityBadge(t.severity)}
    ` : ''} + ${t.rule_id ? `
    Regra
    ${esc(t.rule_id)}
    ` : ''} + ${t.description ? `
    Descrição
    ${esc(t.description)}
    ` : ''} + ${t.desk_message ? `
    Nota
    ${esc(t.desk_message)}
    ` : ''} + ${t.registration_role ? `
    Perfil
    ${esc(roleLabel(t.registration_role))}
    ` : ''} + ${t.activation_url ? `
    Ativar conta
    Abrir link de ativação
    ` : ''} +
    Sessão/Alert ID
    ${esc(t.session_id || '—')}
    + ${t.wizard_ticket_id ? `
    Chamado wizard
    ${esc(t.wizard_ticket_id)}
    ` : ''} + ${t.wizard_client_note ? `
    Nota cliente
    ${esc(t.wizard_client_note)}
    ` : ''} +
    Verificado
    ${t.account_verified ? 'Sim' : 'Não'}
    +
    Revisão
    ${t.needs_review ? 'Necessária' : 'Não'}
    +
    Criado
    ${fmtDate(t.created_at)}
    +
    + ${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 ? `
    ${carbonioBlockPanelHtml(carbonioBlock)}
    ` : ''} +
    + ${typeof canPatchTickets === 'function' && canPatchTickets() + ? (closeStatuses.includes(t.status) + ? '' + : '') + : ''} +
    +
    + Payload técnico +
    ${esc(JSON.stringify(t.payload, null, 2))}
    +
    +
    `; + }, + + livePaneHtml(sessionId) { + const enriched = window.TicketsWorkspace?.context?.liveBySession?.[sessionId]; + return ` +
    + ${enriched ? ` +
    + LIVE +
    +
    Path
    ${esc(enriched.path || '—')}
    +
    Passo wizard
    ${esc(enriched.wizard_step || '—')}
    +
    IP
    ${esc(enriched.ip || '—')}
    +
    Último sinal
    ${fmtDate(enriched.last_seen_at || enriched.updated_at)}
    +
    +
    ` : '

    Cliente offline neste momento.

    '} +
    + +
    `; + }, + + funilPaneHtml(timeline, timing) { + return ` +
    + ${timeline?.length + ? `${phaseTimingCardHtml(timing, timeline)}${timelineHtml(timeline, timing, { compact: false })}` + : '

    Sem eventos de funil para esta sessão.

    '} +
    `; + }, + + 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 = '

    Carregando…

    '; + 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 = ` +
    +
    +
    +

    Ticket #${t.id}${t.wizard_ticket_id ? ` · ${esc(t.wizard_ticket_id)}` : ''}

    +

    ${esc(t.domain || t.subject || t.agent || '')}

    +
    + ${esc(statusLabel(t.status))} +
    + ${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) : ''} +
    `; + + 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 = `

    Erro: ${esc(e.message)}

    `; + } + }, +}; + +window.TicketsDetailPanel = TicketsDetailPanel; diff --git a/projects/ops-desk/frontend/assets/tickets-workspace.js b/projects/ops-desk/frontend/assets/tickets-workspace.js new file mode 100644 index 0000000..8e1f2f5 --- /dev/null +++ b/projects/ops-desk/frontend/assets/tickets-workspace.js @@ -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 ` +
    + ${items.map((it) => ` + `).join('')} +
    `; + }, + + queueChipsHtml() { + return ` +
    + Filas: + ${this.QUEUE_CHIPS.map((c) => ` + `).join('')} + ${this.queueFilter ? '' : ''} +
    `; + }, + + phaseSignal(t) { + if (t._stale) return { text: `parado ${t._idleHours}h`, cls: 'stale' }; + if (t._stageLabel && t._stageLabel !== t.event) { + const short = String(t._stageLabel).replace(/validado|aplicado|criada/gi, '').trim() || t._stageLabel; + return { text: short.slice(0, 28), cls: 'phase' }; + } + if (t.event) return { text: String(t.event).replace('onboarding.', '').replace('.', ' '), cls: 'phase' }; + return { text: '—', cls: 'muted' }; + }, + + ticketCardHtml(t) { + const phase = this.phaseSignal(t); + const isOnboard = t.source === 'vm112-onboard' || t.event?.startsWith?.('onboarding'); + const title = t.event === 'wazuh.alert' + ? esc(t.description || t.agent || t.subject) + : t.domain + ? esc(t.domain) + : isOnboard + ? `Onboarding · ${esc(t._stageLabel || 'VM112')}` + : esc(t.subject || `Ticket #${t.id}`); + const metaParts = []; + metaParts.push(`#${t.id}`); + if (t.wizard_ticket_id) metaParts.push(esc(t.wizard_ticket_id)); + if (t.session_id) metaParts.push(sessionHashHtml(t.session_id, { full: false })); + if (t.email) metaParts.push(esc(t.email)); + if (t.assigned_to || t.assisted_by) metaParts.push(esc(t.assisted_by || t.assigned_to)); + metaParts.push(fmtDate(t.created_at)); + const icons = [ + t._billing ? '💳' : '', + t._carbonioHint ? '🔒' : '', + t._wazuh ? `${severityBadge(t.severity) || '⚠️'}` : '', + t.crm_track === 'lead' ? 'LEAD' : '', + ].filter(Boolean).join(''); + const selected = state.selectedTicketId === t.id; + return ` + `; + }, + + filterByQueue(tickets) { + if (!this.queueFilter) return tickets; + const rules = { + open: (t) => t.status === 'open', + assisting: (t) => t.status === 'assisting', + escalated: (t) => t.status === 'escalated', + live: (t) => t._live, + unassigned: (t) => t._unassigned, + stale: (t) => t._stale, + billing: (t) => t._billing, + wazuh: (t) => t._wazuh, + }; + const fn = rules[this.queueFilter]; + return fn ? tickets.filter(fn) : tickets; + }, + + filterBySearch(tickets) { + const raw = (this.searchQuery || '').trim(); + if (!raw) return tickets; + const q = raw.toLowerCase(); + const ticketIdQuery = q.replace(/^ticket\s*#?/, '').replace(/^#/, '').trim(); + + return tickets.filter((t) => { + if (/^\d+$/.test(ticketIdQuery) && String(t.id) === ticketIdQuery) return true; + const wiz = (t.wizard_ticket_id || '').toLowerCase(); + if (wiz && (wiz === q || wiz.includes(q))) return true; + const liveIp = this.context?.liveBySession?.[t.session_id]?.client_ip; + const hay = [ + t.id, + `#${t.id}`, + `ticket ${t.id}`, + t.subject, + t.domain, + t.email, + t.session_id, + t.agent, + t.description, + t.assigned_to, + t.assisted_by, + t.wizard_ticket_id, + liveIp, + ].filter(Boolean).join(' ').toLowerCase(); + return hay.includes(q); + }); + }, + + applyFilters(tickets) { + return this.filterBySearch(this.filterByQueue(tickets)); + }, + + listFingerprint(tickets) { + return tickets.map((t) => [ + t.id, t.status, t._live ? 1 : 0, t._stale ? 1 : 0, t._stage, t._idleHours, t._livePath || '', + ].join(':')).join('|'); + }, + + setQueueFilter(key) { + this.queueFilter = this.queueFilter === key ? null : key; + const bar = document.getElementById('tickets-queue-bar'); + if (bar) { + bar.innerHTML = this.queueChipsHtml(); + this.bindQueueBar(bar); + } + this.syncQueueUi(); + this.renderListOnly(); + }, + + syncQueueUi() { + document.querySelectorAll('[data-ticket-kpi]').forEach((el) => { + el.classList.toggle('active', el.dataset.ticketKpi === this.queueFilter); + }); + document.querySelectorAll('[data-ticket-queue]').forEach((el) => { + el.classList.toggle('active', el.dataset.ticketQueue === this.queueFilter); + }); + const clearBtn = document.querySelector('[data-ticket-queue-clear]'); + if (clearBtn) clearBtn.hidden = !this.queueFilter; + }, + + bindKpiStrip(strip) { + strip.querySelectorAll('[data-ticket-kpi]').forEach((el) => { + el.addEventListener('click', () => this.setQueueFilter(el.dataset.ticketKpi)); + }); + }, + + bindQueueBar(bar) { + bar.querySelectorAll('[data-ticket-queue]').forEach((el) => { + el.addEventListener('click', () => this.setQueueFilter(el.dataset.ticketQueue)); + }); + bar.querySelector('[data-ticket-queue-clear]')?.addEventListener('click', () => { + this.queueFilter = null; + this.syncQueueUi(); + this.renderListOnly(); + }); + }, + + bindList(listEl) { + listEl.querySelectorAll('.ticket-card').forEach((btn) => { + btn.addEventListener('click', () => { + state.selectedTicketId = Number(btn.dataset.id); + state.selectedSessionId = btn.dataset.session || null; + listEl.querySelectorAll('.ticket-card').forEach((r) => r.classList.remove('selected')); + btn.classList.add('selected'); + if (window.TicketsDetailPanel) TicketsDetailPanel.render(state.selectedTicketId, document.getElementById('ticket-detail')); + else if (typeof renderTicketDetail === 'function') renderTicketDetail(); + }); + }); + }, + + mountSearchToolbar() { + let bar = document.getElementById('tickets-search-bar'); + if (!bar) { + const toolbar = document.querySelector('#view-tickets .toolbar'); + if (!toolbar) return; + bar = document.createElement('div'); + bar.id = 'tickets-search-bar'; + bar.className = 'tickets-search-bar'; + bar.innerHTML = ` + + Scan rápido · 3 sinais por card`; + toolbar.parentNode.insertBefore(bar, toolbar); + bar.querySelector('#tickets-search-input')?.addEventListener('input', (e) => { + this.searchQuery = e.target.value; + if (state.view === 'tickets') this.renderListOnly(); + }); + } + }, + + mountQueueBar() { + let bar = document.getElementById('tickets-queue-bar'); + if (!bar) { + const view = document.getElementById('view-tickets'); + const toolbar = view?.querySelector('.toolbar'); + if (!toolbar) return; + bar = document.createElement('div'); + bar.id = 'tickets-queue-bar'; + toolbar.parentNode.insertBefore(bar, toolbar.nextSibling); + } + bar.innerHTML = this.queueChipsHtml(); + this.bindQueueBar(bar); + }, + + updateKpiStrip(ind) { + const strip = document.getElementById('tickets-kpi-strip'); + if (!strip) return; + const map = { + open: ind.open, assisting: ind.assisting, escalated: ind.escalated, live: ind.live, + unassigned: ind.unassigned, stale: ind.stale, billing: ind.billing, wazuh: ind.wazuh, + }; + strip.querySelectorAll('[data-ticket-kpi]').forEach((el) => { + const key = el.dataset.ticketKpi; + const val = map[key]; + if (val == null) return; + const valueEl = el.querySelector('.tickets-kpi-value'); + if (valueEl) { + const icon = (key === 'billing' && val) ? '💳 ' : (key === 'wazuh' && val) ? '⚠️ ' : ''; + valueEl.textContent = `${icon}${val}`; + } + }); + }, + + patchLiveSignals(listEl, tickets) { + const byId = {}; + for (const t of tickets) byId[t.id] = t; + listEl.querySelectorAll('.ticket-card').forEach((card) => { + const t = byId[Number(card.dataset.id)]; + if (!t) return; + const wasLive = card.dataset.live === '1'; + const isLive = t._live; + if (wasLive !== isLive) { + card.dataset.live = isLive ? '1' : '0'; + card.classList.toggle('ticket-card--live', isLive); + const sig = card.querySelector('.ticket-signal--live'); + if (sig) { + sig.classList.toggle('is-live', isLive); + sig.classList.toggle('is-offline', !isLive); + sig.lastChild.textContent = isLive ? 'LIVE' : 'offline'; + } + } + card.dataset.stale = t._stale ? '1' : '0'; + const phase = card.querySelector('.ticket-signal--phase'); + if (phase) { + const p = this.phaseSignal(t); + phase.textContent = p.text; + phase.className = `ticket-signal ticket-signal--phase ticket-signal--${p.cls}`; + } + const pathEl = card.querySelector('[data-live-path]'); + if (t._live && t._livePath) { + if (pathEl) pathEl.textContent = t._livePath; + else { + const main = card.querySelector('.ticket-card-main'); + if (main) { + const span = document.createElement('span'); + span.className = 'ticket-card-livepath'; + span.dataset.livePath = ''; + span.textContent = t._livePath; + main.appendChild(span); + } + } + } else if (pathEl) pathEl.remove(); + }); + }, + + renderListHtml(tickets) { + return tickets.length + ? `
    ${tickets.map((t) => this.ticketCardHtml(t)).join('')}
    ` + : '

    Nenhum ticket neste filtro

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

    Selecione um ticket para ver detalhes

    '; + } + }, +}; + +window.TicketsWorkspace = TicketsWorkspace; diff --git a/projects/ops-desk/frontend/index.html b/projects/ops-desk/frontend/index.html index ddfaffa..ccb3e0b 100644 --- a/projects/ops-desk/frontend/index.html +++ b/projects/ops-desk/frontend/index.html @@ -414,9 +414,13 @@ - - - - + + + + + + + + diff --git a/specs/029-tickets-workspace/ARCHITECTURE.md b/specs/029-tickets-workspace/ARCHITECTURE.md new file mode 100644 index 0000000..ff63f1f --- /dev/null +++ b/specs/029-tickets-workspace/ARCHITECTURE.md @@ -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 diff --git a/specs/029-tickets-workspace/CORRETIVO-20260619-tickets-rebuild.md b/specs/029-tickets-workspace/CORRETIVO-20260619-tickets-rebuild.md new file mode 100644 index 0000000..f6e4e87 --- /dev/null +++ b/specs/029-tickets-workspace/CORRETIVO-20260619-tickets-rebuild.md @@ -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` diff --git a/specs/029-tickets-workspace/spec.md b/specs/029-tickets-workspace/spec.md new file mode 100644 index 0000000..01252ae --- /dev/null +++ b/specs/029-tickets-workspace/spec.md @@ -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)