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>
317 lines
13 KiB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
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 };
|
|
})();
|