/** * Spec 039 — Quem faz o quê (UX mockup Roger 2026-06-29) * Cards KPI · tabela · painel lateral · modal de edição */ (function () { 'use strict'; const LEVELS = [ { id: 'full', label: 'Total', cls: 'qfx-level--full' }, { id: 'approve', label: 'Aprovação', cls: 'qfx-level--approve' }, { id: 'read', label: 'Leitura', cls: 'qfx-level--read' }, { id: 'link', label: 'Link', cls: 'qfx-level--link' }, { id: 'api', label: 'API', cls: 'qfx-level--api' }, { id: 'none', label: 'Negado', cls: 'qfx-level--none' }, ]; const CHIP_COLORS = { SU: '#2563eb', CO: '#7c3aed', TEC: '#0284c7', NOC: '#64748b', SAD: '#9333ea', SSU: '#6d28d9', FIN: '#0891b2', MKT: '#ea580c', SEO: '#16a34a', DEV: '#0d9488', DVO: '#1d4ed8', SOC: '#dc2626', CMS: '#b45309', AIO: '#6b21a8', PTR: '#a16207', }; const CHIP_BG = { SU: '#dbeafe', CO: '#ede9fe', TEC: '#e0f2fe', NOC: '#f1f5f9', SAD: '#f3e8ff', SSU: '#ede9fe', FIN: '#cffafe', MKT: '#ffedd5', SEO: '#dcfce7', DEV: '#ccfbf1', DVO: '#dbeafe', SOC: '#fee2e2', CMS: '#fef3c7', AIO: '#f3e8ff', PTR: '#fef3c7', }; const KPI_ICONS = { action: '', who: '', why: '', }; let catalogState = null; let filterGroup = 'all'; let searchQuery = ''; let drawerActionId = null; let drawerDraft = null; let editModalOpen = false; let editDraft = null; let editModalHost = null; let saving = false; let saveError = null; let searchDebounceTimer = null; const EDIT_PORTAL_ID = 'qfx-edit-modal-portal'; const BODY_LOCK_CLASS = 'qfx-scroll-lock'; function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(//g, '>'); } async function loadCatalog() { const r = await fetchWithTimeout('/api/v1/rbac/actions', { headers: authHeaders() }); if (!r.ok) throw new Error(`${r.status}`); catalogState = await r.json(); return catalogState; } async function patchLevel(actionId, roleId, level, reset) { saving = true; saveError = null; const r = await fetchWithTimeout('/api/v1/rbac/actions/override', { method: 'PATCH', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ action_id: actionId, role_id: roleId, level, reset: !!reset }), }); saving = false; if (!r.ok) throw new Error((await r.text()).slice(0, 200) || String(r.status)); return r.json(); } function roleCode(roleId) { return catalogState?.role_codes?.[roleId] || roleId?.slice(0, 3).toUpperCase(); } function roleLabel(roleId) { return catalogState?.roles?.[roleId]?.label || roleId; } function levelMeta(level) { return LEVELS.find((l) => l.id === level) || { id: level, label: level, cls: 'qfx-level--read' }; } function whyForAction(action) { if (action.why) return action.why; const map = catalogState?.executive_map || []; const hit = map.find((r) => r.action_ids?.includes(action.id)); return hit?.why || 'Operação registada no catálogo Spec 039'; } function rolesWithAccess(action) { const roles = catalogState?.roles || {}; return Object.keys(roles).filter((rid) => { const lv = action.effective?.[rid] || 'none'; return lv !== 'none'; }); } function filteredActions() { let list = catalogState?.actions || []; if (filterGroup !== 'all') list = list.filter((a) => a.group === filterGroup); const q = searchQuery.trim().toLowerCase(); if (q) { list = list.filter((a) => a.id.toLowerCase().includes(q) || (a.label || '').toLowerCase().includes(q) || whyForAction(a).toLowerCase().includes(q) ); } return list; } function kpiStats(actions) { const roles = catalogState?.roles || {}; const roleIds = Object.keys(roles); let activeProfiles = 0; roleIds.forEach((rid) => { if (actions.some((a) => (a.effective?.[rid] || 'none') !== 'none')) activeProfiles += 1; }); const purposes = new Set(actions.map((a) => whyForAction(a))); return { actions: actions.length, profiles: activeProfiles || roleIds.length, purposes: purposes.size, }; } function descForAction(action) { return `Permite ${(action.label || '').toLowerCase()} no ecossistema Ligbox OPS.`; } function finalidadeSub(action) { const g = (catalogState?.groups || []).find((x) => x.id === action.group); return g?.label || 'Operação registada no catálogo'; } function splitTagHtml(roleId, on, editable, toggle, compact) { const code = roleCode(roleId); const bg = CHIP_BG[code] || '#f1f5f9'; const fg = CHIP_COLORS[code] || '#64748b'; const cls = `qfx-split-tag${on ? ' on' : ''}${!on ? ' off' : ''}${compact ? ' qfx-split-tag--compact' : ''}`; const attrs = toggle && editable ? ` type="button" data-qfx-toggle-role="${esc(roleId)}"` : ''; const tag = toggle && editable ? 'button' : 'span'; return `<${tag} class="${cls}"${attrs} style="--tag-bg:${bg};--tag-fg:${fg}"> ${esc(code)} ${esc(roleLabel(roleId))} `; } function chipHtml(roleId) { return splitTagHtml(roleId, true, false, false, true); } function renderKpiCards(stats) { const cards = [ { key: 'action', label: 'AÇÃO', sub: 'O que pode ser feito no sistema', val: stats.actions, foot: 'ações cadastradas', mod: 'action' }, { key: 'who', label: 'QUEM REALIZA', sub: 'Perfis/Grupos que executam', val: stats.profiles, foot: 'perfis e grupos', mod: 'who' }, { key: 'why', label: 'POR QUÊ', sub: 'Finalidade da ação', val: stats.purposes, foot: 'finalidades mapeadas', mod: 'why' }, ]; return `
${cards.map((c) => `

${c.label}

${c.sub}

${c.val} ${c.foot}
`).join('')}
`; } function renderToolbar(groups) { const groupOpts = [''] .concat(groups.map((g) => `` )).join(''); return `
${filterGroup !== 'all' || searchQuery ? '' : ''}
`; } function renderActionsTable(actions, selectedRole, editable) { const rows = actions.map((a) => { const eff = a.effective?.[selectedRole] || 'none'; const lm = levelMeta(eff); const who = rolesWithAccess(a); const isOpen = drawerActionId === a.id; const levelOpts = LEVELS.map((l) => `` ).join(''); return ` ${esc(a.label)} ${esc(a.id)} ${who.map(chipHtml).join('') || ''} ${esc(whyForAction(a))} `; }).join(''); return `

Ações e permissões

${actions.length} resultado${actions.length === 1 ? '' : 's'}
${rows || ''}
AÇÃO QUEM REALIZA POR QUÊ NÍVEL · ${esc(roleCode(selectedRole))} AÇÕES
Nenhuma acção encontrada.
`; } function renderSidePanel(selectedRole, editable) { if (!drawerActionId || !drawerDraft) return ''; const a = drawerDraft; const groups = catalogState?.groups || []; const roleIds = Object.keys(catalogState?.roles || {}).filter((r) => !['api_service', 'agent_system'].includes(r)); const effLevel = a.effective?.[selectedRole] || 'none'; const lm = levelMeta(effLevel); const roleBadges = roleIds.map((rid) => { const lv = a.effective?.[rid] || 'none'; return splitTagHtml(rid, lv !== 'none', false, false); }).join(''); const groupLabel = groups.find((g) => g.id === a.group)?.label || a.group || '—'; return ` `; } function renderEditModal(selectedRole, editable) { if (!editModalOpen || !editDraft) return ''; const a = editDraft; const groups = catalogState?.groups || []; const roleIds = Object.keys(catalogState?.roles || {}).filter((r) => !['api_service', 'agent_system'].includes(r)); const effLevel = a.effective?.[selectedRole] || 'none'; const lm = levelMeta(effLevel); const roleBadges = roleIds.map((rid) => { const lv = a.effective?.[rid] || 'none'; return splitTagHtml(rid, lv !== 'none', true, true); }).join(''); const levelOpts = LEVELS.map((l) => `` ).join(''); const groupOpts = groups.map((g) => `` ).join(''); return ` `; } function syncBodyScrollLock() { document.body.classList.toggle(BODY_LOCK_CLASS, !!editModalOpen); } function repaintEditModalOnly(selectedRole, editable, onFullRepaint) { mountEditModal(renderEditModal(selectedRole, editable)); bindEditModalEvents(onFullRepaint, selectedRole, editable); syncBodyScrollLock(); } function updateListArea(host, selectedRole, editable, groups) { const area = host.querySelector('[data-qfx-list-area]'); if (!area) return false; const actions = filteredActions(); const stats = kpiStats(catalogState?.actions || []); area.innerHTML = ` ${renderToolbar(groups)} ${renderKpiCards(stats)} ${saveError && !editModalOpen ? `

${esc(saveError)}

` : ''} ${renderActionsTable(actions, selectedRole, editable)}`; return true; } function updateSideArea(host, selectedRole, editable) { const slot = host.querySelector('[data-qfx-side-slot]'); const layout = host.querySelector('.qfx-layout'); if (!slot || !layout) return; layout.classList.toggle('qfx-layout--panel', !!drawerActionId); slot.innerHTML = renderSidePanel(selectedRole, editable); } function ensureEditPortal() { let portal = document.getElementById(EDIT_PORTAL_ID); if (!portal) { portal = document.createElement('div'); portal.id = EDIT_PORTAL_ID; portal.className = 'qfx-edit-portal'; document.body.appendChild(portal); } return portal; } function clearEditPortal() { const portal = document.getElementById(EDIT_PORTAL_ID); if (portal) portal.innerHTML = ''; editModalHost = null; document.body.classList.remove(BODY_LOCK_CLASS); } function mountEditModal(html) { if (!html) { clearEditPortal(); return null; } const portal = ensureEditPortal(); portal.innerHTML = html; editModalHost = portal; return portal; } function openDrawer(actionId) { const a = (catalogState?.actions || []).find((x) => x.id === actionId); if (!a) return; drawerActionId = actionId; drawerDraft = JSON.parse(JSON.stringify(a)); editModalOpen = false; editDraft = null; clearEditPortal(); saveError = null; } function closeDrawer() { drawerActionId = null; drawerDraft = null; editModalOpen = false; editDraft = null; clearEditPortal(); saveError = null; } function openEditModal() { if (!drawerDraft) return; editDraft = JSON.parse(JSON.stringify(drawerDraft)); if (!editDraft.effective) editDraft.effective = {}; editDraft._dirty = false; editModalOpen = true; saveError = null; } function closeEditModal() { editModalOpen = false; editDraft = null; clearEditPortal(); saveError = null; } async function saveEditModal() { if (!editDraft || !drawerActionId) return; const orig = (catalogState.actions || []).find((a) => a.id === drawerActionId); if (!orig) return; saving = true; saveError = null; try { const roleIds = Object.keys(catalogState?.roles || {}); for (const rid of roleIds) { const newLv = editDraft.effective?.[rid] || 'none'; const oldLv = orig.effective?.[rid] || 'none'; const def = orig.defaults?.[rid] || 'none'; if (newLv !== oldLv) { await patchLevel(drawerActionId, rid, newLv, newLv === def); } } await loadCatalog(); const fresh = (catalogState.actions || []).find((a) => a.id === drawerActionId); if (fresh) drawerDraft = JSON.parse(JSON.stringify(fresh)); closeEditModal(); } catch (err) { saveError = err.message; } finally { saving = false; } } function bindEditModalEvents(onFullRepaint, selectedRole, editable) { const root = editModalHost || document.getElementById(EDIT_PORTAL_ID); if (!root) return; const onEditRefresh = () => repaintEditModalOnly(selectedRole, editable, onFullRepaint); root.querySelector('[data-qfx-backdrop]')?.addEventListener('click', (e) => { if (e.target !== e.currentTarget) return; closeEditModal(); syncBodyScrollLock(); onFullRepaint({ side: true, list: true }); }); root.querySelectorAll('[data-qfx-close-edit]').forEach((el) => { el.addEventListener('click', (e) => { e.stopPropagation(); closeEditModal(); syncBodyScrollLock(); onFullRepaint({ side: true }); }); }); root.querySelector('[data-qfx-edit-card]')?.addEventListener('mousedown', (e) => { e.stopPropagation(); }); root.querySelectorAll('[data-qfx-toggle-role]').forEach((btn) => { btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (!catalogState?.editable || !editDraft) return; if (!editDraft.effective) editDraft.effective = {}; const rid = btn.dataset.qfxToggleRole; const cur = editDraft.effective[rid] || 'none'; editDraft.effective[rid] = cur === 'none' ? 'full' : 'none'; editDraft._dirty = true; onEditRefresh(); }); }); root.querySelector('[data-qfx-edit-level]')?.addEventListener('change', (e) => { e.stopPropagation(); if (!editDraft) return; if (!editDraft.effective) editDraft.effective = {}; const rid = e.target.dataset.roleId; editDraft.effective[rid] = e.target.value; editDraft._dirty = true; onEditRefresh(); }); root.querySelector('[data-qfx-save]')?.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); await saveEditModal(); syncBodyScrollLock(); onFullRepaint({ side: true, list: true }); }); } function bindEvents(host, selectedRole, editable, groups, onRefresh) { host.querySelector('[data-qfx-group]')?.addEventListener('change', (e) => { filterGroup = e.target.value; onRefresh({ list: true }); }); const searchInput = host.querySelector('[data-qfx-search]'); searchInput?.addEventListener('input', (e) => { searchQuery = e.target.value; clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => onRefresh({ list: true }), 280); }); host.querySelector('[data-qfx-clear-filters]')?.addEventListener('click', () => { filterGroup = 'all'; searchQuery = ''; if (searchInput) searchInput.value = ''; onRefresh({ list: true }); }); host.querySelectorAll('[data-qfx-level]').forEach((sel) => { sel.addEventListener('change', async () => { if (!catalogState?.editable) return; const actionId = sel.dataset.actionId; const roleId = sel.dataset.roleId; const level = sel.value; const action = (catalogState.actions || []).find((a) => a.id === actionId); const def = action?.defaults?.[roleId] || 'none'; try { await patchLevel(actionId, roleId, level, level === def); await loadCatalog(); saveError = null; onRefresh({ list: true, side: true }); } catch (err) { saveError = err.message; onRefresh({ list: true }); } }); }); host.querySelectorAll('.qfx-row').forEach((row) => { row.addEventListener('click', (e) => { if (editModalOpen) return; if (e.target.closest('select, button')) return; openDrawer(row.dataset.qfxAction); onRefresh({ list: true, side: true }); }); }); host.querySelectorAll('[data-qfx-open]').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); openDrawer(btn.dataset.qfxOpen); onRefresh({ list: true, side: true }); }); }); host.querySelectorAll('[data-qfx-close-panel]').forEach((el) => { el.addEventListener('click', () => { closeDrawer(); onRefresh({ list: true, side: true }); }); }); host.querySelector('[data-qfx-open-edit]')?.addEventListener('click', (e) => { e.stopPropagation(); openEditModal(); syncBodyScrollLock(); repaintEditModalOnly(selectedRole, editable, () => onRefresh({ side: true, list: true })); }); } function syncDraftsFromCatalog() { if (drawerActionId) { const fresh = (catalogState.actions || []).find((a) => a.id === drawerActionId); if (fresh && drawerDraft && !editModalOpen) { drawerDraft.label = fresh.label; drawerDraft.group = fresh.group; drawerDraft.defaults = fresh.defaults; drawerDraft.effective = { ...fresh.effective }; } } if (editModalOpen && editDraft && drawerActionId) { const fresh = (catalogState.actions || []).find((a) => a.id === drawerActionId); if (fresh && !editDraft._dirty) { editDraft.label = fresh.label; editDraft.group = fresh.group; editDraft.defaults = fresh.defaults; editDraft.effective = { ...fresh.effective }; } } } function refreshView(host, selectedRole, editable, groups, scope = { all: true }) { syncDraftsFromCatalog(); if (scope.all) { updateListArea(host, selectedRole, editable, groups); updateSideArea(host, selectedRole, editable); if (editModalOpen) { repaintEditModalOnly(selectedRole, editable, () => refreshView(host, selectedRole, editable, groups, { all: true })); } else { clearEditPortal(); } } else { if (scope.list) updateListArea(host, selectedRole, editable, groups); if (scope.side) updateSideArea(host, selectedRole, editable); if (scope.edit && editModalOpen) { repaintEditModalOnly(selectedRole, editable, () => refreshView(host, selectedRole, editable, groups, { all: true })); } } bindEvents(host, selectedRole, editable, groups, (partial) => { refreshView(host, selectedRole, editable, groups, partial); }); if (editModalOpen) { bindEditModalEvents( () => refreshView(host, selectedRole, editable, groups, { all: true }), selectedRole, editable ); } } async function paint(host, opts = {}) { if (!host) return; const selectedRole = opts.selectedRole || 'super_admin'; try { if (!catalogState) { host.innerHTML = '

Carregando Quem faz o quê…

'; await loadCatalog(); } } catch (e) { host.innerHTML = `

Catálogo indisponível: ${esc(e.message)}

`; clearEditPortal(); return; } const editable = !!catalogState?.editable; const groups = catalogState?.groups || []; syncDraftsFromCatalog(); host.innerHTML = `

Mapa executivo — Quem faz o quê

Controle visual por acção, perfil e finalidade · Spec 039

${editable ? 'Edição activa' : 'Consulta'}
`; refreshView(host, selectedRole, editable, groups, { all: true }); } function resetFilters() { filterGroup = 'all'; searchQuery = ''; clearTimeout(searchDebounceTimer); drawerActionId = null; drawerDraft = null; editModalOpen = false; editDraft = null; clearEditPortal(); catalogState = null; } window.DeskExecutiveMap = { paint, resetFilters, loadCatalog }; })();