ligbox-ops-platform/projects/ops-desk/frontend/assets/operational-feed.js
Ligbox Spec Hub b03bb2c37c feat(desk): UI/API Spec 039-041 + deploy atómico e smoke GREEN
Commita governance, user-wizard, operational-feed e catálogo RBAC;
adiciona deploy-desk-full.sh, smoke-desk.sh e regra anti-deploy parcial;
documenta credencial VM112 @betinplace.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 13:32:15 +00:00

317 lines
13 KiB
JavaScript

/**
* Central Operacional — Operational feed
* Spec 041 · OF-FE-001 · desk.ligbox.com.br (aba messages → Central Operacional)
*/
(function () {
'use strict';
let host = null;
let stats = null;
let events = [];
let selectedId = null;
let channel = 'all';
let priority = '';
let searchQ = '';
let detailTab = 'conversation';
let replyMode = 'reply';
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function fmtRel(iso) {
if (!iso) return '—';
const diff = (Date.now() - new Date(iso).getTime()) / 60000;
if (diff < 1) return 'agora';
if (diff < 60) return `${Math.round(diff)} min atrás`;
if (diff < 1440) return `${Math.round(diff / 60)} h atrás`;
return new Date(iso).toLocaleDateString('pt-PT');
}
function fmtSla(sec) {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function channelIcon(ch) {
const map = { whatsapp: '💬', email: '✉', voice: '📞', tickets: '🎫', agents: '🤖', internal: '📋', alerts: '⚠', sms: '📱', telegram: '✈', clients: '👤' };
return map[ch] || '•';
}
async function loadStats() {
const r = await fetchWithTimeout('/api/v1/ops-inbox/stats', { headers: authHeaders() });
if (!r.ok) throw new Error(String(r.status));
stats = await r.json();
}
async function loadEvents() {
const qs = new URLSearchParams({ channel, limit: '128' });
if (priority) qs.set('priority', priority);
if (searchQ) qs.set('q', searchQ);
const r = await fetchWithTimeout(`/api/v1/ops-inbox/events?${qs}`, { headers: authHeaders() });
if (!r.ok) throw new Error(String(r.status));
const data = await r.json();
events = data.events || [];
}
async function loadEvent(id) {
const r = await fetchWithTimeout(`/api/v1/ops-inbox/events/${encodeURIComponent(id)}`, { headers: authHeaders() });
if (!r.ok) throw new Error(String(r.status));
const data = await r.json();
return data.event;
}
function kpisHtml() {
if (!stats) return '';
return `
<div class="lb-ops-kpis">
<div class="lb-card lb-ops-kpi"><span class="lb-stat-label">Eventos hoje</span><span class="lb-stat-value">${stats.events_today}</span></div>
<div class="lb-card lb-ops-kpi"><span class="lb-stat-label">Pendentes</span><span class="lb-stat-value">${stats.pending}</span></div>
<div class="lb-card lb-ops-kpi"><span class="lb-stat-label">Críticos</span><span class="lb-stat-value">${stats.critical}</span></div>
<div class="lb-card lb-ops-kpi"><span class="lb-stat-label">Aguardando você</span><span class="lb-stat-value">${stats.awaiting_you}</span></div>
<div class="lb-card lb-ops-kpi"><span class="lb-stat-label">SLA médio</span><span class="lb-stat-value">${stats.sla_avg_pct}%</span></div>
</div>`;
}
function sidebarHtml() {
const channels = stats?.channels || [];
return `
<aside class="lb-card lb-ops-sidebar">
<button type="button" class="lb-btn-primary" style="width:100%;margin-bottom:12px">+ Nova mensagem</button>
<p class="lb-stat-label">CANAIS</p>
${channels.map((c) => `
<button type="button" class="lb-ops-channel${channel === c.id ? ' active' : ''}" data-of-channel="${esc(c.id)}">
<span>${esc(c.label)}</span><span>${c.count}</span>
</button>`).join('')}
<p class="lb-stat-label" style="margin-top:16px">FILTROS RÁPIDOS</p>
<button type="button" class="lb-ops-channel" data-of-priority="high">Prioridade alta</button>
<button type="button" class="lb-ops-channel" data-of-priority="critical">Críticos</button>
</aside>`;
}
function eventCard(ev) {
const sel = selectedId === ev.id ? ' selected' : '';
const pri = ev.priority === 'high' || ev.priority === 'critical'
? `<span class="lb-badge lb-badge--high">${esc(ev.priority)}</span>` : '';
return `
<article class="lb-entity-card${sel}" data-of-event="${esc(ev.id)}">
<div class="lb-entity-card__head">
<div>
<p class="lb-entity-card__title">${channelIcon(ev.channel)} ${esc(ev.title)}</p>
<p class="lb-entity-card__preview">${esc(ev.preview)}</p>
</div>
${pri}
</div>
<div class="lb-tag-row">
${(ev.tags || []).map((t) => `<span class="lb-tag">${esc(t)}</span>`).join('')}
<span class="lb-tag">${fmtRel(ev.created_at)}</span>
${ev.assignee ? `<span class="lb-tag">${esc(ev.assignee)}</span>` : ''}
</div>
</article>`;
}
function detailHtml(ev) {
if (!ev) {
return `<div class="lb-card lb-detail-panel"><div class="lb-detail-body"><p class="lb-stat-sub">Seleccione um evento</p></div></div>`;
}
const slaPct = Math.min(100, Math.round((ev.sla_remaining_sec / (ev.sla_minutes * 60)) * 100));
let tabContent = '';
if (detailTab === 'conversation') {
const msgs = (ev.messages || []).map((m) => {
const cls = m.author_type === 'user' ? 'user' : m.author_type === 'agent' ? 'agent'
: m.author_type === 'system' ? 'system' : 'operator';
return `<div class="lb-chat-bubble lb-chat-bubble--${cls}"><strong>${esc(m.author_label)}</strong><br/>${esc(m.body)}</div>`;
}).join('');
tabContent = `
<div class="lb-chat">${msgs || '<p class="lb-stat-sub">Sem mensagens</p>'}</div>
<nav class="lb-subnav" style="margin-bottom:8px">
<button type="button" class="lb-subtab${replyMode === 'reply' ? ' active' : ''}" data-of-reply="reply">Responder</button>
<button type="button" class="lb-subtab${replyMode === 'internal_note' ? ' active' : ''}" data-of-reply="internal_note">Nota interna</button>
</nav>
<textarea data-of-msg rows="3" style="width:100%;border:1px solid var(--lb-border);border-radius:8px;padding:8px" placeholder="Escreva a resposta…"></textarea>
<button type="button" class="lb-btn-primary" style="margin-top:8px" data-of-send>Enviar resposta</button>`;
} else if (detailTab === 'details') {
tabContent = `
<dl style="font-size:0.85rem;line-height:1.7">
<dt>Canal</dt><dd>${esc(ev.channel)}</dd>
<dt>Tipo</dt><dd>${esc(ev.event_type)}</dd>
<dt>Estado</dt><dd>${esc(ev.status)}</dd>
<dt>Responsável</dt><dd>${esc(ev.assignee || '—')}</dd>
</dl>`;
} else {
tabContent = `<p class="lb-stat-sub">Histórico simulado — integração futura.</p>`;
}
return `
<div class="lb-card lb-detail-panel">
<header class="lb-detail-head">
<span class="lb-badge lb-badge--active">${esc(ev.status)}</span>
<h3 style="margin:8px 0 4px">${esc(ev.contact_name || ev.title)}</h3>
<p class="lb-stat-sub">${esc(ev.channel)}</p>
</header>
<nav class="lb-detail-tabs">
<button type="button" class="lb-detail-tab${detailTab === 'conversation' ? ' active' : ''}" data-of-tab="conversation">Conversa</button>
<button type="button" class="lb-detail-tab${detailTab === 'details' ? ' active' : ''}" data-of-tab="details">Detalhes</button>
<button type="button" class="lb-detail-tab${detailTab === 'history' ? ' active' : ''}" data-of-tab="history">Histórico</button>
</nav>
<div class="lb-detail-body">${tabContent}</div>
<div class="lb-detail-body" style="border-top:1px solid var(--lb-border)">
<div class="lb-actions-grid">
<button type="button" class="lb-action-btn" data-of-assign>Atribuir</button>
<button type="button" class="lb-action-btn" data-of-escalate>Escalar</button>
<button type="button" class="lb-action-btn" data-of-resolve>Marcar resolvido</button>
</div>
<p class="lb-stat-label" style="margin-top:12px">Informações do contacto</p>
<p class="lb-stat-sub">${esc(ev.contact_company || '—')}<br/>CNPJ: ${esc(ev.contact_cnpj || '—')}<br/>ID: ${esc(ev.contact_client_id || '—')}</p>
<p class="lb-stat-label" style="margin-top:12px">SLA: ${ev.sla_minutes} min · Restante: ${fmtSla(ev.sla_remaining_sec)}</p>
<div class="lb-sla-bar"><span style="width:${slaPct}%"></span></div>
</div>
</div>`;
}
let currentEvent = null;
async function renderDetail() {
const detailHost = host?.querySelector('[data-of-detail]');
if (!detailHost || !selectedId) return;
try {
currentEvent = await loadEvent(selectedId);
detailHost.innerHTML = detailHtml(currentEvent);
bindDetailEvents();
} catch (e) {
detailHost.innerHTML = `<p class="loading">${esc(e.message)}</p>`;
}
}
function render() {
if (!host) return;
host.innerHTML = `
<div class="lb-page" data-of-root>
<header style="margin-bottom:16px">
<h2 style="margin:0">Central Operacional</h2>
<p class="lb-stat-sub">Operational feed — mensagens, solicitações e eventos de todos os canais</p>
</header>
${kpisHtml()}
<div class="lb-toolbar">
<input type="search" class="lb-search" data-of-search placeholder="Buscar eventos, pessoas, tickets…" value="${esc(searchQ)}"/>
<select class="lb-select" data-of-priority-sel>
<option value="">Prioridade</option>
<option value="high"${priority === 'high' ? ' selected' : ''}>Alta</option>
<option value="critical"${priority === 'critical' ? ' selected' : ''}>Crítica</option>
</select>
</div>
<div class="lb-ops-layout">
${sidebarHtml()}
<div>
<p class="lb-stat-sub" style="margin-bottom:8px">${events.length} resultados · Mais recentes</p>
<div class="lb-feed-list" data-of-list>${events.map(eventCard).join('')}</div>
</div>
<div data-of-detail>${detailHtml(currentEvent)}</div>
</div>
</div>`;
bindEvents();
if (selectedId) renderDetail();
}
function bindDetailEvents() {
host.querySelectorAll('[data-of-tab]').forEach((btn) => {
btn.addEventListener('click', () => { detailTab = btn.dataset.ofTab; renderDetail(); });
});
host.querySelectorAll('[data-of-reply]').forEach((btn) => {
btn.addEventListener('click', () => { replyMode = btn.dataset.ofReply; renderDetail(); });
});
host.querySelector('[data-of-send]')?.addEventListener('click', async () => {
const body = host.querySelector('[data-of-msg]')?.value?.trim();
if (!body || !selectedId) return;
try {
await fetchWithTimeout(`/api/v1/ops-inbox/events/${encodeURIComponent(selectedId)}/messages`, {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ body, note_type: replyMode }),
});
renderDetail();
} catch (e) {
window.alert(e.message);
}
});
host.querySelector('[data-of-resolve]')?.addEventListener('click', async () => {
await fetchWithTimeout(`/api/v1/ops-inbox/events/${encodeURIComponent(selectedId)}`, {
method: 'PATCH',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ status: 'resolved' }),
});
await refresh();
});
host.querySelector('[data-of-assign]')?.addEventListener('click', () => {
const name = window.prompt('Atribuir a:');
if (!name) return;
fetchWithTimeout(`/api/v1/ops-inbox/events/${encodeURIComponent(selectedId)}`, {
method: 'PATCH',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ assignee: name }),
}).then(refresh).catch((e) => window.alert(e.message));
});
}
function bindEvents() {
host.querySelectorAll('[data-of-channel]').forEach((btn) => {
btn.addEventListener('click', async () => {
channel = btn.dataset.ofChannel;
await refresh();
});
});
host.querySelectorAll('[data-of-priority]').forEach((btn) => {
btn.addEventListener('click', async () => {
priority = btn.dataset.ofPriority;
await refresh();
});
});
host.querySelector('[data-of-priority-sel]')?.addEventListener('change', async (e) => {
priority = e.target.value;
await refresh();
});
host.querySelector('[data-of-search]')?.addEventListener('input', (e) => {
searchQ = e.target.value;
clearTimeout(host._ofTimer);
host._ofTimer = setTimeout(refresh, 300);
});
host.querySelectorAll('[data-of-event]').forEach((card) => {
card.addEventListener('click', () => {
selectedId = card.dataset.ofEvent;
detailTab = 'conversation';
render();
});
});
}
async function refresh() {
try {
await loadEvents();
if (!selectedId && events.length) selectedId = events[0].id;
render();
} catch (e) {
host.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function paint(container) {
host = container;
if (typeof canManageUsers === 'function' && !canManageUsers()) {
host.innerHTML = '<p class="loading">Sem permissão</p>';
return;
}
host.innerHTML = '<p class="loading">Carregando Central Operacional…</p>';
try {
await loadStats();
await loadEvents();
if (!selectedId && events.length) selectedId = events[0].id;
render();
} catch (e) {
host.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
window.DeskOperationalFeed = { paint };
})();