ligbox-ops-platform/projects/ops-desk/frontend/assets/executive-map-panel.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

727 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>',
who: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19c0-2.5 2.7-4.5 6-4.5s6 2 6 4.5"/><path d="M17 19c0-1.8 1.5-3.2 3.5-3.5"/></svg>',
why: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="1" fill="currentColor"/></svg>',
};
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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}">
<span class="qfx-split-tag__code">${esc(code)}</span>
<span class="qfx-split-tag__name">${esc(roleLabel(roleId))}</span>
</${tag}>`;
}
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 `<div class="qfx-kpi-grid">${cards.map((c) => `
<article class="qfx-kpi qfx-kpi--${c.mod}">
<div class="qfx-kpi-body">
<h3 class="qfx-kpi-label">${c.label}</h3>
<p class="qfx-kpi-subtitle">${c.sub}</p>
<strong class="qfx-kpi-val">${c.val}</strong>
<span class="qfx-kpi-foot">${c.foot}</span>
</div>
<div class="qfx-kpi-icon-ring" aria-hidden="true">${KPI_ICONS[c.mod]}</div>
</article>`).join('')}</div>`;
}
function renderToolbar(groups) {
const groupOpts = ['<option value="all">Todos os grupos</option>']
.concat(groups.map((g) =>
`<option value="${esc(g.id)}"${filterGroup === g.id ? ' selected' : ''}>${esc(g.label)}</option>`
)).join('');
return `
<div class="qfx-toolbar">
<label class="qfx-field">
<span>Grupo</span>
<select class="qfx-select" data-qfx-group>${groupOpts}</select>
</label>
<label class="qfx-field qfx-field--search">
<span class="sr-only">Buscar</span>
<input type="search" class="qfx-search" data-qfx-search placeholder="Buscar acção, perfil ou nível…" value="${esc(searchQuery)}"/>
</label>
${filterGroup !== 'all' || searchQuery ? '<button type="button" class="qfx-btn-ghost" data-qfx-clear-filters>Limpar filtros</button>' : ''}
</div>`;
}
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) =>
`<option value="${l.id}"${eff === l.id ? ' selected' : ''}>${esc(l.label)}</option>`
).join('');
return `
<tr class="qfx-row${isOpen ? ' qfx-row--open' : ''}${a.overridden_roles?.includes(selectedRole) ? ' qfx-row--custom' : ''}" data-qfx-action="${esc(a.id)}">
<td class="qfx-col-action">
<strong class="qfx-action-title">${esc(a.label)}</strong>
<code class="qfx-action-id">${esc(a.id)}</code>
</td>
<td class="qfx-col-who">${who.map(chipHtml).join('') || '<span class="qfx-muted">—</span>'}</td>
<td class="qfx-col-why">${esc(whyForAction(a))}</td>
<td class="qfx-col-level">
<select class="qfx-level-select ${lm.cls}" data-qfx-level data-action-id="${esc(a.id)}" data-role-id="${esc(selectedRole)}" ${!editable || saving ? 'disabled' : ''}>
${levelOpts}
</select>
</td>
<td class="qfx-col-ops">
<button type="button" class="qfx-icon-btn" data-qfx-open="${esc(a.id)}" title="Ver detalhe">👁</button>
<button type="button" class="qfx-icon-btn" data-qfx-open="${esc(a.id)}" title="Editar">⋯</button>
</td>
</tr>`;
}).join('');
return `
<section class="qfx-table-block">
<header class="qfx-table-head">
<h4>Ações e permissões</h4>
<span class="qfx-table-count">${actions.length} resultado${actions.length === 1 ? '' : 's'}</span>
</header>
<div class="qfx-table-wrap">
<table class="qfx-table">
<thead>
<tr>
<th>AÇÃO</th>
<th>QUEM REALIZA</th>
<th>POR QUÊ</th>
<th>NÍVEL · ${esc(roleCode(selectedRole))}</th>
<th>AÇÕES</th>
</tr>
</thead>
<tbody>${rows || '<tr><td colspan="5" class="qfx-empty">Nenhuma acção encontrada.</td></tr>'}</tbody>
</table>
</div>
</section>`;
}
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 `
<aside class="qfx-side-panel" aria-label="Detalhe da acção">
<article class="qfx-side-card">
<header class="qfx-modal-head">
<div class="qfx-modal-head-text">
<h3>${esc(a.label)}</h3>
<span class="qfx-tag-pill">${esc(a.id)}</span>
</div>
<button type="button" class="qfx-modal-close" data-qfx-close-panel aria-label="Fechar painel">×</button>
</header>
<div class="qfx-modal-body">
<section class="qfx-modal-block">
<h4>Descrição</h4>
<p>${esc(descForAction(a))}</p>
</section>
<section class="qfx-modal-block">
<h4>Finalidade</h4>
<p class="qfx-finalidade-main">${esc(whyForAction(a))}</p>
<p class="qfx-finalidade-sub">${esc(finalidadeSub(a))}</p>
</section>
<section class="qfx-modal-block">
<h4>Quem realiza</h4>
<div class="qfx-split-tag-grid">${roleBadges || '<span class="qfx-muted">Nenhum perfil</span>'}</div>
</section>
<section class="qfx-modal-block">
<h4>Nível padrão · ${esc(roleLabel(selectedRole))}</h4>
<span class="qfx-level-badge ${lm.cls}">${esc(lm.label)}</span>
</section>
<section class="qfx-modal-block">
<h4>Grupos</h4>
<p class="qfx-readonly-field">${esc(groupLabel)}</p>
</section>
</div>
<footer class="qfx-modal-foot">
${editable ? `
<button type="button" class="qfx-btn-edit qfx-btn-edit--full" data-qfx-open-edit ${saving ? 'disabled' : ''}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
Editar permissão
</button>` : '<span class="qfx-muted qfx-readonly-hint">Modo consulta</span>'}
</footer>
</article>
</aside>`;
}
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) =>
`<option value="${l.id}"${effLevel === l.id ? ' selected' : ''}>${esc(l.label)}</option>`
).join('');
const groupOpts = groups.map((g) =>
`<option value="${esc(g.id)}"${a.group === g.id ? ' selected' : ''}>${esc(g.label)}</option>`
).join('');
return `
<div class="qfx-edit-modal-root" role="dialog" aria-modal="true" aria-label="Editar permissão">
<div class="qfx-backdrop" data-qfx-backdrop></div>
<article class="qfx-modal-card qfx-modal-card--edit" data-qfx-edit-card>
<header class="qfx-modal-head">
<div class="qfx-modal-head-text">
<h3>Editar permissão</h3>
<span class="qfx-tag-pill">${esc(a.id)}</span>
<p class="qfx-edit-subtitle">${esc(a.label)}</p>
</div>
<button type="button" class="qfx-modal-close" data-qfx-close-edit aria-label="Fechar">×</button>
</header>
<div class="qfx-modal-body">
<section class="qfx-modal-block">
<h4>Quem realiza</h4>
<p class="qfx-hint">Clique nos perfis para activar (Total) ou desactivar (Negado).</p>
<div class="qfx-split-tag-grid">${roleBadges}</div>
</section>
<section class="qfx-modal-block">
<h4>Nível · ${esc(roleLabel(selectedRole))}</h4>
<div class="qfx-level-field">
<select class="qfx-level-select qfx-level-select--modal ${lm.cls}" data-qfx-edit-level data-role-id="${esc(selectedRole)}" ${!editable ? 'disabled' : ''}>
${levelOpts}
</select>
</div>
</section>
<section class="qfx-modal-block">
<h4>Grupos</h4>
<select class="qfx-select qfx-select--modal" disabled title="Grupo fixo por acção">
${groupOpts}
</select>
</section>
${saveError ? `<p class="qfx-error">${esc(saveError)}</p>` : ''}
</div>
<footer class="qfx-modal-foot">
<button type="button" class="qfx-btn-cancel" data-qfx-close-edit>Cancelar</button>
<button type="button" class="qfx-btn-edit" data-qfx-save ${!editable || saving ? 'disabled' : ''}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 13l4 4L19 7"/></svg>
Guardar alterações
</button>
</footer>
</article>
</div>`;
}
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 ? `<p class="qfx-error">${esc(saveError)}</p>` : ''}
${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 = '<p class="loading">Carregando Quem faz o quê…</p>';
await loadCatalog();
}
} catch (e) {
host.innerHTML = `<p class="loading">Catálogo indisponível: ${esc(e.message)}</p>`;
clearEditPortal();
return;
}
const editable = !!catalogState?.editable;
const groups = catalogState?.groups || [];
syncDraftsFromCatalog();
host.innerHTML = `
<div class="qfx-layout${drawerActionId ? ' qfx-layout--panel' : ''}">
<div class="qfx-main">
<header class="qfx-page-head">
<div>
<h2 class="qfx-title">Mapa executivo — Quem faz o quê</h2>
<p class="qfx-subtitle">Controle visual por acção, perfil e finalidade · Spec 039</p>
</div>
${editable ? '<span class="qfx-badge-edit">Edição activa</span>' : '<span class="qfx-badge-read">Consulta</span>'}
</header>
<div data-qfx-list-area></div>
</div>
<div data-qfx-side-slot></div>
</div>`;
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 };
})();