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>
727 lines
27 KiB
JavaScript
727 lines
27 KiB
JavaScript
/**
|
||
* 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, '&').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}">
|
||
<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 };
|
||
})();
|