portal_publico/static/js/dual-select.js
2026-09-25 15:45:38 -03:00

153 lines
5.7 KiB
JavaScript

// Widget genérico de vinculação em duas tabelas — não vinculados (esquerda)
// e vinculados (direita), com checkbox de seleção múltipla, busca por coluna,
// ordenação por nome e um botão de ação em lote por painel ("Vincular"/
// "Desvincular"). Mesmo espírito visual/de interação do painel "Usuários do
// Escritório" de perfis-acesso.html (profiles.js), mas genérico o bastante
// pra rodar em qualquer shell — hoje reaproveitado por "Usuários sob
// liderança" em usuarios.html (users-admin.js) e no modal "Gerenciar
// Usuário" (account.js, presente em todo shell).
//
// A vinculação aqui é só em memória — quem chama decide quando persistir
// (chamando getVinculadosIds() no momento de salvar), não dispara nenhuma
// requisição por conta própria.
//
// config = {
// available: { selectAll, sortHeader, searchPrincipal, searchSecundaria, body, footer, acaoBtn },
// linked: { ...mesmo formato... },
// secundariaValor: (candidato) => string,
// }
// candidato = { id, nome, ...outros campos usados por secundariaValor }
function pidCriarSeletorDuplo(config) {
const paineis = { available: config.available, linked: config.linked };
const secundariaValor = config.secundariaValor || (() => "");
const estado = {
available: { sortDir: 1, selected: new Set() },
linked: { sortDir: 1, selected: new Set() },
};
let candidatos = [];
let vinculadosIds = new Set();
function secundariaTexto(candidato) {
return secundariaValor(candidato) || "—";
}
function listaPara(kind) {
return kind === "linked"
? candidatos.filter((c) => vinculadosIds.has(c.id))
: candidatos.filter((c) => !vinculadosIds.has(c.id));
}
function filtradosOrdenados(kind) {
const el = paineis[kind];
const state = estado[kind];
const termoNome = el.searchPrincipal.value.trim().toLowerCase();
const termoSecundaria = el.searchSecundaria.value.trim().toLowerCase();
return listaPara(kind)
.filter((c) => !termoNome || c.nome.toLowerCase().includes(termoNome))
.filter((c) => !termoSecundaria || secundariaTexto(c).toLowerCase().includes(termoSecundaria))
.slice()
.sort((a, b) => a.nome.localeCompare(b.nome, "pt-BR", { sensitivity: "base" }) * state.sortDir);
}
function renderPainel(kind) {
const el = paineis[kind];
const state = estado[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(listaPara(kind).map((c) => c.id));
Array.from(state.selected).forEach((id) => {
if (!idsAtuais.has(id)) state.selected.delete(id);
});
const filtrados = filtradosOrdenados(kind);
el.body.innerHTML = filtrados.length
? filtrados
.map(
(c) => `
<tr>
<td><input type="checkbox" data-dual-select="${c.id}" ${state.selected.has(c.id) ? "checked" : ""} /></td>
<td>${pidEscapeHtml(c.nome)}</td>
<td>${pidEscapeHtml(secundariaTexto(c))}</td>
</tr>`
)
.join("")
: `<tr class="dual-select__empty-row"><td colspan="3">Nenhum usuário encontrado.</td></tr>`;
const selecionadosVisiveis = filtrados.filter((c) => state.selected.has(c.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.acaoBtn.disabled = state.selected.size === 0;
}
function renderAmbos() {
renderPainel("available");
renderPainel("linked");
}
function mover(kind) {
const state = estado[kind];
const ids = Array.from(state.selected);
if (!ids.length) return;
ids.forEach((id) => {
if (kind === "available") vinculadosIds.add(id);
else vinculadosIds.delete(id);
});
state.selected.clear();
renderAmbos();
}
["available", "linked"].forEach((kind) => {
const el = paineis[kind];
el.searchPrincipal.addEventListener("input", () => renderPainel(kind));
el.searchSecundaria.addEventListener("input", () => renderPainel(kind));
el.sortHeader.addEventListener("click", () => {
estado[kind].sortDir *= -1;
renderPainel(kind);
});
el.selectAll.addEventListener("change", () => {
const filtrados = filtradosOrdenados(kind);
filtrados.forEach((c) => {
if (el.selectAll.checked) estado[kind].selected.add(c.id);
else estado[kind].selected.delete(c.id);
});
renderPainel(kind);
});
el.body.addEventListener("change", (event) => {
const checkbox = event.target.closest("[data-dual-select]");
if (!checkbox) return;
const id = Number(checkbox.getAttribute("data-dual-select"));
if (checkbox.checked) estado[kind].selected.add(id);
else estado[kind].selected.delete(id);
renderPainel(kind);
});
el.acaoBtn.addEventListener("click", () => mover(kind));
});
function setDados(novosCandidatos, vinculadosIniciais) {
candidatos = novosCandidatos;
vinculadosIds = new Set(vinculadosIniciais);
["available", "linked"].forEach((kind) => {
estado[kind].selected.clear();
estado[kind].sortDir = 1;
paineis[kind].searchPrincipal.value = "";
paineis[kind].searchSecundaria.value = "";
});
renderAmbos();
}
function getVinculadosIds() {
return Array.from(vinculadosIds);
}
return { setDados, getVinculadosIds };
}