837 lines
32 KiB
JavaScript
837 lines
32 KiB
JavaScript
let PID_MODULES = [];
|
|
let PID_MODULE_APPS = {};
|
|
|
|
async function pidFetchCatalogo() {
|
|
return pidApiRequest("/catalogo/");
|
|
}
|
|
|
|
async function pidFetchPerfis() {
|
|
return pidApiRequest("/perfis/");
|
|
}
|
|
|
|
async function pidCreatePerfil(payload) {
|
|
return pidApiRequest("/perfis/", { method: "POST", body: payload });
|
|
}
|
|
|
|
async function pidUpdatePerfil(codigo, payload) {
|
|
return pidApiRequest(`/perfis/${codigo}/`, { method: "PUT", body: payload });
|
|
}
|
|
|
|
async function pidUpdatePerfilPermissoes(codigo, permissoes) {
|
|
return pidApiRequest(`/perfis/${codigo}/`, { method: "PATCH", body: { permissoes } });
|
|
}
|
|
|
|
async function pidDeletePerfil(codigo) {
|
|
return pidApiRequest(`/perfis/${codigo}/`, { method: "DELETE" });
|
|
}
|
|
|
|
/** Usada também por users-admin.js (lista de usuários + perfis vinculados). */
|
|
async function pidFetchUsuarios() {
|
|
return pidApiRequest("/usuarios/");
|
|
}
|
|
|
|
/** Usadas também por users-admin.js (checklist de departamentos + cadastro inline de um novo). */
|
|
async function pidFetchDepartamentos() {
|
|
return pidApiRequest("/departamentos/");
|
|
}
|
|
|
|
async function pidCreateDepartamento(payload) {
|
|
return pidApiRequest("/departamentos/", { method: "POST", body: payload });
|
|
}
|
|
|
|
async function pidUpdateDepartamento(id, payload) {
|
|
return pidApiRequest(`/departamentos/${id}/`, { method: "PATCH", body: payload });
|
|
}
|
|
|
|
async function pidDeleteDepartamento(id) {
|
|
return pidApiRequest(`/departamentos/${id}/`, { method: "DELETE" });
|
|
}
|
|
|
|
async function pidSetUsuarioPerfis(id, perfisCodigos) {
|
|
return pidApiRequest(`/usuarios/${id}/`, { method: "PATCH", body: { perfis: perfisCodigos } });
|
|
}
|
|
|
|
function pidPerfilFromApi(p) {
|
|
return {
|
|
codigo: p.codigo,
|
|
nome: p.nome,
|
|
ativo: p.ativo,
|
|
gerenciaPermissoes: p.gerencia_permissoes,
|
|
permissions: pidCompletaPermissoes(p.permissoes),
|
|
};
|
|
}
|
|
|
|
/** Completa o JSON salvo de um perfil com todo módulo/app do catálogo que
|
|
* ainda não existe nele, desmarcado. Um módulo novo no catálogo (ex.:
|
|
* "Diretoria") não está no JSON dos perfis já gravados, e como o seed não
|
|
* roda em produção ninguém o acrescenta — sem isto, abrir um perfil quebra a
|
|
* tela ao ler `permissions[modulo].enabled`. Desmarcado é o mesmo que o
|
|
* servidor já assume para chave ausente (`permissoes_efetivas()`), então
|
|
* nada muda de acesso; o perfil só passa a gravar a chave quando for salvo. */
|
|
function pidCompletaPermissoes(permissoes) {
|
|
const completo = JSON.parse(JSON.stringify(permissoes || {}));
|
|
PID_MODULES.forEach((m) => {
|
|
const atual = completo[m.key] || {};
|
|
const apps = Object.assign(pidAppsFor(m.key, false), atual.apps || {});
|
|
completo[m.key] = Object.assign({}, atual, { enabled: !!atual.enabled, apps });
|
|
});
|
|
return completo;
|
|
}
|
|
|
|
function pidPerfilToApi(p) {
|
|
return {
|
|
nome: p.nome,
|
|
ativo: p.ativo,
|
|
gerencia_permissoes: p.gerenciaPermissoes,
|
|
permissoes: p.permissions,
|
|
};
|
|
}
|
|
|
|
function pidAppsFor(moduleKey, value) {
|
|
const apps = {};
|
|
(PID_MODULE_APPS[moduleKey] || []).forEach((entry) => {
|
|
if (entry.tools) {
|
|
entry.tools.forEach((tool) => {
|
|
apps[tool.key] = value;
|
|
});
|
|
} else {
|
|
apps[entry.key] = value;
|
|
}
|
|
});
|
|
return apps;
|
|
}
|
|
|
|
function pidEmptyPermissions(allEnabled) {
|
|
const perms = {};
|
|
PID_MODULES.forEach((m) => {
|
|
perms[m.key] = { enabled: !!allEnabled, apps: pidAppsFor(m.key, !!allEnabled) };
|
|
});
|
|
return perms;
|
|
}
|
|
|
|
/** Achata PID_MODULES x PID_MODULE_APPS numa lista plana de "aplicações"
|
|
* pesquisáveis (uma por entrada de MODULE_APPS — app simples ou subgrupo),
|
|
* usada pelo popup "Nova Aplicação". Cada item mantém `tools` (ou `null`
|
|
* pra app simples) pra o painel de gerenciamento saber quantas colunas de
|
|
* acesso desenhar por perfil. */
|
|
function pidFlattenAplicacoes() {
|
|
const result = [];
|
|
PID_MODULES.forEach((modulo) => {
|
|
(PID_MODULE_APPS[modulo.key] || []).forEach((entry) => {
|
|
result.push({
|
|
moduleKey: modulo.key,
|
|
moduleLabel: modulo.label,
|
|
key: entry.key,
|
|
label: entry.label,
|
|
tools: entry.tools || null,
|
|
});
|
|
});
|
|
});
|
|
return result;
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
const listView = document.getElementById("pa-list-view");
|
|
const editView = document.getElementById("pa-edit-view");
|
|
if (!listView || !editView) return;
|
|
|
|
const me = await pidGetMe();
|
|
if (!me) return;
|
|
if (!me.gerencia_permissoes) {
|
|
window.location.href = "portal.html";
|
|
return;
|
|
}
|
|
|
|
const msgErroCarga = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
|
|
|
|
// Sem catálogo/perfis a tela não tem o que mostrar: avisa na própria
|
|
// tabela em vez de deixá-la vazia como se não houvesse perfil nenhum.
|
|
let catalogo;
|
|
let perfisApi;
|
|
try {
|
|
[catalogo, perfisApi] = await Promise.all([pidFetchCatalogo(), pidFetchPerfis()]);
|
|
} catch (e) {
|
|
const body = document.getElementById("pa-table-body");
|
|
if (body) body.innerHTML = `<tr class="pa-empty-row"><td colspan="3">${msgErroCarga}</td></tr>`;
|
|
return;
|
|
}
|
|
PID_MODULES = catalogo.modulos;
|
|
PID_MODULE_APPS = catalogo.aplicacoes_por_modulo;
|
|
|
|
let profiles = perfisApi.map(pidPerfilFromApi);
|
|
// Retrato do nome + permissões na abertura da edição, pra só perguntar
|
|
// "Sair sem salvar" quando algo mudou de fato.
|
|
let retratoEdicao = null;
|
|
let draftProfile = null;
|
|
let activeModuleKey = PID_MODULES[0].key;
|
|
let activeTab = "permissoes";
|
|
const expandedModules = new Set();
|
|
const expandedSubgroups = new Set();
|
|
let appSearchSelected = null;
|
|
|
|
const tableBody = document.getElementById("pa-table-body");
|
|
const footerCount = document.getElementById("pa-footer-count");
|
|
const listSearchInput = document.getElementById("pa-search-input");
|
|
const newBtn = document.getElementById("pa-new-btn");
|
|
const backBtn = document.getElementById("pa-back-btn");
|
|
const cancelBtn = document.getElementById("pa-cancel-btn");
|
|
const saveBtn = document.getElementById("pa-save-btn");
|
|
const formCodigo = document.getElementById("pa-codigo");
|
|
const formNome = document.getElementById("pa-nome");
|
|
const formError = document.getElementById("pa-nome-error");
|
|
const tabUsuarios = document.getElementById("pa-tab-usuarios");
|
|
const tabUsuariosHint = document.getElementById("pa-tab-usuarios-hint");
|
|
const moduleListEl = document.getElementById("pa-module-list");
|
|
const treePanel = document.getElementById("pa-tree-panel");
|
|
const treeSearchInput = document.getElementById("pa-tree-search-input");
|
|
const usersPanelsEl = {
|
|
available: {
|
|
refreshBtn: document.getElementById("pa-users-available-refresh-btn"),
|
|
linkBtn: document.getElementById("pa-users-link-btn"),
|
|
selectAll: document.getElementById("pa-users-available-select-all"),
|
|
sortHeader: document.getElementById("pa-users-available-sort-nome"),
|
|
searchNome: document.getElementById("pa-users-available-search-nome"),
|
|
searchEmail: document.getElementById("pa-users-available-search-email"),
|
|
body: document.getElementById("pa-users-available-body"),
|
|
footer: document.getElementById("pa-users-available-footer"),
|
|
},
|
|
linked: {
|
|
refreshBtn: document.getElementById("pa-users-linked-refresh-btn"),
|
|
linkBtn: document.getElementById("pa-users-unlink-btn"),
|
|
selectAll: document.getElementById("pa-users-linked-select-all"),
|
|
sortHeader: document.getElementById("pa-users-linked-sort-nome"),
|
|
searchNome: document.getElementById("pa-users-linked-search-nome"),
|
|
searchEmail: document.getElementById("pa-users-linked-search-email"),
|
|
body: document.getElementById("pa-users-linked-body"),
|
|
footer: document.getElementById("pa-users-linked-footer"),
|
|
},
|
|
};
|
|
const usersDualState = {
|
|
available: { sortDir: 1, selected: new Set() },
|
|
linked: { sortDir: 1, selected: new Set() },
|
|
};
|
|
let usuariosCacheAtual = [];
|
|
const appSearchBtn = document.getElementById("pa-app-search-btn");
|
|
const appSearchModal = document.getElementById("pa-app-search-modal");
|
|
const appSearchCloseBtn = document.getElementById("pa-app-search-close-btn");
|
|
const appSearchField = document.getElementById("pa-app-search-field");
|
|
const appSearchInput = document.getElementById("pa-app-search-input");
|
|
const appSearchResults = document.getElementById("pa-app-search-results");
|
|
const appManagePanel = document.getElementById("pa-app-manage-panel");
|
|
const appManageBackBtn = document.getElementById("pa-app-manage-back-btn");
|
|
const appManageTitle = document.getElementById("pa-app-manage-title");
|
|
const appManageSearchInput = document.getElementById("pa-app-manage-search");
|
|
const appManageThead = document.getElementById("pa-app-manage-thead");
|
|
const appManageTbody = document.getElementById("pa-app-manage-tbody");
|
|
|
|
function renderList() {
|
|
const term = listSearchInput.value.trim().toLowerCase();
|
|
const rows = profiles
|
|
.filter((p) => !term || p.nome.toLowerCase().includes(term))
|
|
.slice()
|
|
.sort((a, b) => a.codigo - b.codigo);
|
|
|
|
tableBody.innerHTML = rows.length
|
|
? rows
|
|
.map(
|
|
(p) => `
|
|
<tr>
|
|
<td class="pa-num">${p.codigo}</td>
|
|
<td>
|
|
<a href="#" class="pa-name-link" data-pa-edit="${p.codigo}">${pidEscapeHtml(p.nome)}</a>
|
|
${p.gerenciaPermissoes ? '<span class="badge-total-access">Acesso total</span>' : ""}
|
|
</td>
|
|
<td>
|
|
<div class="pa-row-actions">
|
|
<button type="button" class="icon-btn" data-pa-edit="${p.codigo}" aria-label="Editar perfil">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9" stroke-linecap="round"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" stroke-linejoin="round"/></svg>
|
|
</button>
|
|
<button type="button" class="icon-btn" data-pa-delete="${p.codigo}" aria-label="Excluir perfil">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m-8 0 1 12a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1l1-12" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>`
|
|
)
|
|
.join("")
|
|
: `<tr class="pa-empty-row"><td colspan="3">Nenhum perfil encontrado.</td></tr>`;
|
|
|
|
footerCount.textContent = `${rows.length} ${rows.length === 1 ? "item" : "itens"}`;
|
|
}
|
|
|
|
function renderModuleList() {
|
|
moduleListEl.innerHTML = PID_MODULES.map((m, idx) => {
|
|
const enabled = draftProfile.permissions[m.key].enabled;
|
|
return `
|
|
<button type="button" class="pa-module-item ${m.key === activeModuleKey ? "is-active" : ""}" data-pa-module="${pidEscapeHtml(m.key)}">
|
|
<span class="pa-module-item__index">${idx + 1}</span>
|
|
<span class="pa-module-item__label">${pidEscapeHtml(m.label)}</span>
|
|
<svg class="pa-module-item__check" ${enabled ? "" : "hidden"} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
</button>`;
|
|
}).join("");
|
|
}
|
|
|
|
function renderLeaf(perm, tool, term) {
|
|
const match = !term || tool.label.toLowerCase().includes(term);
|
|
return `
|
|
<label class="perm-node perm-node--leaf ${match ? "" : "is-hidden"}">
|
|
<input type="checkbox" data-perm-app="${pidEscapeHtml(tool.key)}" ${perm.apps[tool.key] ? "checked" : ""} />
|
|
<span>${pidEscapeHtml(tool.label)}</span>
|
|
</label>`;
|
|
}
|
|
|
|
function renderEntry(perm, entry, term) {
|
|
if (!entry.tools) {
|
|
return renderLeaf(perm, entry, term);
|
|
}
|
|
|
|
const subgroupKey = `${activeModuleKey}__${entry.key}`;
|
|
const subgroupExpanded = expandedSubgroups.has(subgroupKey);
|
|
const groupChecked = entry.tools.every((tool) => perm.apps[tool.key]);
|
|
const anyMatch =
|
|
!term || entry.label.toLowerCase().includes(term) || entry.tools.some((tool) => tool.label.toLowerCase().includes(term));
|
|
|
|
return `
|
|
<div class="perm-node perm-node--group ${anyMatch ? "" : "is-hidden"}">
|
|
<button type="button" class="perm-expand ${subgroupExpanded ? "is-open" : ""}" data-perm-subgroup-expand="${pidEscapeHtml(subgroupKey)}" aria-label="Expandir">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
</button>
|
|
<label>
|
|
<input type="checkbox" data-perm-group="${pidEscapeHtml(entry.key)}" ${groupChecked ? "checked" : ""} />
|
|
<span>${pidEscapeHtml(entry.label)}</span>
|
|
</label>
|
|
</div>
|
|
<div class="perm-children perm-children--nested" data-perm-children ${subgroupExpanded ? "" : "hidden"}>
|
|
${entry.tools.map((tool) => renderLeaf(perm, tool, term)).join("")}
|
|
</div>`;
|
|
}
|
|
|
|
function renderTree() {
|
|
const perm = draftProfile.permissions[activeModuleKey];
|
|
const moduleLabel = PID_MODULES.find((m) => m.key === activeModuleKey).label;
|
|
const term = treeSearchInput.value.trim().toLowerCase();
|
|
const expanded = expandedModules.has(activeModuleKey);
|
|
const entries = PID_MODULE_APPS[activeModuleKey] || [];
|
|
const hasEntries = entries.length > 0;
|
|
|
|
treePanel.innerHTML = `
|
|
<label class="perm-node perm-node--root">
|
|
<input type="checkbox" data-perm-role="root" ${perm.enabled ? "checked" : ""} />
|
|
<span>Todos</span>
|
|
</label>
|
|
<div class="perm-node perm-node--module">
|
|
${
|
|
hasEntries
|
|
? `<button type="button" class="perm-expand ${expanded ? "is-open" : ""}" data-perm-expand aria-label="Expandir">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
</button>`
|
|
: ""
|
|
}
|
|
<label>
|
|
<input type="checkbox" data-perm-role="module" ${perm.enabled ? "checked" : ""} />
|
|
<span>${pidEscapeHtml(moduleLabel)}</span>
|
|
</label>
|
|
</div>
|
|
${
|
|
hasEntries
|
|
? `<div class="perm-children" data-perm-children ${expanded ? "" : "hidden"}>
|
|
${entries.map((entry) => renderEntry(perm, entry, term)).join("")}
|
|
</div>`
|
|
: ""
|
|
}
|
|
`;
|
|
}
|
|
|
|
function usersDualFiltrados(kind, lista) {
|
|
const el = usersPanelsEl[kind];
|
|
const state = usersDualState[kind];
|
|
const termNome = el.searchNome.value.trim().toLowerCase();
|
|
const termEmail = el.searchEmail.value.trim().toLowerCase();
|
|
return lista
|
|
.filter((u) => !termNome || u.nome.toLowerCase().includes(termNome))
|
|
.filter((u) => !termEmail || (u.email || "").toLowerCase().includes(termEmail))
|
|
.slice()
|
|
.sort((a, b) => a.nome.localeCompare(b.nome, "pt-BR", { sensitivity: "base" }) * state.sortDir);
|
|
}
|
|
|
|
function renderUsersPanel(kind, listaCompleta) {
|
|
const el = usersPanelsEl[kind];
|
|
const state = usersDualState[kind];
|
|
|
|
// Poda a seleção de ids que saíram da lista (ex.: acabou de ser
|
|
// vinculado/desvinculado e migrou pro outro painel).
|
|
const idsAtuais = new Set(listaCompleta.map((u) => u.id));
|
|
Array.from(state.selected).forEach((id) => {
|
|
if (!idsAtuais.has(id)) state.selected.delete(id);
|
|
});
|
|
|
|
const filtrados = usersDualFiltrados(kind, listaCompleta);
|
|
|
|
el.body.innerHTML = filtrados.length
|
|
? filtrados
|
|
.map(
|
|
(u) => `
|
|
<tr>
|
|
<td><input type="checkbox" data-pa-users-select="${u.id}" ${state.selected.has(u.id) ? "checked" : ""} /></td>
|
|
<td>${pidEscapeHtml(u.nome)}</td>
|
|
<td>${pidEscapeHtml(u.email || "—")}</td>
|
|
</tr>`
|
|
)
|
|
.join("")
|
|
: `<tr class="pa-empty-row"><td colspan="3">Nenhum usuário encontrado.</td></tr>`;
|
|
|
|
const selecionadosVisiveis = filtrados.filter((u) => state.selected.has(u.id)).length;
|
|
el.selectAll.disabled = filtrados.length === 0;
|
|
el.selectAll.checked = filtrados.length > 0 && selecionadosVisiveis === filtrados.length;
|
|
el.selectAll.indeterminate = selecionadosVisiveis > 0 && selecionadosVisiveis < filtrados.length;
|
|
|
|
el.sortHeader.classList.toggle("is-sort-asc", state.sortDir === 1);
|
|
el.sortHeader.classList.toggle("is-sort-desc", state.sortDir === -1);
|
|
|
|
el.footer.textContent = `${filtrados.length} ${filtrados.length === 1 ? "usuário" : "usuários"}`;
|
|
el.linkBtn.disabled = state.selected.size === 0;
|
|
}
|
|
|
|
function listaParaPainelUsuarios(kind) {
|
|
// Usuário inativo não aparece em nenhum dos dois painéis — decisão
|
|
// explícita do usuário; inativar já limpa os perfis dele no backend
|
|
// (ver _revogar_acesso_se_inativo em serializers.py), então isso é
|
|
// sobretudo defensivo pra dados legados/transição.
|
|
const ativos = usuariosCacheAtual.filter((u) => u.is_active);
|
|
const isLinked = (u) => u.perfis.some((p) => p.codigo === draftProfile.codigo);
|
|
return kind === "linked" ? ativos.filter(isLinked) : ativos.filter((u) => !isLinked(u));
|
|
}
|
|
|
|
function renderUsersFromCache() {
|
|
renderUsersPanel("available", listaParaPainelUsuarios("available"));
|
|
renderUsersPanel("linked", listaParaPainelUsuarios("linked"));
|
|
}
|
|
|
|
async function refreshUsuarios() {
|
|
try {
|
|
usuariosCacheAtual = await pidFetchUsuarios();
|
|
} catch (e) {
|
|
// Erro de carga não pode parecer "nenhum usuário": avisa nos dois painéis.
|
|
["available", "linked"].forEach((kind) => {
|
|
usersPanelsEl[kind].body.innerHTML = `<tr class="pa-empty-row"><td colspan="3">${msgErroCarga}</td></tr>`;
|
|
usersPanelsEl[kind].footer.textContent = "";
|
|
usersPanelsEl[kind].linkBtn.disabled = true;
|
|
});
|
|
return;
|
|
}
|
|
renderUsersFromCache();
|
|
}
|
|
|
|
async function renderUsers() {
|
|
["available", "linked"].forEach((kind) => {
|
|
usersDualState[kind].selected.clear();
|
|
usersDualState[kind].sortDir = 1;
|
|
usersPanelsEl[kind].searchNome.value = "";
|
|
usersPanelsEl[kind].searchEmail.value = "";
|
|
});
|
|
await refreshUsuarios();
|
|
}
|
|
|
|
function renderTabs() {
|
|
document.querySelectorAll("[data-pa-tab]").forEach((tab) => {
|
|
tab.classList.toggle("is-active", tab.getAttribute("data-pa-tab") === activeTab);
|
|
});
|
|
document.querySelectorAll("[data-pa-tab-panel]").forEach((panel) => {
|
|
panel.hidden = panel.getAttribute("data-pa-tab-panel") !== activeTab;
|
|
});
|
|
}
|
|
|
|
function renderAppSearchResults() {
|
|
const term = appSearchInput.value.trim().toLowerCase();
|
|
const items = pidFlattenAplicacoes().filter((a) => {
|
|
if (!term) return true;
|
|
if (a.label.toLowerCase().includes(term)) return true;
|
|
if (a.moduleLabel.toLowerCase().includes(term)) return true;
|
|
return !!(a.tools && a.tools.some((tool) => tool.label.toLowerCase().includes(term)));
|
|
});
|
|
|
|
appSearchResults.innerHTML = items.length
|
|
? items
|
|
.map(
|
|
(a) => `
|
|
<button type="button" class="pa-app-result-item" data-pa-app-module="${pidEscapeHtml(a.moduleKey)}" data-pa-app-key="${pidEscapeHtml(a.key)}">
|
|
<span class="pa-app-result-item__label">${pidEscapeHtml(a.label)}</span>
|
|
<span class="pa-app-result-item__module">${pidEscapeHtml(a.moduleLabel)}</span>
|
|
</button>`
|
|
)
|
|
.join("")
|
|
: `<p class="checklist-empty">Nenhuma aplicação encontrada.</p>`;
|
|
}
|
|
|
|
function renderAppManageTable() {
|
|
const entry = appSearchSelected;
|
|
if (!entry) return;
|
|
const term = appManageSearchInput.value.trim().toLowerCase();
|
|
const tools = entry.tools || [{ key: entry.key, label: "Acesso" }];
|
|
|
|
appManageThead.innerHTML = `
|
|
<tr>
|
|
<th>Perfil</th>
|
|
${tools.map((tool) => `<th>${pidEscapeHtml(tool.label.split(" (")[0])}</th>`).join("")}
|
|
</tr>`;
|
|
|
|
const rows = profiles
|
|
.filter((p) => !term || p.nome.toLowerCase().includes(term))
|
|
.slice()
|
|
.sort((a, b) => a.nome.localeCompare(b.nome, "pt-BR"));
|
|
|
|
appManageTbody.innerHTML = rows.length
|
|
? rows
|
|
.map((p) => {
|
|
const perm = p.permissions[entry.moduleKey];
|
|
return `
|
|
<tr>
|
|
<td>${pidEscapeHtml(p.nome)} ${p.ativo ? "" : '<span class="status-pill status-pill--inativo">Inativo</span>'}</td>
|
|
${tools
|
|
.map(
|
|
(tool) => `
|
|
<td><input type="checkbox" data-pa-app-perfil="${p.codigo}" data-pa-app-key="${pidEscapeHtml(tool.key)}" ${
|
|
perm.apps[tool.key] ? "checked" : ""
|
|
} /></td>`
|
|
)
|
|
.join("")}
|
|
</tr>`;
|
|
})
|
|
.join("")
|
|
: `<tr class="pa-empty-row"><td colspan="${1 + tools.length}">Nenhum perfil encontrado.</td></tr>`;
|
|
}
|
|
|
|
function openAppManage(entry) {
|
|
appSearchSelected = entry;
|
|
appManageTitle.textContent = `${entry.label} — ${entry.moduleLabel}`;
|
|
appManageSearchInput.value = "";
|
|
appSearchField.hidden = true;
|
|
appManagePanel.hidden = false;
|
|
renderAppManageTable();
|
|
}
|
|
|
|
function closeAppManage() {
|
|
appSearchSelected = null;
|
|
appManagePanel.hidden = true;
|
|
appSearchField.hidden = false;
|
|
}
|
|
|
|
function openAppSearchModal() {
|
|
closeAppManage();
|
|
appSearchInput.value = "";
|
|
renderAppSearchResults();
|
|
appSearchModal.hidden = false;
|
|
}
|
|
|
|
function closeAppSearchModal() {
|
|
appSearchModal.hidden = true;
|
|
}
|
|
|
|
async function openEdit(codigo) {
|
|
if (codigo === null) {
|
|
draftProfile = {
|
|
codigo: null,
|
|
nome: "",
|
|
ativo: true,
|
|
gerenciaPermissoes: false,
|
|
permissions: pidEmptyPermissions(false),
|
|
};
|
|
} else {
|
|
const found = profiles.find((p) => p.codigo === codigo);
|
|
if (!found) return;
|
|
draftProfile = JSON.parse(JSON.stringify(found));
|
|
}
|
|
|
|
activeModuleKey = PID_MODULES[0].key;
|
|
activeTab = "permissoes";
|
|
expandedModules.clear();
|
|
expandedSubgroups.clear();
|
|
formError.textContent = "";
|
|
|
|
formCodigo.value = draftProfile.codigo === null ? "Gerado ao salvar" : draftProfile.codigo;
|
|
formNome.value = draftProfile.nome;
|
|
|
|
// Perfil novo ainda não tem código: não há o que vincular até salvar.
|
|
const perfilNovo = draftProfile.codigo === null;
|
|
tabUsuarios.disabled = perfilNovo;
|
|
tabUsuariosHint.hidden = !perfilNovo;
|
|
|
|
renderTabs();
|
|
renderModuleList();
|
|
renderTree();
|
|
|
|
retratoEdicao = retratoAtual();
|
|
|
|
listView.hidden = true;
|
|
editView.hidden = false;
|
|
|
|
if (!perfilNovo) await renderUsers();
|
|
}
|
|
|
|
function retratoAtual() {
|
|
return JSON.stringify({ nome: formNome.value.trim(), permissions: draftProfile.permissions });
|
|
}
|
|
|
|
// Os vínculos de usuário são gravados na hora (não fazem parte do
|
|
// rascunho), por isso só nome e permissões entram na comparação.
|
|
async function podeSairDaEdicao() {
|
|
if (!draftProfile || retratoAtual() === retratoEdicao) return true;
|
|
return pidConfirm("Sair sem salvar as alterações?", {
|
|
titulo: "Sair sem salvar",
|
|
textoConfirmar: "Sair sem salvar",
|
|
perigoso: true,
|
|
});
|
|
}
|
|
|
|
function closeEdit() {
|
|
editView.hidden = true;
|
|
listView.hidden = false;
|
|
retratoEdicao = null;
|
|
renderList();
|
|
}
|
|
|
|
async function tentarFecharEdicao() {
|
|
if (await podeSairDaEdicao()) closeEdit();
|
|
}
|
|
|
|
listSearchInput.addEventListener("input", renderList);
|
|
|
|
newBtn.addEventListener("click", () => openEdit(null));
|
|
backBtn.addEventListener("click", tentarFecharEdicao);
|
|
cancelBtn.addEventListener("click", tentarFecharEdicao);
|
|
|
|
appSearchBtn.addEventListener("click", openAppSearchModal);
|
|
appSearchCloseBtn.addEventListener("click", closeAppSearchModal);
|
|
appSearchModal.addEventListener("click", (event) => {
|
|
if (event.target === appSearchModal) closeAppSearchModal();
|
|
});
|
|
appSearchInput.addEventListener("input", renderAppSearchResults);
|
|
appManageBackBtn.addEventListener("click", closeAppManage);
|
|
appManageSearchInput.addEventListener("input", renderAppManageTable);
|
|
|
|
appSearchResults.addEventListener("click", (event) => {
|
|
const btn = event.target.closest("[data-pa-app-key]");
|
|
if (!btn) return;
|
|
const moduleKey = btn.getAttribute("data-pa-app-module");
|
|
const key = btn.getAttribute("data-pa-app-key");
|
|
const entry = pidFlattenAplicacoes().find((a) => a.moduleKey === moduleKey && a.key === key);
|
|
if (entry) openAppManage(entry);
|
|
});
|
|
|
|
appManageTbody.addEventListener("change", async (event) => {
|
|
const checkbox = event.target.closest("[data-pa-app-key]");
|
|
if (!checkbox || !appSearchSelected) return;
|
|
const codigo = Number(checkbox.getAttribute("data-pa-app-perfil"));
|
|
const key = checkbox.getAttribute("data-pa-app-key");
|
|
const profile = profiles.find((p) => p.codigo === codigo);
|
|
if (!profile) return;
|
|
|
|
const perm = profile.permissions[appSearchSelected.moduleKey];
|
|
const checked = checkbox.checked;
|
|
const wasEnabled = perm.enabled;
|
|
perm.apps[key] = checked;
|
|
if (checked && !perm.enabled) perm.enabled = true;
|
|
|
|
checkbox.disabled = true;
|
|
try {
|
|
await pidUpdatePerfilPermissoes(profile.codigo, profile.permissions);
|
|
} catch (e) {
|
|
perm.apps[key] = !checked;
|
|
perm.enabled = wasEnabled;
|
|
checkbox.checked = !checked;
|
|
await pidAlert(`Não foi possível salvar: ${e.message}`);
|
|
} finally {
|
|
checkbox.disabled = false;
|
|
}
|
|
});
|
|
|
|
tableBody.addEventListener("click", async (event) => {
|
|
const editTarget = event.target.closest("[data-pa-edit]");
|
|
if (editTarget) {
|
|
event.preventDefault();
|
|
await openEdit(Number(editTarget.getAttribute("data-pa-edit")));
|
|
return;
|
|
}
|
|
const deleteTarget = event.target.closest("[data-pa-delete]");
|
|
if (deleteTarget) {
|
|
const codigo = Number(deleteTarget.getAttribute("data-pa-delete"));
|
|
const profile = profiles.find((p) => p.codigo === codigo);
|
|
if (profile && (await pidConfirm(`Excluir o perfil "${profile.nome}"?`, { perigoso: true }))) {
|
|
deleteTarget.disabled = true;
|
|
try {
|
|
await pidDeletePerfil(codigo);
|
|
profiles = (await pidFetchPerfis()).map(pidPerfilFromApi);
|
|
renderList();
|
|
} catch (e) {
|
|
deleteTarget.disabled = false;
|
|
await pidAlert(e.message, { titulo: "Não foi possível excluir o perfil" });
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll("[data-pa-tab]").forEach((tab) => {
|
|
tab.addEventListener("click", () => {
|
|
activeTab = tab.getAttribute("data-pa-tab");
|
|
renderTabs();
|
|
});
|
|
});
|
|
|
|
moduleListEl.addEventListener("click", (event) => {
|
|
const btn = event.target.closest("[data-pa-module]");
|
|
if (!btn) return;
|
|
activeModuleKey = btn.getAttribute("data-pa-module");
|
|
renderModuleList();
|
|
renderTree();
|
|
});
|
|
|
|
treeSearchInput.addEventListener("input", renderTree);
|
|
|
|
treePanel.addEventListener("click", (event) => {
|
|
const subgroupExpandBtn = event.target.closest("[data-perm-subgroup-expand]");
|
|
if (subgroupExpandBtn) {
|
|
const key = subgroupExpandBtn.getAttribute("data-perm-subgroup-expand");
|
|
if (expandedSubgroups.has(key)) {
|
|
expandedSubgroups.delete(key);
|
|
} else {
|
|
expandedSubgroups.add(key);
|
|
}
|
|
renderTree();
|
|
return;
|
|
}
|
|
const expandBtn = event.target.closest("[data-perm-expand]");
|
|
if (!expandBtn) return;
|
|
if (expandedModules.has(activeModuleKey)) {
|
|
expandedModules.delete(activeModuleKey);
|
|
} else {
|
|
expandedModules.add(activeModuleKey);
|
|
}
|
|
renderTree();
|
|
});
|
|
|
|
treePanel.addEventListener("change", (event) => {
|
|
const perm = draftProfile.permissions[activeModuleKey];
|
|
const roleInput = event.target.closest("[data-perm-role]");
|
|
if (roleInput) {
|
|
const checked = roleInput.checked;
|
|
perm.enabled = checked;
|
|
Object.keys(perm.apps).forEach((key) => {
|
|
perm.apps[key] = checked;
|
|
});
|
|
renderModuleList();
|
|
renderTree();
|
|
return;
|
|
}
|
|
const groupInput = event.target.closest("[data-perm-group]");
|
|
if (groupInput) {
|
|
const entryKey = groupInput.getAttribute("data-perm-group");
|
|
const entries = PID_MODULE_APPS[activeModuleKey] || [];
|
|
const entry = entries.find((e) => e.key === entryKey);
|
|
if (entry && entry.tools) {
|
|
entry.tools.forEach((tool) => {
|
|
perm.apps[tool.key] = groupInput.checked;
|
|
});
|
|
}
|
|
renderTree();
|
|
return;
|
|
}
|
|
const appInput = event.target.closest("[data-perm-app]");
|
|
if (appInput) {
|
|
perm.apps[appInput.getAttribute("data-perm-app")] = appInput.checked;
|
|
}
|
|
});
|
|
|
|
// Vincula (available → linked) ou desvincula (linked → available) todos
|
|
// os usuários selecionados no painel de uma vez, em paralelo.
|
|
async function bulkAlterarVinculo(kind, vincular) {
|
|
const state = usersDualState[kind];
|
|
const ids = Array.from(state.selected);
|
|
if (!ids.length || draftProfile.codigo === null) return;
|
|
const el = usersPanelsEl[kind];
|
|
el.linkBtn.disabled = true;
|
|
let erro = null;
|
|
try {
|
|
await Promise.all(
|
|
ids.map((id) => {
|
|
const account = usuariosCacheAtual.find((u) => u.id === id);
|
|
if (!account) return Promise.resolve();
|
|
const codes = account.perfis.map((p) => p.codigo).filter((c) => c !== draftProfile.codigo);
|
|
if (vincular) codes.push(draftProfile.codigo);
|
|
return pidSetUsuarioPerfis(id, codes);
|
|
})
|
|
);
|
|
} catch (e) {
|
|
erro = e;
|
|
} finally {
|
|
// Recarrega mesmo na falha: parte do lote pode ter sido gravada.
|
|
state.selected.clear();
|
|
await refreshUsuarios();
|
|
}
|
|
if (erro) {
|
|
await pidAlert(erro.message, { titulo: vincular ? "Não foi possível vincular" : "Não foi possível desvincular" });
|
|
}
|
|
}
|
|
|
|
["available", "linked"].forEach((kind) => {
|
|
const el = usersPanelsEl[kind];
|
|
el.refreshBtn.addEventListener("click", refreshUsuarios);
|
|
el.searchNome.addEventListener("input", renderUsersFromCache);
|
|
el.searchEmail.addEventListener("input", renderUsersFromCache);
|
|
el.sortHeader.addEventListener("click", () => {
|
|
usersDualState[kind].sortDir *= -1;
|
|
renderUsersFromCache();
|
|
});
|
|
el.selectAll.addEventListener("change", () => {
|
|
const filtrados = usersDualFiltrados(kind, listaParaPainelUsuarios(kind));
|
|
filtrados.forEach((u) => {
|
|
if (el.selectAll.checked) usersDualState[kind].selected.add(u.id);
|
|
else usersDualState[kind].selected.delete(u.id);
|
|
});
|
|
renderUsersFromCache();
|
|
});
|
|
el.body.addEventListener("change", (event) => {
|
|
const checkbox = event.target.closest("[data-pa-users-select]");
|
|
if (!checkbox) return;
|
|
const id = Number(checkbox.getAttribute("data-pa-users-select"));
|
|
if (checkbox.checked) usersDualState[kind].selected.add(id);
|
|
else usersDualState[kind].selected.delete(id);
|
|
renderUsersFromCache();
|
|
});
|
|
el.linkBtn.addEventListener("click", () => bulkAlterarVinculo(kind, kind === "available"));
|
|
});
|
|
|
|
saveBtn.addEventListener("click", async () => {
|
|
const nome = formNome.value.trim();
|
|
if (!nome) {
|
|
formError.textContent = "Informe o nome do perfil.";
|
|
formNome.focus();
|
|
return;
|
|
}
|
|
formError.textContent = "";
|
|
draftProfile.nome = nome;
|
|
|
|
// Trava contra clique duplo (dois cliques criavam dois perfis).
|
|
const textoOriginal = saveBtn.textContent;
|
|
saveBtn.disabled = true;
|
|
saveBtn.textContent = "Salvando…";
|
|
try {
|
|
if (draftProfile.codigo === null) {
|
|
await pidCreatePerfil(pidPerfilToApi(draftProfile));
|
|
} else {
|
|
await pidUpdatePerfil(draftProfile.codigo, pidPerfilToApi(draftProfile));
|
|
}
|
|
profiles = (await pidFetchPerfis()).map(pidPerfilFromApi);
|
|
closeEdit();
|
|
} catch (e) {
|
|
// O erro aparece abaixo do Nome (ex.: nome repetido); leva o cursor até lá.
|
|
formError.textContent = e.message;
|
|
formNome.focus();
|
|
} finally {
|
|
saveBtn.disabled = false;
|
|
saveBtn.textContent = textoOriginal;
|
|
}
|
|
});
|
|
|
|
renderList();
|
|
});
|