// 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) => ` ${c.nome} ${secundariaTexto(c)} ` ) .join("") : `Nenhum usuário encontrado.`; 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 }; }