portal_publico/static/js/indicador-desempenho.js

2615 lines
116 KiB
JavaScript
Raw Permalink 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.

const PID_IND_TIPO_LABELS = {
1: "Contábil + Fiscal",
2: "Contador (sem conciliador)",
3: "Contador (com conciliador)",
4: "Fiscal",
5: "Conciliador",
};
const PID_IND_GRUPO_LABELS = { individual: "Individual", grupo: "Grupo", departamento: "Departamento" };
const PID_IND_PERIODO_LABELS = { todos: "Todos os meses", mar_a_nov: "Março a Novembro", dez_a_jan: "Dezembro a Janeiro" };
const PID_IND_CALCULO_LABELS = {
"": "Manual",
balancete: "Balancetes (automático)",
liberacao_fiscal: "Liberações fiscais (automático)",
conciliacao: "Conciliações (automático)",
};
const PID_IND_STATUS_LABELS = { revisao: "Em revisão", concluida: "Concluída" };
function pidIndFormatMoeda(valor) {
const numero = Number(valor || 0);
return numero.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
}
// Vírgula decimal (pt-BR): `toFixed` mostrava "74.29%" (revisão de 30/09/2026).
const PID_IND_PERCENTUAL = new Intl.NumberFormat("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const PID_IND_PESO = new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 2 });
function pidIndFormatPercentual(valor) {
return `${PID_IND_PERCENTUAL.format(Number(valor || 0))}%`;
}
// Peso é configurado pelo RH em IndicadorCriterio (ex.: 60, 10, 30) — quase
// sempre um número redondo; formata sem casas decimais "penduradas" (60% em
// vez de 60.00%), mas preserva casas reais se um peso fracionário existir.
function pidIndFormatPeso(valor) {
return `${PID_IND_PESO.format(Number(valor || 0))}%`;
}
// Todo percentual "atingido" deste app (Individual/Grupo/Departamento, cada
// um é uma média ponderada de critérios/percentuais <= 100) só bate 100,00%
// quando tudo que pesa ali foi de fato atingido — mesmo critério usado pra
// colorir cada um dos 3 níveis da composição (verde/vermelho).
function pidIndPercentualAtingido(valor) {
return Number(valor || 0).toFixed(2) === "100.00";
}
function pidIndFormatCompetencia(iso) {
if (!iso) return "";
const [ano, mes] = iso.split("-");
return `${mes}/${ano}`;
}
function pidIndMascaraCompetencia(valor) {
const digitos = (valor || "").replace(/\D/g, "").slice(0, 6);
if (digitos.length <= 2) return digitos;
return `${digitos.slice(0, 2)}/${digitos.slice(2)}`;
}
function pidIndCompetenciaParaIso(valor) {
const match = /^(\d{2})\/(\d{4})$/.exec(valor || "");
if (!match) return null;
const mes = Number(match[1]);
if (mes < 1 || mes > 12) return null;
return `${match[2]}-${match[1]}-01`;
}
function pidIndPeriodoAtual(competenciaIso) {
// Mesma regra de IndicadorApuracao.periodo_criterio() (models.py): dezembro
// e janeiro caem no período "Dez a Jan", os outros 10 meses em "Mar a Nov".
const mes = Number((competenciaIso || "").split("-")[1]);
return mes === 12 || mes === 1 ? "dez_a_jan" : "mar_a_nov";
}
function pidIndRespostaNaoUtilizada(resposta, colaborador, periodoAtual) {
if (resposta.valor === "NAO_SE_APLICA") return true;
if (resposta.criterio_periodo !== "todos" && resposta.criterio_periodo !== periodoAtual) return true;
if (resposta.criterio_grupo === "individual" && resposta.criterio_papel_aplicavel) {
const tiposDoColaborador = new Set(colaborador.empresas.map((empresa) => empresa.tipo));
if (!tiposDoColaborador.has(resposta.criterio_papel_aplicavel)) return true;
}
return false;
}
function pidIndFormatData(iso) {
if (!iso) return "";
// Data pura ("AAAA-MM-DD", DateField): `new Date()` lê como meia-noite UTC
// e mostrava o dia anterior no fuso de Brasília.
const dataPura = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (dataPura) return `${dataPura[3]}/${dataPura[2]}/${dataPura[1]}`;
return new Date(iso).toLocaleDateString("pt-BR");
}
/** Hoje em "AAAA-MM-DD" no fuso local (`toISOString()` usa UTC e, depois
* das 21h, dava a data de amanhã). */
function pidIndHojeIso() {
const hoje = new Date();
return `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, "0")}-${String(hoje.getDate()).padStart(2, "0")}`;
}
const PID_IND_MSG_ERRO_CARGA = "Não foi possível carregar. Recarregue a página; se persistir, contate a Inovação.";
/**
* Número digitado no padrão pt-BR → string com ponto decimal ("1234.56"),
* ou null se inválido. Com vírgula, todo ponto é separador de milhar
* ("1.234,56"); sem vírgula, o ponto só é milhar quando `milhar` é true e o
* texto tem cara de milhar ("1.500"), senão é decimal ("85.5"). Substitui o
* antigo `replace(",", ".")`, que só trocava a primeira vírgula e rejeitava
* "1.234,56" (revisão de 30/09/2026).
*/
function pidIndParseDecimal(texto, { milhar = false } = {}) {
let valor = (texto || "").trim().replace(/\s/g, "");
if (!valor) return null;
if (valor.includes(",")) {
valor = valor.replace(/\./g, "").replace(",", ".");
} else if (milhar && /^\d{1,3}(\.\d{3})+$/.test(valor)) {
valor = valor.replace(/\./g, "");
}
return /^\d+(\.\d+)?$/.test(valor) ? valor : null;
}
/**
* Trava um botão durante uma ação assíncrona (sem envio duplo): desabilita,
* troca o texto (ex.: "Salvando…") e restaura no fim, com ou sem erro.
* Clique repetido enquanto travado é ignorado.
*/
async function pidIndComBotaoOcupado(btn, textoOcupado, acao) {
if (!btn || btn.disabled) return undefined;
const textoOriginal = btn.textContent;
btn.disabled = true;
btn.textContent = textoOcupado;
try {
return await acao();
} finally {
btn.disabled = false;
btn.textContent = textoOriginal;
}
}
/** Retrato dos campos de um formulário (texto, select, checkbox, arquivo),
* comparado ao fechar pra só pedir confirmação quando algo mudou de fato. */
function pidIndRetratoCampos(ids) {
return JSON.stringify(
ids.map((id) => {
const campo = document.getElementById(id);
if (!campo) return null;
if (campo.type === "checkbox") return campo.checked;
if (campo.type === "file") return campo.files[0] ? campo.files[0].name : "";
return campo.value;
})
);
}
async function pidIndConfirmarDescarte(retratoInicial, ids) {
if (retratoInicial == null || pidIndRetratoCampos(ids) === retratoInicial) return true;
return pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
});
}
/** Mostra a mensagem de validação e leva o cursor ao campo com problema. */
function pidIndErroNoCampo(errorEl, mensagem, campoId) {
errorEl.textContent = mensagem;
const campo = document.getElementById(campoId);
if (campo) campo.focus();
}
// Delegado ao `pidEscapeHtml` (api.js), que também escapa aspas (a versão
// antiga por `textContent` não escapava, e a função é usada em atributos).
function pidIndEscapeHtml(texto) {
return pidEscapeHtml(texto);
}
function pidIndEscapeAttr(texto) {
return pidEscapeHtml(texto);
}
// "Cada gerente representa um grupo" (CLAUDE.md): agrupa os colaboradores da
// apuração por `gerente`, ordenado alfabeticamente — quem não tem gerente
// preenchido cai num grupo próprio, sempre exibido por último.
function pidIndAgruparPorGerente(colaboradores) {
const grupos = new Map();
colaboradores.forEach((colaborador) => {
const chave = colaborador.gerente || "";
if (!grupos.has(chave)) grupos.set(chave, []);
grupos.get(chave).push(colaborador);
});
return Array.from(grupos.entries()).sort(([a], [b]) => {
if (!a && !b) return 0;
if (!a) return 1;
if (!b) return -1;
return a.localeCompare(b, "pt-BR");
});
}
// Sentinel usado quando o `gerente` do colaborador ainda não está mapeado a
// nenhum IndicadorDepartamento (ver Configurações → Departamentos) — grupo
// próprio, sempre exibido por último.
const PID_IND_SEM_DEPARTAMENTO = "__sem-departamento__";
function pidIndChaveDepartamento(departamentoId) {
return departamentoId == null ? PID_IND_SEM_DEPARTAMENTO : String(departamentoId);
}
// Cada IndicadorDepartamento (Fisco/Contábil, Rocket, Gerentes, ...) tem sua
// própria meta de Departamento, seus próprios critérios e percentuais (ver
// CLAUDE.md) — agrupa os colaboradores da apuração por `departamento` (id,
// resolvido no servidor a partir do gerente na criação da apuração),
// ordenado alfabeticamente pelo nome do departamento.
function pidIndAgruparPorDepartamento(colaboradores) {
const grupos = new Map();
colaboradores.forEach((colaborador) => {
const chave = pidIndChaveDepartamento(colaborador.departamento);
if (!grupos.has(chave)) {
grupos.set(chave, {
chave,
id: colaborador.departamento,
nome: colaborador.departamento == null ? "Sem departamento definido" : colaborador.departamento_nome,
colaboradores: [],
});
}
grupos.get(chave).colaboradores.push(colaborador);
});
return Array.from(grupos.values()).sort((a, b) => {
if (a.id == null) return 1;
if (b.id == null) return -1;
return a.nome.localeCompare(b.nome, "pt-BR");
});
}
// Grupo/Departamento são compartilhados entre colaboradores (ver CLAUDE.md) —
// como o valor efetivo ainda é gravado por colaborador no banco (sem tabela
// nova pra grupo/apuração), aqui tiramos a média do conjunto pra exibição:
// depois de qualquer ajuste em bloco (ajustar-grupo/ajustar-departamento)
// todos os membros já ficam com o mesmo valor, então a média coincide com
// ele; só diverge se as respostas de critério subjacentes (ainda editáveis
// por colaborador) não tiverem sido igualadas — nesse caso a média é o
// retrato mais honesto do grupo até alguém usar o ajuste em bloco.
function pidIndValorRepresentativo(colaboradores, campo) {
if (!colaboradores.length) return { valor: 100, ajustado: false };
const soma = colaboradores.reduce((total, colaborador) => total + Number(colaborador[campo]), 0);
return {
valor: soma / colaboradores.length,
ajustado: colaboradores.some((colaborador) => colaborador[`${campo}_ajustado_manualmente`]),
};
}
// ---- API ----
async function pidFetchApuracoesIndicador() {
return pidApiRequest("/indicadores-apuracoes/");
}
async function pidFetchApuracaoIndicador(id) {
return pidApiRequest(`/indicadores-apuracoes/${id}/`);
}
async function pidCriarApuracaoIndicador(formData) {
return pidApiRequest("/indicadores-apuracoes/", { method: "POST", body: formData });
}
async function pidExcluirApuracaoIndicador(id) {
return pidApiRequest(`/indicadores-apuracoes/${id}/`, { method: "DELETE" });
}
async function pidAtualizarRespostaIndicador(id, valor) {
return pidApiRequest(`/indicadores-apuracoes-respostas/${id}/`, { method: "PATCH", body: { valor } });
}
async function pidAjustarPercentualColaboradorIndicador(colaboradorId, campo, valor) {
return pidApiRequest(`/indicadores-apuracoes-colaboradores/${colaboradorId}/`, {
method: "PATCH",
body: { [campo]: valor },
});
}
async function pidRecalcularColaboradorIndicador(colaboradorId) {
return pidApiRequest(`/indicadores-apuracoes-colaboradores/${colaboradorId}/recalcular/`, { method: "POST" });
}
// Checklist de revisão do RH (`validado`) — não recalcula nada, só marca/
// desmarca que este colaborador já foi conferido.
async function pidMarcarValidadoColaboradorIndicador(colaboradorId, validado) {
return pidApiRequest(`/indicadores-apuracoes-colaboradores/${colaboradorId}/marcar-validado/`, {
method: "POST",
body: { validado },
});
}
async function pidAjustarHonorarioEmpresaIndicador(empresaId, honorario) {
return pidApiRequest(`/indicadores-apuracoes-empresas/${empresaId}/`, { method: "PATCH", body: { honorario } });
}
// Reatribui uma linha (empresa+tipo) — identificada por `empresaId`, o id da
// IndicadorApuracaoEmpresa — pra outro colaborador da mesma apuração.
async function pidTrocarResponsavelEmpresaIndicador(empresaId, colaboradorId) {
return pidApiRequest(`/indicadores-apuracoes-empresas/${empresaId}/trocar-responsavel/`, {
method: "POST",
body: { colaborador_id: colaboradorId },
});
}
// "Grupo" (cada gerente representa um grupo) e "Departamento" (todo o
// Fiscontábil) não são ajustados colaborador a colaborador — os 4 endpoints
// abaixo aplicam o valor/recálculo de uma vez a todos os colaboradores do
// mesmo gerente (grupo) ou de toda a apuração (departamento), ver CLAUDE.md.
async function pidAjustarGrupoIndicador(apuracaoId, gerente, pctGrupo) {
return pidApiRequest(`/indicadores-apuracoes/${apuracaoId}/ajustar-grupo/`, {
method: "POST",
body: { gerente, pct_grupo: pctGrupo },
});
}
async function pidRecalcularGrupoIndicador(apuracaoId, gerente) {
return pidApiRequest(`/indicadores-apuracoes/${apuracaoId}/recalcular-grupo/`, {
method: "POST",
body: { gerente },
});
}
async function pidAjustarDepartamentoIndicador(apuracaoId, departamentoId, pctDepartamento) {
return pidApiRequest(`/indicadores-apuracoes/${apuracaoId}/ajustar-departamento/`, {
method: "POST",
body: { departamento: departamentoId, pct_departamento: pctDepartamento },
});
}
async function pidRecalcularDepartamentoIndicador(apuracaoId, departamentoId) {
return pidApiRequest(`/indicadores-apuracoes/${apuracaoId}/recalcular-departamento/`, {
method: "POST",
body: { departamento: departamentoId },
});
}
// Preenche o honorário de uma empresa (por código) de uma vez pra todos os
// colaboradores desta apuração que a têm com honorario_nao_encontrado=True —
// diferente de pidAjustarHonorarioEmpresaIndicador (acima), que ajusta só uma
// linha por id. Ver CLAUDE.md/IndicadorApuracaoViewSet.ajustar_honorario_empresa.
async function pidAjustarHonorarioEmpresaEmLoteIndicador(apuracaoId, codigoEmpresa, honorario) {
return pidApiRequest(`/indicadores-apuracoes/${apuracaoId}/ajustar-honorario-empresa/`, {
method: "POST",
body: { codigo_empresa: codigoEmpresa, honorario },
});
}
async function pidAplicarEmLoteIndicador(respostaIds, valor) {
return pidApiRequest("/indicadores-apuracoes-respostas/aplicar-em-lote/", {
method: "POST",
body: { resposta_ids: respostaIds, valor },
});
}
async function pidFetchCriteriosIndicador() {
return pidApiRequest("/indicadores-criterios/");
}
async function pidCriarCriterioIndicador(dados) {
return pidApiRequest("/indicadores-criterios/", { method: "POST", body: dados });
}
async function pidAtualizarCriterioIndicador(id, dados) {
return pidApiRequest(`/indicadores-criterios/${id}/`, { method: "PATCH", body: dados });
}
async function pidExcluirCriterioIndicador(id) {
return pidApiRequest(`/indicadores-criterios/${id}/`, { method: "DELETE" });
}
async function pidFetchDepartamentosIndicador() {
return pidApiRequest("/indicadores-departamentos/");
}
async function pidCriarDepartamentoIndicador(dados) {
return pidApiRequest("/indicadores-departamentos/", { method: "POST", body: dados });
}
async function pidAtualizarDepartamentoIndicador(id, dados) {
return pidApiRequest(`/indicadores-departamentos/${id}/`, { method: "PATCH", body: dados });
}
async function pidExcluirDepartamentoIndicador(id) {
return pidApiRequest(`/indicadores-departamentos/${id}/`, { method: "DELETE" });
}
async function pidFetchDepartamentoGerentesIndicador() {
return pidApiRequest("/indicadores-departamentos-gerentes/");
}
async function pidCriarDepartamentoGerenteIndicador(dados) {
return pidApiRequest("/indicadores-departamentos-gerentes/", { method: "POST", body: dados });
}
async function pidExcluirDepartamentoGerenteIndicador(id) {
return pidApiRequest(`/indicadores-departamentos-gerentes/${id}/`, { method: "DELETE" });
}
async function pidFetchPercentuaisIndicador() {
return pidApiRequest("/indicadores-percentuais-tipo/");
}
async function pidCriarPercentualIndicador(dados) {
return pidApiRequest("/indicadores-percentuais-tipo/", { method: "POST", body: dados });
}
async function pidExcluirPercentualIndicador(id) {
return pidApiRequest(`/indicadores-percentuais-tipo/${id}/`, { method: "DELETE" });
}
/**
* Resposta de /gerar/ é binária (ZIP de PDFs), não JSON — mesmo padrão de
* pidGerarArquivoPlanoSaude em importacao-plano-saude.js: fetch manual,
* reaproveitando pidEnsureCsrfCookie/pidGetCookie/pidErrorMessageFrom (globais
* de api.js). `colaboradorIds` opcional restringe a geração a só esses
* colaboradores (modal "Gerar Recibos") — omitido/vazio gera pra todos.
*/
async function pidGerarRecibosIndicador(id, colaboradorIds) {
await pidEnsureCsrfCookie();
const response = await fetch(`/api/indicadores-apuracoes/${id}/gerar/`, {
method: "POST",
credentials: "include",
headers: {
"X-CSRFToken": pidGetCookie("csrftoken") || "",
"Content-Type": "application/json",
},
body: JSON.stringify({ colaborador_ids: colaboradorIds && colaboradorIds.length ? colaboradorIds : undefined }),
});
if (!response.ok) {
let mensagem = "Não foi possível gerar os recibos.";
try {
mensagem = pidErrorMessageFrom(await response.json());
} catch (e) {
// resposta sem corpo JSON
}
throw new Error(mensagem);
}
const blob = await response.blob();
const disposition = response.headers.get("Content-Disposition") || "";
const match = disposition.match(/filename="?([^"]+)"?/);
const filename = match ? match[1] : `recibos_indicador_${id}.zip`;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
document.addEventListener("DOMContentLoaded", async () => {
const listView = document.getElementById("ind-list-view");
if (!listView) return;
const me = await pidGetMe();
if (!me) return;
const geradocPerms = (me.permissoes_efetivas && me.permissoes_efetivas["geradoc"]) || {};
const canUse = !!(geradocPerms.enabled && geradocPerms.apps && geradocPerms.apps["indicador-desempenho"]);
const noAccessEl = document.getElementById("ind-no-access");
const contentEl = document.getElementById("ind-content");
if (noAccessEl) noAccessEl.hidden = canUse;
if (contentEl) contentEl.hidden = !canUse;
if (!canUse) return;
const formView = document.getElementById("ind-form-view");
const reviewView = document.getElementById("ind-review-view");
const configView = document.getElementById("ind-config-view");
let apuracaoAtual = null;
const respostasSelecionadas = new Set();
let criterios = [];
let percentuais = [];
let departamentosConfig = [];
let departamentoGerentesConfig = [];
function showView(view) {
listView.hidden = view !== "list";
formView.hidden = view !== "form";
reviewView.hidden = view !== "review";
configView.hidden = view !== "config";
}
// ---- Histórico ----
// Erro de carga vira mensagem própria na tabela, nunca "Nenhuma apuração
// realizada ainda" (que daria a entender que o histórico está vazio).
async function carregarLista() {
const body = document.getElementById("ind-list-body");
const empty = document.getElementById("ind-list-empty");
let apuracoes;
try {
apuracoes = await pidFetchApuracoesIndicador();
} catch (e) {
empty.hidden = true;
body.innerHTML = `<tr><td colspan="6" class="ind-tabela-vazia ind-tabela-vazia--erro">${pidIndEscapeHtml(PID_IND_MSG_ERRO_CARGA)}</td></tr>`;
return false;
}
body.innerHTML = "";
empty.hidden = apuracoes.length > 0;
apuracoes.forEach((apuracao) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${pidIndFormatCompetencia(apuracao.competencia)}</td>
<td>${apuracao.total_colaboradores}</td>
<td><span class="status-pill ${apuracao.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"}">${pidIndEscapeHtml(PID_IND_STATUS_LABELS[apuracao.status] || apuracao.status)}</span></td>
<td>${pidIndEscapeHtml(apuracao.criado_por_nome || "-")}</td>
<td>${pidIndFormatData(apuracao.criado_em)}</td>
<td class="pa-row-actions">
<button type="button" class="btn-outline" data-ind-abrir="${apuracao.id}">Abrir</button>
<button type="button" class="icon-btn" data-ind-excluir="${apuracao.id}" aria-label="Excluir" title="Excluir">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</td>
`;
body.appendChild(tr);
});
return true;
}
document.getElementById("ind-list-body").addEventListener("click", async (event) => {
const abrirBtn = event.target.closest("[data-ind-abrir]");
if (abrirBtn) {
await pidIndComBotaoOcupado(abrirBtn, "Abrindo…", async () => {
try {
await abrirRevisao(Number(abrirBtn.getAttribute("data-ind-abrir")));
} catch (e) {
await pidAlert(e.message || PID_IND_MSG_ERRO_CARGA, { titulo: "Não foi possível abrir a apuração" });
}
});
return;
}
const excluirBtn = event.target.closest("[data-ind-excluir]");
if (excluirBtn) {
if (excluirBtn.disabled) return;
if (!(await pidConfirm("Excluir esta apuração? Essa ação não pode ser desfeita.", { perigoso: true }))) return;
excluirBtn.disabled = true;
try {
await pidExcluirApuracaoIndicador(Number(excluirBtn.getAttribute("data-ind-excluir")));
} catch (e) {
excluirBtn.disabled = false;
await pidAlert(e.message || "Tente de novo; se persistir, contate a Inovação.", {
titulo: "Não foi possível excluir a apuração",
});
return;
}
await carregarLista();
}
});
function wireArquivoField(inputId) {
const input = document.getElementById(inputId);
const nameEl = document.getElementById(`${inputId}-name`);
const removeBtn = document.getElementById(`${inputId}-remove`);
function atualizar() {
const arquivo = input.files[0];
nameEl.textContent = arquivo ? arquivo.name : "Nenhum arquivo selecionado";
removeBtn.hidden = !arquivo;
}
input.addEventListener("change", atualizar);
removeBtn.addEventListener("click", () => {
input.value = "";
atualizar();
});
return atualizar;
}
const resetTareffaField = wireArquivoField("ind-form-tareffa");
const resetHonorariosField = wireArquivoField("ind-form-honorarios");
document.getElementById("ind-form-competencia").addEventListener("input", (event) => {
event.target.value = pidIndMascaraCompetencia(event.target.value);
});
const PID_IND_CAMPOS_NOVA_APURACAO = ["ind-form-competencia", "ind-form-tareffa", "ind-form-honorarios"];
let retratoNovaApuracao = null;
document.getElementById("ind-new-btn").addEventListener("click", () => {
document.getElementById("ind-form-competencia").value = "";
document.getElementById("ind-form-tareffa").value = "";
document.getElementById("ind-form-honorarios").value = "";
resetTareffaField();
resetHonorariosField();
document.getElementById("ind-form-error").textContent = "";
retratoNovaApuracao = pidIndRetratoCampos(PID_IND_CAMPOS_NOVA_APURACAO);
showView("form");
});
async function sairDaNovaApuracao() {
if (!(await pidIndConfirmarDescarte(retratoNovaApuracao, PID_IND_CAMPOS_NOVA_APURACAO))) return;
showView("list");
}
document.getElementById("ind-form-back-btn").addEventListener("click", sairDaNovaApuracao);
document.getElementById("ind-form-cancel-btn").addEventListener("click", sairDaNovaApuracao);
document.getElementById("ind-form-submit-btn").addEventListener("click", async () => {
const errorEl = document.getElementById("ind-form-error");
errorEl.textContent = "";
const competenciaTexto = document.getElementById("ind-form-competencia").value.trim();
const competenciaIso = pidIndCompetenciaParaIso(competenciaTexto);
const tareffa = document.getElementById("ind-form-tareffa").files[0];
const honorarios = document.getElementById("ind-form-honorarios").files[0];
if (!competenciaTexto) {
pidIndErroNoCampo(errorEl, "Informe a competência (MM/AAAA, ex.: 08/2026).", "ind-form-competencia");
return;
}
if (!competenciaIso) {
pidIndErroNoCampo(errorEl, "Competência inválida. Use mês e ano no formato MM/AAAA, ex.: 08/2026.", "ind-form-competencia");
return;
}
if (!tareffa) {
pidIndErroNoCampo(errorEl, "Anexe a planilha de Serviços Tareffa (.xlsx).", "ind-form-tareffa");
return;
}
if (!honorarios) {
pidIndErroNoCampo(errorEl, "Anexe a planilha de Honorários Por Cliente (.xlsx).", "ind-form-honorarios");
return;
}
const formData = new FormData();
formData.append("competencia", competenciaIso);
formData.append("planilha_tareffa", tareffa);
formData.append("planilha_honorarios", honorarios);
const submitBtn = document.getElementById("ind-form-submit-btn");
await pidIndComBotaoOcupado(submitBtn, "Processando…", async () => {
try {
const apuracao = await pidCriarApuracaoIndicador(formData);
apuracaoAtual = apuracao;
respostasSelecionadas.clear();
cardsExpandidos.clear();
filtroDepartamento = PID_IND_FILTRO_TODOS;
retratoNovaApuracao = null;
renderRevisao();
showView("review");
window.scrollTo(0, 0);
await carregarLista();
} catch (e) {
errorEl.textContent = e.message;
}
});
});
// ---- Revisão ----
const PID_IND_FILTRO_TODOS = "__todos__";
let filtroDepartamento = PID_IND_FILTRO_TODOS;
// Ids dos colaboradores com o card aberto — sobrevive ao redesenho que
// todo ajuste faz (recarregarRevisao), pra uma resposta não fechar o card
// que o RH está conferindo. Zerado só ao abrir outra apuração.
const cardsExpandidos = new Set();
async function abrirRevisao(id) {
apuracaoAtual = await pidFetchApuracaoIndicador(id);
respostasSelecionadas.clear();
cardsExpandidos.clear();
filtroDepartamento = PID_IND_FILTRO_TODOS;
renderRevisao();
showView("review");
window.scrollTo(0, 0);
}
// Busca a apuração de novo e redesenha mantendo os cards abertos (ver
// cardsExpandidos) e a posição da rolagem da página — usado depois de
// todo ajuste da revisão. Mesmo objetivo do "validado", que nem redesenha.
async function recarregarRevisao() {
const rolagem = window.scrollY;
apuracaoAtual = await pidFetchApuracaoIndicador(apuracaoAtual.id);
renderRevisao();
window.scrollTo(0, rolagem);
}
function pidIndRespostasDoGrupo(colaborador, grupo) {
// Mostra todos os critérios ativos daquele grupo, mesmo os que não se
// aplicam ao(s) papel(is) do colaborador nesta apuração — a exclusão por
// papel/período só afeta o cálculo do percentual (ver calculo.py), não a
// visibilidade na revisão, pra ficar transparente o que foi considerado.
return colaborador.respostas.filter((resposta) => resposta.criterio_grupo === grupo);
}
function renderRevisao() {
const apuracao = apuracaoAtual;
document.getElementById("ind-review-subtitle").textContent = `Competência ${pidIndFormatCompetencia(apuracao.competencia)} — ${apuracao.colaboradores.length} colaborador(es)`;
document.getElementById("ind-review-error").textContent = "";
const avisosEl = document.getElementById("ind-review-avisos");
if (apuracao.avisos && apuracao.avisos.length) {
avisosEl.hidden = false;
avisosEl.innerHTML = `<strong>Casos pra confirmar manualmente:</strong><ul>${apuracao.avisos
.map((aviso) => `<li>${pidIndEscapeHtml(aviso)}</li>`)
.join("")}</ul>`;
} else {
avisosEl.hidden = true;
avisosEl.innerHTML = "";
}
renderFiltroDepartamento();
renderMetasSection();
renderColaboradoresList();
atualizarBotaoEmpresasSemHonorario();
atualizarBarraLote();
}
// ---- Metas de Grupo e Departamento (tela no início da revisão) ----
// "Cada gerente representa um grupo" (CLAUDE.md): em vez de editar o
// percentual espalhado card a card, o RH preenche todas as metas — uma por
// gerente + a do Departamento (todo o Fiscontábil) — numa única tabela no
// topo da página, antes da lista de colaboradores.
// Mostra, embaixo de cada label da tabela de metas, os mesmos critérios que
// compõem aquele nível (mesma fonte usada na seção "Individual"/"Grupo"/
// "Departamento" de dentro do card do colaborador — ver
// renderRespostasGrupoHtml) — deixa explícito o que o percentual
// preenchido está medindo, do mesmo jeito que a linha "Critério" já deixa
// claro na parte individual.
// `colaboradoresEscopo` é quem responde esse critério — todos os
// colaboradores da apuração pra Departamento, só os do gerente pra Grupo —
// usa o primeiro deles como referência pro valor atual do select (todos
// deveriam estar em sincronia, já que selecionar aqui aplica em lote pra
// todo o escopo de uma vez).
function pidIndCriteriosDoGrupo(grupo, colaboradoresEscopo) {
const primeiroColaborador = colaboradoresEscopo[0];
if (!primeiroColaborador) return [];
const periodoAtual = pidIndPeriodoAtual(apuracaoAtual.competencia);
return primeiroColaborador.respostas
.filter((resposta) => resposta.criterio_grupo === grupo)
.map((resposta) => ({
criterioId: resposta.criterio,
nome: resposta.criterio_nome,
peso: resposta.criterio_peso,
valor: resposta.valor,
apagado: resposta.criterio_periodo !== "todos" && resposta.criterio_periodo !== periodoAtual,
}));
}
// Só texto informativo agora (nome + peso do critério) — o Sim/Não de cada
// critério deixou de ser respondível aqui (existia um select duplicado ao
// lado do texto, redundante com o Sim/Não da coluna "Meta (%)" à direita,
// que já ajusta pct_grupo/pct_departamento direto; pedido explícito do
// usuário pra remover a duplicidade). Responder um critério específico
// continua possível por colaborador, dentro do card de revisão (ver
// renderRespostasGrupoHtml).
function renderMetaCriteriosHtml(criteriosDoGrupo) {
if (!criteriosDoGrupo.length) {
return '<p class="ind-meta-criterios-vazio">Nenhum critério cadastrado pra este nível.</p>';
}
return `
<ul class="ind-meta-criterios">
${criteriosDoGrupo
.map(
(criterio) => `
<li class="${criterio.apagado ? "is-apagado" : ""}">
<span class="ind-meta-criterios__nome">${pidIndEscapeHtml(criterio.nome)}</span>
<span class="ind-meta-criterios__peso">(peso ${pidIndFormatPercentual(criterio.peso)})</span>
${criterio.apagado ? '<span class="ind-apagado-badge">não entra no cálculo neste período</span>' : ""}
</li>`
)
.join("")}
</ul>
`;
}
// Uma linha de Departamento por IndicadorDepartamento (Fisco/Contábil,
// Rocket, Gerentes, ...) + as linhas de Grupo (por gerente) dos
// colaboradores daquele departamento — o filtro de departamento
// (`renderFiltroDepartamento`) restringe a só um de cada vez, sem afetar o
// cálculo de nenhum deles (ver CLAUDE.md). Colaborador cujo gerente ainda
// não está mapeado a nenhum departamento cai num grupo "Sem departamento
// definido", sem `<select>` de meta (não há em qual IndicadorDepartamento
// aplicá-la).
function renderMetasSection() {
const body = document.getElementById("ind-metas-body");
const gruposDepartamento = pidIndAgruparPorDepartamento(apuracaoAtual.colaboradores).filter(
(grupo) => filtroDepartamento === PID_IND_FILTRO_TODOS || grupo.chave === filtroDepartamento
);
const linhas = [];
gruposDepartamento.forEach((grupo) => {
const colaboradoresDoDepartamento = grupo.colaboradores;
if (grupo.id == null) {
linhas.push(`
<tr>
<td>
<div class="ind-meta-label">${pidIndEscapeHtml(grupo.nome)}</div>
<p class="ind-meta-sem-departamento-aviso">Gerente não mapeado a nenhum departamento — cadastre em Configurações → Departamentos.</p>
</td>
<td>${colaboradoresDoDepartamento.length} colaborador(es)</td>
<td></td>
<td></td>
</tr>
`);
} else {
const departamentoInfo = pidIndValorRepresentativo(colaboradoresDoDepartamento, "pct_departamento");
const criteriosDepartamento = renderMetaCriteriosHtml(
pidIndCriteriosDoGrupo("departamento", colaboradoresDoDepartamento)
);
linhas.push(
renderMetaLinhaHtml({
label: `Departamento (todo o ${grupo.nome})`,
criteriosHtml: criteriosDepartamento,
count: colaboradoresDoDepartamento.length,
valor: departamentoInfo.valor,
ajustado: departamentoInfo.ajustado,
inputAttrs: `data-ind-meta-tipo="departamento" data-ind-meta-departamento="${grupo.id}"`,
recalcularAttrs: `data-ind-meta-recalcular-departamento="${grupo.id}"`,
})
);
}
pidIndAgruparPorGerente(colaboradoresDoDepartamento).forEach(([gerente, colaboradoresDoGrupo]) => {
const grupoInfo = pidIndValorRepresentativo(colaboradoresDoGrupo, "pct_grupo");
const criteriosGrupo = renderMetaCriteriosHtml(pidIndCriteriosDoGrupo("grupo", colaboradoresDoGrupo));
linhas.push(
renderMetaLinhaHtml({
label: `Gerente: ${gerente || "Sem gerente definido"}`,
criteriosHtml: criteriosGrupo,
count: colaboradoresDoGrupo.length,
valor: grupoInfo.valor,
ajustado: grupoInfo.ajustado,
inputAttrs: `data-ind-meta-tipo="grupo" data-ind-meta-gerente="${pidIndEscapeAttr(gerente)}"`,
recalcularAttrs: `data-ind-meta-recalcular-grupo="${pidIndEscapeAttr(gerente)}"`,
})
);
});
});
body.innerHTML = linhas.join("");
}
// Meta de Grupo/Departamento só tem duas possibilidades de verdade — "será
// pago ou não" (decisão explícita do usuário) — por isso é Sim/Não (100%/0%),
// não um percentual livre como o "% Individual atingido" de cada colaborador.
function renderMetaLinhaHtml({ label, criteriosHtml, count, valor, ajustado, inputAttrs, recalcularAttrs }) {
const ehSim = Number(valor) >= 50;
return `
<tr>
<td>
<div class="ind-meta-label">${pidIndEscapeHtml(label)}</div>
${criteriosHtml}
</td>
<td>${count} colaborador(es)</td>
<td>
<select class="ind-meta-select" ${inputAttrs}>
<option value="100" ${ehSim ? "selected" : ""}>Sim</option>
<option value="0" ${ehSim ? "" : "selected"}>Não</option>
</select>
${ajustado ? '<span class="is-ajustado">ajustado manualmente</span>' : ""}
</td>
<td class="ind-meta-acao">
${
ajustado
? `<button type="button" class="ind-pct-reset-btn" ${recalcularAttrs} title="Recalcular automaticamente">↺</button>`
: ""
}
</td>
</tr>
`;
}
document.getElementById("ind-metas-body").addEventListener("change", async (event) => {
const select = event.target.closest(".ind-meta-select");
if (!select) return;
const errorEl = document.getElementById("ind-metas-error");
errorEl.textContent = "";
select.disabled = true;
try {
if (select.getAttribute("data-ind-meta-tipo") === "departamento") {
await pidAjustarDepartamentoIndicador(
apuracaoAtual.id,
Number(select.getAttribute("data-ind-meta-departamento")),
select.value
);
} else {
await pidAjustarGrupoIndicador(apuracaoAtual.id, select.getAttribute("data-ind-meta-gerente"), select.value);
}
await recarregarRevisao();
} catch (e) {
errorEl.textContent = e.message;
} finally {
select.disabled = false;
}
});
document.getElementById("ind-metas-body").addEventListener("click", async (event) => {
const errorEl = document.getElementById("ind-metas-error");
const recalcularDepartamentoBtn = event.target.closest("[data-ind-meta-recalcular-departamento]");
if (recalcularDepartamentoBtn) {
if (recalcularDepartamentoBtn.disabled) return;
recalcularDepartamentoBtn.disabled = true;
errorEl.textContent = "";
try {
await pidRecalcularDepartamentoIndicador(
apuracaoAtual.id,
Number(recalcularDepartamentoBtn.getAttribute("data-ind-meta-recalcular-departamento"))
);
await recarregarRevisao();
} catch (e) {
errorEl.textContent = e.message;
} finally {
recalcularDepartamentoBtn.disabled = false;
}
return;
}
const recalcularGrupoBtn = event.target.closest("[data-ind-meta-recalcular-grupo]");
if (recalcularGrupoBtn) {
if (recalcularGrupoBtn.disabled) return;
recalcularGrupoBtn.disabled = true;
errorEl.textContent = "";
try {
await pidRecalcularGrupoIndicador(apuracaoAtual.id, recalcularGrupoBtn.getAttribute("data-ind-meta-recalcular-grupo"));
await recarregarRevisao();
} catch (e) {
errorEl.textContent = e.message;
} finally {
recalcularGrupoBtn.disabled = false;
}
}
});
// ---- Filtro por departamento (Fisco/Contábil, Rocket, Gerentes, ...) —
// afeta a tabela de Metas (renderMetasSection) e a lista de colaboradores
// abaixo ao mesmo tempo. ----
function renderFiltroDepartamento() {
const container = document.getElementById("ind-filtro-departamento");
const grupos = pidIndAgruparPorDepartamento(apuracaoAtual.colaboradores);
const chipHtml = (chave, rotulo) =>
`<button type="button" class="ind-departamento-chip ${filtroDepartamento === chave ? "is-active" : ""}" data-ind-filtro-departamento="${pidIndEscapeAttr(chave)}">${pidIndEscapeHtml(rotulo)}</button>`;
container.innerHTML =
chipHtml(PID_IND_FILTRO_TODOS, "Todos") + grupos.map((grupo) => chipHtml(grupo.chave, grupo.nome)).join("");
}
document.getElementById("ind-filtro-departamento").addEventListener("click", (event) => {
const chip = event.target.closest("[data-ind-filtro-departamento]");
if (!chip) return;
filtroDepartamento = chip.getAttribute("data-ind-filtro-departamento");
renderFiltroDepartamento();
renderMetasSection();
renderColaboradoresList();
});
function colaboradoresFiltrados() {
return filtroDepartamento === PID_IND_FILTRO_TODOS
? apuracaoAtual.colaboradores
: apuracaoAtual.colaboradores.filter(
(colaborador) => pidIndChaveDepartamento(colaborador.departamento) === filtroDepartamento
);
}
function renderColaboradoresList() {
const listEl = document.getElementById("ind-colaboradores-list");
listEl.innerHTML = "";
colaboradoresFiltrados().forEach((colaborador) => listEl.appendChild(renderColaboradorCard(colaborador)));
}
// ---- Empresas por código (popup "Empresas sem Honorário", com as duas seções) ----
// A mesma empresa pode aparecer sob mais de um colaborador — um responsável
// pelo balancete, outro pela liberação fiscal, outro pela conciliação
// financeira — mas o honorário é da empresa, não do colaborador. Agrupar
// por código deixa explícito que preencher (ou corrigir) uma vez já resolve
// todo mundo (aplicado via pidAjustarHonorarioEmpresaEmLoteIndicador, que
// atualiza todas as linhas com esse código de uma vez no backend — o mesmo
// endpoint cobre tanto o preenchimento inicial quanto a correção de um
// valor já ajustado, ver IndicadorApuracaoViewSet.ajustar_honorario_empresa).
function empresasAgrupadasPorCodigo(filtro) {
const porCodigo = new Map();
apuracaoAtual.colaboradores.forEach((colaborador) => {
colaborador.empresas.filter(filtro).forEach((empresa) => {
if (!porCodigo.has(empresa.codigo_empresa)) {
porCodigo.set(empresa.codigo_empresa, {
codigo_empresa: empresa.codigo_empresa,
nome_empresa: empresa.nome_empresa,
honorario: empresa.honorario,
responsaveis: [],
});
}
porCodigo.get(empresa.codigo_empresa).responsaveis.push({
nome: colaborador.nome,
tipoLabel: empresa.tipo_label,
empresaRowId: empresa.id,
colaboradorId: colaborador.id,
});
});
});
// Mesmo critério de IndicadorApuracaoEmpresa.Meta.ordering: tamanho da
// string primeiro — senão "80"/"503" ficam depois de "2134" (ordem
// alfabética, não numérica, quando os códigos têm tamanhos diferentes).
return Array.from(porCodigo.values()).sort((a, b) => {
const porTamanho = a.codigo_empresa.length - b.codigo_empresa.length;
return porTamanho !== 0 ? porTamanho : a.codigo_empresa.localeCompare(b.codigo_empresa);
});
}
function renderEmpresaGrupoItemHtml(grupo, valorInicial) {
return `
<div class="ind-esh-item">
<div class="ind-esh-header">
<span class="ind-esh-codigo">Código ${pidIndEscapeHtml(grupo.codigo_empresa)}</span>
<span class="ind-esh-nome">${pidIndEscapeHtml(grupo.nome_empresa || grupo.codigo_empresa)}</span>
</div>
<ul class="ind-esh-responsaveis">
${grupo.responsaveis
.map((r) => `<li>${pidIndEscapeHtml(r.nome)} — ${pidIndEscapeHtml(r.tipoLabel)}</li>`)
.join("")}
</ul>
<div class="ind-esh-form">
<input type="text" class="ind-empresa-honorario-input" name="honorario" inputmode="decimal" autocomplete="off" placeholder="Ex.: 1.500,00…" value="${valorInicial}" />
<button type="button" class="btn-solid" data-ind-esh-salvar="${pidIndEscapeAttr(grupo.codigo_empresa)}">Salvar</button>
</div>
</div>`;
}
// Sem nada pendente, o botão deixa de ser um alerta ("sem honorário") e
// passa a apontar direto pra revisão do que já foi ajustado — pedido
// explícito do usuário: nada mais pra resolver, então nem faz sentido
// manter a cor de atenção (--danger).
function atualizarBotaoEmpresasSemHonorario() {
const btn = document.getElementById("ind-empresas-sem-honorario-btn");
const semHonorario = empresasAgrupadasPorCodigo((empresa) => empresa.honorario_nao_encontrado).length;
if (semHonorario > 0) {
btn.textContent = `Visualizar Empresas sem Honorário (${semHonorario})`;
btn.classList.add("ind-empresas-sem-honorario-btn");
return;
}
const ajustadas = empresasAgrupadasPorCodigo((empresa) => empresa.honorario_ajustado_manualmente).length;
btn.textContent = ajustadas
? `Visualizar Empresas com Honorário Ajustado (${ajustadas})`
: "Visualizar Empresas com Honorário Ajustado";
btn.classList.remove("ind-empresas-sem-honorario-btn");
}
function renderEmpresasSemHonorarioList() {
const grupos = empresasAgrupadasPorCodigo((empresa) => empresa.honorario_nao_encontrado);
document.getElementById("ind-empresas-sem-honorario-vazio").hidden = grupos.length > 0;
document.getElementById("ind-empresas-sem-honorario-list").innerHTML = grupos
.map((grupo) => renderEmpresaGrupoItemHtml(grupo, ""))
.join("");
}
// Diferente da lista de "sem honorário": aqui o campo já vem preenchido com
// o valor atual, porque o RH está corrigindo um número já informado, não
// preenchendo um em branco pela primeira vez. Fica escondida por padrão
// dentro do mesmo popup, atrás do botão "Visualizar Empresas Ajustadas
// Manualmente" (pedido explícito do usuário — no final do modal, revelada
// sob demanda, em vez de sempre visível junto com a lista principal).
function renderEmpresasAjustadasList() {
const grupos = empresasAgrupadasPorCodigo((empresa) => empresa.honorario_ajustado_manualmente);
document.getElementById("ind-empresas-ajustadas-vazio").hidden = grupos.length > 0;
document.getElementById("ind-empresas-ajustadas-list").innerHTML = grupos
.map((grupo) => renderEmpresaGrupoItemHtml(grupo, Number(grupo.honorario).toFixed(2).replace(".", ",")))
.join("");
}
function atualizarBotaoEmpresasAjustadas() {
const total = empresasAgrupadasPorCodigo((empresa) => empresa.honorario_ajustado_manualmente).length;
const aberta = !document.getElementById("ind-empresas-ajustadas-section").hidden;
const rotulo = aberta ? "Ocultar Empresas Ajustadas Manualmente" : "Visualizar Empresas Ajustadas Manualmente";
document.getElementById("ind-empresas-ajustadas-toggle-btn").textContent = total ? `${rotulo} (${total})` : rotulo;
}
// Reflete o estado atual nas duas seções — a "sem honorário" (sempre
// visível) e a "ajustadas" (só se já estiver aberta), usado tanto ao abrir
// o popup quanto depois de salvar um ajuste, já que corrigir uma empresa
// "sem honorário" faz ela migrar pra lista "ajustada manualmente" na hora.
function renderEmpresasHonorario() {
renderEmpresasSemHonorarioList();
if (!document.getElementById("ind-empresas-ajustadas-section").hidden) {
renderEmpresasAjustadasList();
}
atualizarBotaoEmpresasAjustadas();
}
async function salvarAjusteHonorarioEmpresa(event) {
const btn = event.target.closest("[data-ind-esh-salvar]");
if (!btn) return;
const errorEl = document.getElementById("ind-empresas-sem-honorario-error");
errorEl.textContent = "";
const input = btn.closest(".ind-esh-item").querySelector(".ind-empresa-honorario-input");
const valor = pidIndParseDecimal(input.value, { milhar: true });
if (valor == null) {
errorEl.textContent = "Informe um honorário válido, no formato 1.234,56.";
input.focus();
return;
}
await pidIndComBotaoOcupado(btn, "Salvando…", async () => {
try {
await pidAjustarHonorarioEmpresaEmLoteIndicador(apuracaoAtual.id, btn.getAttribute("data-ind-esh-salvar"), valor);
await recarregarRevisao();
renderEmpresasHonorario();
} catch (e) {
errorEl.textContent = e.message;
}
});
}
document.getElementById("ind-empresas-sem-honorario-btn").addEventListener("click", () => {
document.getElementById("ind-empresas-sem-honorario-error").textContent = "";
document.getElementById("ind-empresas-ajustadas-section").hidden = true;
renderEmpresasHonorario();
document.getElementById("ind-empresas-sem-honorario-modal").hidden = false;
});
document.getElementById("ind-empresas-sem-honorario-close-btn").addEventListener("click", () => {
document.getElementById("ind-empresas-sem-honorario-modal").hidden = true;
});
document.getElementById("ind-empresas-sem-honorario-modal").addEventListener("click", (event) => {
if (event.target.id === "ind-empresas-sem-honorario-modal") {
document.getElementById("ind-empresas-sem-honorario-modal").hidden = true;
}
});
document.getElementById("ind-empresas-ajustadas-toggle-btn").addEventListener("click", () => {
const section = document.getElementById("ind-empresas-ajustadas-section");
section.hidden = !section.hidden;
if (!section.hidden) renderEmpresasAjustadasList();
atualizarBotaoEmpresasAjustadas();
});
document.getElementById("ind-empresas-sem-honorario-list").addEventListener("click", salvarAjusteHonorarioEmpresa);
document.getElementById("ind-empresas-ajustadas-list").addEventListener("click", salvarAjusteHonorarioEmpresa);
// ---- Corrigir Responsável (popup: busca uma empresa, mostra os
// responsáveis por cada tipo/papel dela e permite trocar quem responde) ----
let corrigirEmpresaSelecionada = null; // codigo_empresa da empresa selecionada na busca
function renderCorrigirEmpresaResultados(termo) {
const box = document.getElementById("ind-corrigir-empresa-resultados");
const termoNormalizado = termo.trim().toLowerCase();
if (!termoNormalizado) {
box.hidden = true;
box.innerHTML = "";
return;
}
const grupos = empresasAgrupadasPorCodigo(() => true).filter(
(grupo) =>
grupo.codigo_empresa.toLowerCase().includes(termoNormalizado) ||
(grupo.nome_empresa || "").toLowerCase().includes(termoNormalizado)
);
box.hidden = false;
box.innerHTML = grupos.length
? grupos
.map(
(grupo) => `
<button type="button" class="checklist-item ind-corrigir-resultado" data-ind-corrigir-empresa="${pidIndEscapeAttr(grupo.codigo_empresa)}">
<span class="ind-esh-codigo">Código ${pidIndEscapeHtml(grupo.codigo_empresa)}</span>
<span class="ind-esh-nome">${pidIndEscapeHtml(grupo.nome_empresa || grupo.codigo_empresa)}</span>
</button>`
)
.join("")
: '<p class="checklist-empty">Nenhuma empresa encontrada.</p>';
}
// Uma <select> por responsável, excluindo o próprio colaborador atual da
// lista de opções (trocar "pra ele mesmo" não faz sentido).
function renderCorrigirEmpresaDetalhe() {
const container = document.getElementById("ind-corrigir-empresa-detalhe");
const grupo = corrigirEmpresaSelecionada
? empresasAgrupadasPorCodigo(() => true).find((g) => g.codigo_empresa === corrigirEmpresaSelecionada)
: null;
if (!grupo) {
corrigirEmpresaSelecionada = null;
container.innerHTML = "";
return;
}
container.innerHTML = `
<div class="ind-esh-item">
<div class="ind-esh-header">
<span class="ind-esh-codigo">Código ${pidIndEscapeHtml(grupo.codigo_empresa)}</span>
<span class="ind-esh-nome">${pidIndEscapeHtml(grupo.nome_empresa || grupo.codigo_empresa)}</span>
</div>
<ul class="ind-esh-responsaveis ind-corrigir-responsaveis">
${grupo.responsaveis
.map((r) => {
const opcoes = apuracaoAtual.colaboradores
.filter((c) => c.id !== r.colaboradorId)
.map((c) => `<option value="${c.id}">${pidIndEscapeHtml(c.nome)}</option>`)
.join("");
return `
<li>
<span>${pidIndEscapeHtml(r.nome)} — ${pidIndEscapeHtml(r.tipoLabel)}</span>
<span class="ind-corrigir-responsavel-form">
<select data-ind-corrigir-empresa-row="${r.empresaRowId}">
<option value="" selected disabled>Selecionar novo responsável…</option>
${opcoes}
</select>
<button type="button" class="btn-solid" data-ind-corrigir-salvar="${r.empresaRowId}">Salvar</button>
</span>
</li>`;
})
.join("")}
</ul>
</div>
`;
}
document.getElementById("ind-corrigir-responsavel-btn").addEventListener("click", () => {
corrigirEmpresaSelecionada = null;
document.getElementById("ind-corrigir-empresa-search").value = "";
document.getElementById("ind-corrigir-empresa-resultados").hidden = true;
document.getElementById("ind-corrigir-empresa-resultados").innerHTML = "";
document.getElementById("ind-corrigir-empresa-detalhe").innerHTML = "";
document.getElementById("ind-corrigir-responsavel-error").textContent = "";
document.getElementById("ind-corrigir-responsavel-modal").hidden = false;
});
document.getElementById("ind-corrigir-responsavel-close-btn").addEventListener("click", () => {
document.getElementById("ind-corrigir-responsavel-modal").hidden = true;
});
document.getElementById("ind-corrigir-responsavel-modal").addEventListener("click", (event) => {
if (event.target.id === "ind-corrigir-responsavel-modal") {
document.getElementById("ind-corrigir-responsavel-modal").hidden = true;
}
});
document.getElementById("ind-corrigir-empresa-search").addEventListener("input", (event) => {
renderCorrigirEmpresaResultados(event.target.value);
});
document.getElementById("ind-corrigir-empresa-resultados").addEventListener("click", (event) => {
const btn = event.target.closest("[data-ind-corrigir-empresa]");
if (!btn) return;
corrigirEmpresaSelecionada = btn.getAttribute("data-ind-corrigir-empresa");
document.getElementById("ind-corrigir-empresa-search").value = "";
document.getElementById("ind-corrigir-empresa-resultados").hidden = true;
document.getElementById("ind-corrigir-empresa-resultados").innerHTML = "";
renderCorrigirEmpresaDetalhe();
});
document.getElementById("ind-corrigir-empresa-detalhe").addEventListener("click", async (event) => {
const btn = event.target.closest("[data-ind-corrigir-salvar]");
if (!btn) return;
const errorEl = document.getElementById("ind-corrigir-responsavel-error");
errorEl.textContent = "";
const empresaRowId = Number(btn.getAttribute("data-ind-corrigir-salvar"));
const select = document.querySelector(`select[data-ind-corrigir-empresa-row="${empresaRowId}"]`);
const novoColaboradorId = Number(select.value);
if (!novoColaboradorId) {
errorEl.textContent = "Selecione o novo responsável na lista ao lado do botão Salvar.";
select.focus();
return;
}
await pidIndComBotaoOcupado(btn, "Salvando…", async () => {
try {
await pidTrocarResponsavelEmpresaIndicador(empresaRowId, novoColaboradorId);
await recarregarRevisao();
renderCorrigirEmpresaDetalhe();
} catch (e) {
errorEl.textContent = e.message;
}
});
});
function renderPctResumoHtml({ label, valor, ajustado, editarAttrs, recalcularAttrs, corClasse }) {
return `
<span class="ind-pct-field ind-pct-field--resumo">
<span class="ind-pct-field__principal">
${label}: <span class="ind-pct-value ${corClasse || ""}">${pidIndFormatPercentual(valor)}</span>
<button type="button" class="ind-pct-edit-btn" ${editarAttrs} title="Ajustar manualmente">✎</button>
</span>
${
ajustado
? `<span class="is-ajustado">ajustado manualmente</span>
<button type="button" class="ind-pct-reset-btn" ${recalcularAttrs} title="Recalcular automaticamente">↺</button>`
: ""
}
</span>
`;
}
// "Individual (peso 60%) + Grupo (peso 10%) + Departamento (peso 30%) =
// Total Indicador" — a composição que CLAUDE.md documenta (ver
// portal_api.indicadores.calculo.composicao_individual), montada no backend
// pra não duplicar a lógica de arredondamento/composição em JS. Cada nível
// fica verde/vermelho conforme foi atingido (100%) ou não — só o Total
// (renderPctResumoHtml) fica neutro, pra não repetir a mesma cor 4 vezes.
function renderComposicaoIndicadorHtml(composicao) {
const niveis = [
{ label: "Individual", dados: composicao.individual },
{ label: "Grupo", dados: composicao.grupo },
{ label: "Departamento", dados: composicao.departamento },
]
.map(({ label, dados }) => {
const corClasse = pidIndPercentualAtingido(dados.percentual) ? "is-atingido" : "is-nao-atingido";
return `${label}: <b class="${corClasse}">${pidIndFormatPercentual(dados.percentual)}</b> (peso ${pidIndFormatPeso(dados.peso)})`;
})
.join(" &middot; ");
return `<div class="ind-colaborador-card__composicao">${niveis}</div>`;
}
// Botão "i" ao lado do cabeçalho "Honorário Ajustado" — explica de onde
// vem esse valor (Honorário × % do Total Indicador do colaborador, ver
// calculo.py) mostrando já o percentual real usado nesta apuração, não só
// um texto genérico. Popover próprio (não reaproveita nenhum modal
// existente) — clique fora fecha, mesmo padrão de `.notif-dropdown`.
function renderHonorarioAjustadoInfoHtml(colaborador) {
const pct = pidIndFormatPercentual(colaborador.pct_individual);
return `
<span class="ind-info-wrap">
<button type="button" class="ind-info-btn" data-ind-info-toggle aria-label="O que é o Honorário Ajustado?" title="O que é o Honorário Ajustado?">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-5" stroke-linecap="round"/><path d="M12 8h.01" stroke-linecap="round"/></svg>
</button>
<span class="ind-info-popover" hidden>Honorário já proporcionalizado ao percentual do Total Indicador atingido pelo colaborador — nesse caso, ${pct}.</span>
</span>
`;
}
function renderColaboradorCard(colaborador) {
const card = document.createElement("div");
card.className = colaborador.validado ? "ind-colaborador-card is-validado" : "ind-colaborador-card";
const composicao = colaborador.composicao_individual;
const header = document.createElement("div");
header.className = "ind-colaborador-card__header";
header.innerHTML = `
<label class="ind-colaborador-card__validado" title="Marcar como validado">
<input type="checkbox" data-ind-colaborador-validado="${colaborador.id}" ${colaborador.validado ? "checked" : ""} />
</label>
<div>
<div class="ind-colaborador-card__nome">${pidIndEscapeHtml(colaborador.nome)}</div>
<div class="ind-colaborador-card__gerente">Gerente: ${pidIndEscapeHtml(colaborador.gerente || "Sem gerente definido")}</div>
</div>
${composicao ? renderComposicaoIndicadorHtml(composicao) : "<div></div>"}
<div class="ind-colaborador-card__pcts">
${renderPctResumoHtml({
label: "Total Indicador",
valor: colaborador.pct_individual,
ajustado: colaborador.pct_individual_ajustado_manualmente,
editarAttrs: `data-ind-pct-editar-individual="${colaborador.id}"`,
recalcularAttrs: `data-ind-pct-recalcular-individual="${colaborador.id}"`,
})}
</div>
<div class="ind-colaborador-card__total">${pidIndFormatMoeda(colaborador.valor_total)}</div>
`;
header.addEventListener("click", (event) => {
// .ind-colaborador-card__validado (o <label>, não só o <input>) — um
// clique na área do label fora do glifo do checkbox ainda dispara um
// evento de clique com target=label antes do clique sintético no
// input; sem excluir o label inteiro, esse primeiro clique passava
// pelo filtro (closest só olha pros data-attrs do <input>) e expandia/
// recolhia o card ao mesmo tempo que marcava/desmarcava o validado.
if (
event.target.closest(
"[data-ind-pct-editar-individual], [data-ind-pct-recalcular-individual], .ind-colaborador-card__validado"
)
)
return;
body.hidden = !body.hidden;
if (body.hidden) cardsExpandidos.delete(colaborador.id);
else cardsExpandidos.add(colaborador.id);
});
const body = document.createElement("div");
body.className = "ind-colaborador-card__body";
body.hidden = !cardsExpandidos.has(colaborador.id);
body.innerHTML = `
<p class="ind-section-title">Empresas</p>
<div class="pa-table-wrap">
<table class="pa-table ind-empresas-table">
<thead><tr><th>Código</th><th>Empresa</th><th>Honorário</th><th>Honorário Ajustado ${renderHonorarioAjustadoInfoHtml(colaborador)}</th><th>Tipo</th><th>Individual</th><th>Grupo</th><th>Departamento</th><th>Total</th></tr></thead>
<tbody>
${colaborador.empresas
.map(
(empresa) => `
<tr>
<td>${pidIndEscapeHtml(empresa.codigo_empresa)}</td>
<td>${pidIndEscapeHtml(empresa.nome_empresa || empresa.codigo_empresa)}${empresa.honorario_nao_encontrado ? ' <span class="status-pill status-pill--inativo">honorário não encontrado</span>' : ""}</td>
<td>
${
empresa.honorario_nao_encontrado
? `<input type="text" class="ind-empresa-honorario-input" name="honorario" inputmode="decimal" autocomplete="off" data-ind-empresa-honorario="${empresa.id}" placeholder="Ex.: 1.500,00…" />`
: `${pidIndFormatMoeda(empresa.honorario)}${
empresa.honorario_ajustado_manualmente
? ' <span class="is-ajustado">honorário ajustado manualmente</span>'
: ""
}`
}
</td>
<td>${pidIndFormatMoeda(empresa.honorario_ajustado)}</td>
<td>${pidIndEscapeHtml(empresa.tipo_label)}</td>
<td>${pidIndFormatMoeda(empresa.valor_individual)}</td>
<td>${pidIndFormatMoeda(empresa.valor_grupo)}</td>
<td>${pidIndFormatMoeda(empresa.valor_departamento)}</td>
<td>${pidIndFormatMoeda(empresa.valor_total)}</td>
</tr>`
)
.join("")}
</tbody>
</table>
</div>
${["individual", "grupo", "departamento"]
.map((grupo) => renderRespostasGrupoHtml(colaborador, grupo))
.join("")}
`;
card.appendChild(header);
card.appendChild(body);
return card;
}
// Grupo/Departamento não têm "não se aplica"/"não faz" real — o
// colaborador sempre faz parte de um grupo e do departamento (decisão
// explícita do usuário, ver CLAUDE.md) — então o select ali só oferece
// Sim/Não. Individual continua com as 4 opções (pode genuinamente não se
// aplicar ao papel do colaborador).
function pidIndOpcoesRespostaHtml(valorAtual, grupo) {
if (grupo === "individual") {
return `
<option value="SIM" ${valorAtual === "SIM" ? "selected" : ""}>Sim</option>
<option value="NAO" ${valorAtual === "NAO" ? "selected" : ""}>Não</option>
<option value="NAO_FAZ" ${valorAtual === "NAO_FAZ" ? "selected" : ""}>Não faz</option>
<option value="NAO_SE_APLICA" ${valorAtual === "NAO_SE_APLICA" ? "selected" : ""}>Não se aplica</option>
`;
}
return `
<option value="SIM" ${valorAtual === "SIM" ? "selected" : ""}>Sim</option>
<option value="NAO" ${valorAtual === "NAO" ? "selected" : ""}>Não</option>
`;
}
function renderRespostasGrupoHtml(colaborador, grupo) {
const respostas = pidIndRespostasDoGrupo(colaborador, grupo);
if (!respostas.length) return "";
const periodoAtual = pidIndPeriodoAtual(apuracaoAtual.competencia);
return `
<p class="ind-section-title">${PID_IND_GRUPO_LABELS[grupo]}</p>
<div class="pa-table-wrap">
<table class="pa-table ind-respostas-table">
<thead><tr><th class="ind-select-col"></th><th>Critério</th><th>Peso</th><th>% Atingido</th><th>Valor</th></tr></thead>
<tbody>
${respostas
.map((resposta) => {
const naoUtilizada = pidIndRespostaNaoUtilizada(resposta, colaborador, periodoAtual);
return `
<tr class="${naoUtilizada ? "is-apagado" : ""}">
<td class="ind-select-col"><input type="checkbox" data-ind-resposta-check="${resposta.id}" ${respostasSelecionadas.has(resposta.id) ? "checked" : ""} /></td>
<td>${pidIndEscapeHtml(resposta.criterio_nome)}${naoUtilizada ? ' <span class="ind-apagado-badge">apagado — não entra no cálculo</span>' : ""}</td>
<td>${pidIndFormatPercentual(resposta.criterio_peso)}</td>
<td>${resposta.percentual_calculado != null ? pidIndFormatPercentual(resposta.percentual_calculado) : "—"}</td>
<td>
<select data-ind-resposta-select="${resposta.id}">
${pidIndOpcoesRespostaHtml(resposta.valor, grupo)}
</select>
${resposta.ajustado_manualmente ? '<span class="is-ajustado">ajustado manualmente</span>' : ""}
</td>
</tr>`;
})
.join("")}
</tbody>
</table>
</div>
`;
}
function atualizarBarraLote() {
const bar = document.getElementById("ind-lote-bar");
bar.hidden = respostasSelecionadas.size === 0;
document.getElementById("ind-lote-count").textContent = `${respostasSelecionadas.size} selecionada(s)`;
}
// Preencher aqui o honorário de uma empresa com "honorário não encontrado"
// (código sem casamento na planilha de Honorários Por Cliente) zera esse
// flag e recalcula o colaborador (honorário ajustado, valores individual/
// grupo/departamento — ver IndicadorApuracaoEmpresaViewSet).
document.getElementById("ind-colaboradores-list").addEventListener("focusout", async (event) => {
const input = event.target.closest(".ind-empresa-honorario-input");
if (!input) return;
const errorEl = document.getElementById("ind-review-error");
errorEl.textContent = "";
if (!input.value.trim() || input.disabled) return;
const raw = pidIndParseDecimal(input.value, { milhar: true });
if (raw == null) {
errorEl.textContent = "Informe um honorário válido, no formato 1.234,56.";
return;
}
const empresaId = Number(input.getAttribute("data-ind-empresa-honorario"));
input.disabled = true;
try {
await pidAjustarHonorarioEmpresaIndicador(empresaId, raw);
await recarregarRevisao();
} catch (e) {
errorEl.textContent = e.message;
} finally {
input.disabled = false;
}
});
document.getElementById("ind-colaboradores-list").addEventListener("change", async (event) => {
// Checklist de revisão do RH — atualiza só o card clicado (sem recarregar
// a apuração inteira, ao contrário dos outros ajustes desta tela), pra
// não fechar outros cards já expandidos nem perder a posição da rolagem
// no meio de uma conferência com muitos colaboradores. Erro de rede
// reverte o checkbox e o estado em memória, mesmo padrão de outros
// toggles imediatos do app (ex. inativar usuário).
const validadoCheckbox = event.target.closest("[data-ind-colaborador-validado]");
if (validadoCheckbox) {
const id = Number(validadoCheckbox.getAttribute("data-ind-colaborador-validado"));
const novoValor = validadoCheckbox.checked;
const card = validadoCheckbox.closest(".ind-colaborador-card");
card.classList.toggle("is-validado", novoValor);
try {
await pidMarcarValidadoColaboradorIndicador(id, novoValor);
const colaborador = apuracaoAtual.colaboradores.find((c) => c.id === id);
if (colaborador) colaborador.validado = novoValor;
} catch (e) {
validadoCheckbox.checked = !novoValor;
card.classList.toggle("is-validado", !novoValor);
pidAlert(e.message, { titulo: "Não foi possível salvar" });
}
return;
}
const checkbox = event.target.closest("[data-ind-resposta-check]");
if (checkbox) {
const id = Number(checkbox.getAttribute("data-ind-resposta-check"));
if (checkbox.checked) respostasSelecionadas.add(id);
else respostasSelecionadas.delete(id);
atualizarBarraLote();
return;
}
const select = event.target.closest("[data-ind-resposta-select]");
if (select) {
const id = Number(select.getAttribute("data-ind-resposta-select"));
const errorEl = document.getElementById("ind-review-error");
errorEl.textContent = "";
select.disabled = true;
try {
await pidAtualizarRespostaIndicador(id, select.value);
await recarregarRevisao();
} catch (e) {
errorEl.textContent = e.message;
} finally {
select.disabled = false;
}
}
});
// Grupo/Departamento agora são preenchidos na tabela de metas no topo da
// página (ver renderMetasSection) — este modal genérico só cobre o
// percentual Individual, que continua por colaborador.
let ajusteColaboradorId = null;
const ajustePctModal = document.getElementById("ind-ajuste-pct-modal");
const PID_IND_CAMPOS_AJUSTE_PCT = ["ind-ajuste-pct-valor"];
let retratoAjustePct = null;
document.getElementById("ind-colaboradores-list").addEventListener("click", async (event) => {
const infoToggleBtn = event.target.closest("[data-ind-info-toggle]");
if (infoToggleBtn) {
const popover = infoToggleBtn.nextElementSibling;
const jaAberto = !popover.hidden;
document
.querySelectorAll("#ind-colaboradores-list .ind-info-popover")
.forEach((outro) => (outro.hidden = true));
popover.hidden = jaAberto;
return;
}
const editarIndividualBtn = event.target.closest("[data-ind-pct-editar-individual]");
if (editarIndividualBtn) {
ajusteColaboradorId = Number(editarIndividualBtn.getAttribute("data-ind-pct-editar-individual"));
const colaborador = apuracaoAtual.colaboradores.find((c) => c.id === ajusteColaboradorId);
document.getElementById("ind-ajuste-pct-titulo").textContent = `Ajustar percentual — Total Indicador (${colaborador.nome})`;
document.getElementById("ind-ajuste-pct-subtitle").textContent =
'O valor informado passa a valer só pra esse colaborador, até você reverter (botão "Recalcular automaticamente").';
document.getElementById("ind-ajuste-pct-valor").value = Number(colaborador.pct_individual).toFixed(2).replace(".", ",");
document.getElementById("ind-ajuste-pct-error").textContent = "";
retratoAjustePct = pidIndRetratoCampos(PID_IND_CAMPOS_AJUSTE_PCT);
ajustePctModal.hidden = false;
return;
}
const recalcularIndividualBtn = event.target.closest("[data-ind-pct-recalcular-individual]");
if (recalcularIndividualBtn) {
if (recalcularIndividualBtn.disabled) return;
recalcularIndividualBtn.disabled = true;
const colaboradorId = Number(recalcularIndividualBtn.getAttribute("data-ind-pct-recalcular-individual"));
const errorEl = document.getElementById("ind-review-error");
errorEl.textContent = "";
try {
await pidRecalcularColaboradorIndicador(colaboradorId);
await recarregarRevisao();
} catch (e) {
errorEl.textContent = e.message;
} finally {
recalcularIndividualBtn.disabled = false;
}
}
});
// Fecha o popover de "Honorário Ajustado" ao clicar fora dele — mesmo
// padrão de fechamento de `.notif-dropdown` (notifications.js).
document.addEventListener("click", (event) => {
if (event.target.closest(".ind-info-wrap")) return;
document.querySelectorAll(".ind-info-popover").forEach((popover) => {
popover.hidden = true;
});
});
async function fecharAjustePct() {
if (!(await pidIndConfirmarDescarte(retratoAjustePct, PID_IND_CAMPOS_AJUSTE_PCT))) return;
ajustePctModal.hidden = true;
}
document.getElementById("ind-ajuste-pct-cancel-btn").addEventListener("click", fecharAjustePct);
ajustePctModal.addEventListener("click", (event) => {
if (event.target === ajustePctModal) fecharAjustePct();
});
document.getElementById("ind-ajuste-pct-save-btn").addEventListener("click", async (event) => {
const errorEl = document.getElementById("ind-ajuste-pct-error");
errorEl.textContent = "";
const valor = pidIndParseDecimal(document.getElementById("ind-ajuste-pct-valor").value);
if (valor == null) {
pidIndErroNoCampo(errorEl, "Informe um percentual válido de 0 a 100, ex.: 85 ou 85,5.", "ind-ajuste-pct-valor");
return;
}
await pidIndComBotaoOcupado(event.currentTarget, "Salvando…", async () => {
try {
await pidAjustarPercentualColaboradorIndicador(ajusteColaboradorId, "pct_individual", valor);
ajustePctModal.hidden = true;
await recarregarRevisao();
} catch (e) {
errorEl.textContent = e.message;
}
});
});
document.getElementById("ind-lote-aplicar-btn").addEventListener("click", async (event) => {
const errorEl = document.getElementById("ind-review-error");
errorEl.textContent = "";
const valor = document.getElementById("ind-lote-valor").value;
await pidIndComBotaoOcupado(event.currentTarget, "Aplicando…", async () => {
try {
await pidAplicarEmLoteIndicador(Array.from(respostasSelecionadas), valor);
respostasSelecionadas.clear();
await recarregarRevisao();
} catch (e) {
errorEl.textContent = e.message;
}
});
});
// carregarLista() já mostra a mensagem de erro na própria tabela se falhar.
document.getElementById("ind-review-back-btn").addEventListener("click", async () => {
showView("list");
await carregarLista();
});
// ---- Ajuste Indicador em Lote (percentual Individual, entre colaboradores selecionados) ----
const loteGlobalModal = document.getElementById("ind-lote-global-modal");
const loteGlobalSelecionados = new Set();
let loteGlobalBusca = "";
let loteGlobalDepartamento = PID_IND_FILTRO_TODOS;
function loteGlobalColaboradoresFiltrados() {
const termo = loteGlobalBusca.trim().toLowerCase();
return apuracaoAtual.colaboradores.filter((colaborador) => {
if (termo && !colaborador.nome.toLowerCase().includes(termo)) return false;
if (
loteGlobalDepartamento !== PID_IND_FILTRO_TODOS &&
pidIndChaveDepartamento(colaborador.departamento) !== loteGlobalDepartamento
)
return false;
return true;
});
}
function renderLoteGlobalDepartamentoOptions() {
const select = document.getElementById("ind-lote-global-departamento");
const grupos = pidIndAgruparPorDepartamento(apuracaoAtual.colaboradores);
select.innerHTML =
`<option value="${PID_IND_FILTRO_TODOS}">Todos os departamentos</option>` +
grupos.map((grupo) => `<option value="${pidIndEscapeAttr(grupo.chave)}">${pidIndEscapeHtml(grupo.nome)}</option>`).join("");
select.value = loteGlobalDepartamento;
}
function renderLoteGlobalChecklist() {
const filtrados = loteGlobalColaboradoresFiltrados();
const box = document.getElementById("ind-lote-global-checklist");
box.innerHTML = filtrados.length
? filtrados
.map(
(colaborador) => `
<label class="checklist-item">
<input type="checkbox" data-ind-lote-global-colaborador="${colaborador.id}" ${loteGlobalSelecionados.has(colaborador.id) ? "checked" : ""} />
<span class="checklist-item__nome">${pidIndEscapeHtml(colaborador.nome)}</span>
</label>`
)
.join("")
: '<p class="checklist-empty">Nenhum colaborador encontrado.</p>';
const selectAll = document.getElementById("ind-lote-global-select-all");
const todosMarcados = filtrados.length > 0 && filtrados.every((colaborador) => loteGlobalSelecionados.has(colaborador.id));
const algunsMarcados = filtrados.some((colaborador) => loteGlobalSelecionados.has(colaborador.id));
selectAll.checked = todosMarcados;
selectAll.indeterminate = !todosMarcados && algunsMarcados;
selectAll.disabled = filtrados.length === 0;
}
document.getElementById("ind-lote-global-btn").addEventListener("click", () => {
loteGlobalSelecionados.clear();
loteGlobalBusca = "";
loteGlobalDepartamento = PID_IND_FILTRO_TODOS;
document.getElementById("ind-lote-global-search").value = "";
document.getElementById("ind-lote-global-error").textContent = "";
renderLoteGlobalDepartamentoOptions();
renderLoteGlobalChecklist();
loteGlobalModal.hidden = false;
});
document.getElementById("ind-lote-global-cancel-btn").addEventListener("click", () => {
loteGlobalModal.hidden = true;
});
loteGlobalModal.addEventListener("click", (event) => {
if (event.target === loteGlobalModal) loteGlobalModal.hidden = true;
});
document.getElementById("ind-lote-global-search").addEventListener("input", (event) => {
loteGlobalBusca = event.target.value;
renderLoteGlobalChecklist();
});
document.getElementById("ind-lote-global-departamento").addEventListener("change", (event) => {
loteGlobalDepartamento = event.target.value;
renderLoteGlobalChecklist();
});
document.getElementById("ind-lote-global-select-all").addEventListener("change", (event) => {
const filtrados = loteGlobalColaboradoresFiltrados();
if (event.target.checked) filtrados.forEach((colaborador) => loteGlobalSelecionados.add(colaborador.id));
else filtrados.forEach((colaborador) => loteGlobalSelecionados.delete(colaborador.id));
renderLoteGlobalChecklist();
});
document.getElementById("ind-lote-global-checklist").addEventListener("change", (event) => {
const checkbox = event.target.closest("[data-ind-lote-global-colaborador]");
if (!checkbox) return;
const id = Number(checkbox.getAttribute("data-ind-lote-global-colaborador"));
if (checkbox.checked) loteGlobalSelecionados.add(id);
else loteGlobalSelecionados.delete(id);
renderLoteGlobalChecklist();
});
// Grupo/Departamento são ajustados na tela de Metas (no início da
// revisão) — estes botões cobrem só o Indicador Individual, aplicando de
// uma vez a todos os colaboradores selecionados: "Sim" considera o
// indicador atingido (100%, ex.: a diretoria decide pagar integral pra
// quem não bateu a meta individual); "Reverter" desfaz qualquer ajuste
// manual anterior e volta pro cálculo automático. Reaproveita os mesmos
// endpoints por-colaborador do ajuste individual (não existe ajuste em
// lote dedicado pra isso no backend), disparando um PATCH/POST por
// colaborador em paralelo.
async function aplicarLoteGlobal(acao) {
const errorEl = document.getElementById("ind-lote-global-error");
errorEl.textContent = "";
if (loteGlobalSelecionados.size === 0) {
errorEl.textContent = "Selecione ao menos um colaborador.";
return;
}
// Trava os dois botões (Ajustar e Reverter) — só o clicado troca o texto.
const ajustarBtn = document.getElementById("ind-lote-global-ajustar-btn");
const reverterBtn = document.getElementById("ind-lote-global-reverter-btn");
const [clicado, outro] = acao === "sim" ? [ajustarBtn, reverterBtn] : [reverterBtn, ajustarBtn];
if (outro.disabled) return;
outro.disabled = true;
try {
await pidIndComBotaoOcupado(clicado, acao === "sim" ? "Ajustando…" : "Revertendo…", async () => {
try {
await Promise.all(
Array.from(loteGlobalSelecionados).map((colaboradorId) =>
acao === "sim"
? pidAjustarPercentualColaboradorIndicador(colaboradorId, "pct_individual", "100")
: pidRecalcularColaboradorIndicador(colaboradorId)
)
);
loteGlobalModal.hidden = true;
await recarregarRevisao();
} catch (e) {
errorEl.textContent = e.message;
}
});
} finally {
outro.disabled = false;
}
}
document.getElementById("ind-lote-global-ajustar-btn").addEventListener("click", () => aplicarLoteGlobal("sim"));
document.getElementById("ind-lote-global-reverter-btn").addEventListener("click", () => aplicarLoteGlobal("reverter"));
// ---- Gerar Recibos (modal: escolher quem recebe recibo desta vez — um
// colaborador só, alguns específicos, por departamento ou todos) ----
// Mesmo componente de busca+filtro por departamento+checklist do "Ajuste
// Indicador em Lote" acima, só que nasce com todo mundo já marcado (pra
// reproduzir o comportamento antigo — gerar pra todos — sem precisar
// marcar um por um; o RH desmarca quem não quer incluir desta vez).
const gerarRecibosModal = document.getElementById("ind-gerar-recibos-modal");
const gerarRecibosSelecionados = new Set();
let gerarRecibosBusca = "";
let gerarRecibosDepartamento = PID_IND_FILTRO_TODOS;
function gerarRecibosColaboradoresFiltrados() {
const termo = gerarRecibosBusca.trim().toLowerCase();
return apuracaoAtual.colaboradores.filter((colaborador) => {
if (termo && !colaborador.nome.toLowerCase().includes(termo)) return false;
if (
gerarRecibosDepartamento !== PID_IND_FILTRO_TODOS &&
pidIndChaveDepartamento(colaborador.departamento) !== gerarRecibosDepartamento
)
return false;
return true;
});
}
function renderGerarRecibosDepartamentoOptions() {
const select = document.getElementById("ind-gerar-recibos-departamento");
const grupos = pidIndAgruparPorDepartamento(apuracaoAtual.colaboradores);
select.innerHTML =
`<option value="${PID_IND_FILTRO_TODOS}">Todos os departamentos</option>` +
grupos.map((grupo) => `<option value="${pidIndEscapeAttr(grupo.chave)}">${pidIndEscapeHtml(grupo.nome)}</option>`).join("");
select.value = gerarRecibosDepartamento;
}
function renderGerarRecibosChecklist() {
const filtrados = gerarRecibosColaboradoresFiltrados();
const box = document.getElementById("ind-gerar-recibos-checklist");
box.innerHTML = filtrados.length
? filtrados
.map(
(colaborador) => `
<label class="checklist-item">
<input type="checkbox" data-ind-gerar-recibos-colaborador="${colaborador.id}" ${gerarRecibosSelecionados.has(colaborador.id) ? "checked" : ""} />
<span class="checklist-item__nome">${pidIndEscapeHtml(colaborador.nome)}</span>
</label>`
)
.join("")
: '<p class="checklist-empty">Nenhum colaborador encontrado.</p>';
const selectAll = document.getElementById("ind-gerar-recibos-select-all");
const todosMarcados = filtrados.length > 0 && filtrados.every((colaborador) => gerarRecibosSelecionados.has(colaborador.id));
const algunsMarcados = filtrados.some((colaborador) => gerarRecibosSelecionados.has(colaborador.id));
selectAll.checked = todosMarcados;
selectAll.indeterminate = !todosMarcados && algunsMarcados;
selectAll.disabled = filtrados.length === 0;
}
document.getElementById("ind-gerar-btn").addEventListener("click", () => {
gerarRecibosBusca = "";
gerarRecibosDepartamento = PID_IND_FILTRO_TODOS;
document.getElementById("ind-gerar-recibos-search").value = "";
document.getElementById("ind-gerar-recibos-error").textContent = "";
gerarRecibosSelecionados.clear();
apuracaoAtual.colaboradores.forEach((colaborador) => gerarRecibosSelecionados.add(colaborador.id));
renderGerarRecibosDepartamentoOptions();
renderGerarRecibosChecklist();
gerarRecibosModal.hidden = false;
});
document.getElementById("ind-gerar-recibos-cancel-btn").addEventListener("click", () => {
gerarRecibosModal.hidden = true;
});
gerarRecibosModal.addEventListener("click", (event) => {
if (event.target === gerarRecibosModal) gerarRecibosModal.hidden = true;
});
document.getElementById("ind-gerar-recibos-search").addEventListener("input", (event) => {
gerarRecibosBusca = event.target.value;
renderGerarRecibosChecklist();
});
document.getElementById("ind-gerar-recibos-departamento").addEventListener("change", (event) => {
gerarRecibosDepartamento = event.target.value;
renderGerarRecibosChecklist();
});
document.getElementById("ind-gerar-recibos-select-all").addEventListener("change", (event) => {
const filtrados = gerarRecibosColaboradoresFiltrados();
if (event.target.checked) filtrados.forEach((colaborador) => gerarRecibosSelecionados.add(colaborador.id));
else filtrados.forEach((colaborador) => gerarRecibosSelecionados.delete(colaborador.id));
renderGerarRecibosChecklist();
});
document.getElementById("ind-gerar-recibos-checklist").addEventListener("change", (event) => {
const checkbox = event.target.closest("[data-ind-gerar-recibos-colaborador]");
if (!checkbox) return;
const id = Number(checkbox.getAttribute("data-ind-gerar-recibos-colaborador"));
if (checkbox.checked) gerarRecibosSelecionados.add(id);
else gerarRecibosSelecionados.delete(id);
renderGerarRecibosChecklist();
});
document.getElementById("ind-gerar-recibos-confirm-btn").addEventListener("click", async (event) => {
const errorEl = document.getElementById("ind-gerar-recibos-error");
errorEl.textContent = "";
if (gerarRecibosSelecionados.size === 0) {
errorEl.textContent = "Selecione ao menos um colaborador.";
return;
}
await pidIndComBotaoOcupado(event.currentTarget, "Gerando…", async () => {
try {
await pidGerarRecibosIndicador(apuracaoAtual.id, Array.from(gerarRecibosSelecionados));
} catch (e) {
errorEl.textContent = e.message;
return;
}
gerarRecibosModal.hidden = true;
// O ZIP já foi baixado; falha só na atualização da tela não pode
// parecer falha na geração.
try {
await recarregarRevisao();
} catch (e) {
document.getElementById("ind-review-error").textContent =
"Recibos gerados, mas não foi possível atualizar a tela. Recarregue a página.";
}
await carregarLista();
});
});
// ---- Configurações ----
document.getElementById("ind-config-btn").addEventListener("click", async (event) => {
await pidIndComBotaoOcupado(event.currentTarget, "Carregando…", async () => {
try {
await carregarConfig({ resetarFiltros: true });
} catch (e) {
await pidAlert(e.message || PID_IND_MSG_ERRO_CARGA, { titulo: "Não foi possível abrir as Configurações" });
return;
}
showView("config");
window.scrollTo(0, 0);
});
});
document.getElementById("ind-config-back-btn").addEventListener("click", () => showView("list"));
document.getElementById("ind-config-tabs").addEventListener("click", (event) => {
const tabBtn = event.target.closest("[data-ind-config-tab]");
if (!tabBtn) return;
const tab = tabBtn.getAttribute("data-ind-config-tab");
document.querySelectorAll("#ind-config-tabs .pa-tab").forEach((btn) => btn.classList.toggle("is-active", btn === tabBtn));
document.querySelectorAll("[data-ind-config-panel]").forEach((panel) => {
panel.hidden = panel.getAttribute("data-ind-config-panel") !== tab;
});
});
let criterioFiltroDepartamento = PID_IND_FILTRO_TODOS;
let percentualFiltroDepartamento = PID_IND_FILTRO_TODOS;
// null = a busca de sugestões falhou (mostra erro, não "nenhum gerente novo").
let gerentesSugeridos = [];
// Nomes de `gerente` distintos da apuração mais recente (a lista já vem
// ordenada por `-competencia`/`-criado_em`, ver IndicadorApuracao.Meta) que
// ainda não estão em nenhum IndicadorDepartamentoGerente — poupa o RH de
// digitar os nomes de novo (arriscando um typo que faria a apuração futura
// não casar com o departamento certo) na hora de montar a relação
// gerente→departamento pela primeira vez.
async function carregarGerentesSugeridos() {
try {
const apuracoes = await pidFetchApuracoesIndicador();
if (!apuracoes.length) {
gerentesSugeridos = [];
return;
}
const ultima = await pidFetchApuracaoIndicador(apuracoes[0].id);
const nomesJaMapeados = new Set(departamentoGerentesConfig.map((g) => g.nome_gerente));
const distintos = new Set(
ultima.colaboradores.map((c) => c.gerente).filter((nome) => nome && !nomesJaMapeados.has(nome))
);
gerentesSugeridos = Array.from(distintos).sort((a, b) => a.localeCompare(b, "pt-BR"));
} catch (e) {
gerentesSugeridos = null;
}
}
// `resetarFiltros` só ao entrar em Configurações; depois de salvar/excluir
// algo, o filtro de departamento escolhido continua valendo.
async function carregarConfig({ resetarFiltros = false } = {}) {
[criterios, percentuais, departamentosConfig, departamentoGerentesConfig] = await Promise.all([
pidFetchCriteriosIndicador(),
pidFetchPercentuaisIndicador(),
pidFetchDepartamentosIndicador(),
pidFetchDepartamentoGerentesIndicador(),
]);
await carregarGerentesSugeridos();
const existe = (filtro) => filtro === PID_IND_FILTRO_TODOS || departamentosConfig.some((d) => d.id === filtro);
if (resetarFiltros || !existe(criterioFiltroDepartamento)) criterioFiltroDepartamento = PID_IND_FILTRO_TODOS;
if (resetarFiltros || !existe(percentualFiltroDepartamento)) percentualFiltroDepartamento = PID_IND_FILTRO_TODOS;
renderDepartamentoFiltroOptions("ind-criterio-departamento-filtro", criterioFiltroDepartamento);
renderDepartamentoFiltroOptions("ind-percentual-departamento-filtro", percentualFiltroDepartamento);
renderCriterios();
renderPercentuais();
renderDepartamentosConfig();
}
// Recarrega Configurações depois de uma ação já concluída; se só a
// recarga falhar, avisa sem dar a entender que a ação falhou.
async function recarregarConfigAposAcao() {
try {
await carregarConfig();
} catch (e) {
await pidAlert("A alteração foi salva, mas não foi possível atualizar a tela. Recarregue a página.", {
titulo: "Tela desatualizada",
});
}
}
// Reaproveitado pelos dois filtros (Critérios/Percentuais) — lista de
// departamentos cadastrados, com "Todos" no topo.
function renderDepartamentoFiltroOptions(selectId, valorAtual) {
const select = document.getElementById(selectId);
select.innerHTML =
`<option value="${PID_IND_FILTRO_TODOS}">Todos os departamentos</option>` +
departamentosConfig.map((d) => `<option value="${d.id}">${pidIndEscapeHtml(d.nome)}</option>`).join("");
select.value = String(valorAtual);
}
// Linha única de estado vazio nas tabelas de Configurações: distingue
// "nada cadastrado" de "o filtro escondeu tudo".
function pidIndLinhaVaziaHtml(colspan, totalCadastrado, textoVazio) {
const texto = totalCadastrado ? "Nenhum resultado para o filtro." : textoVazio;
return `<tr><td colspan="${colspan}" class="ind-tabela-vazia">${pidIndEscapeHtml(texto)}</td></tr>`;
}
function renderCriterios() {
const body = document.getElementById("ind-criterios-body");
const filtrados =
criterioFiltroDepartamento === PID_IND_FILTRO_TODOS
? criterios
: criterios.filter((c) => c.departamento === criterioFiltroDepartamento);
if (!filtrados.length) {
body.innerHTML = pidIndLinhaVaziaHtml(9, criterios.length, "Nenhum critério cadastrado.");
return;
}
body.innerHTML = filtrados
.map(
(criterio) => `
<tr>
<td>${pidIndEscapeHtml(criterio.nome)}</td>
<td>${pidIndEscapeHtml(criterio.departamento_nome)}</td>
<td>${pidIndEscapeHtml(PID_IND_GRUPO_LABELS[criterio.grupo] || criterio.grupo)}</td>
<td>${pidIndFormatPercentual(criterio.peso)}</td>
<td>${pidIndEscapeHtml(PID_IND_PERIODO_LABELS[criterio.periodo] || criterio.periodo)}</td>
<td>${criterio.papel_aplicavel ? PID_IND_TIPO_LABELS[criterio.papel_aplicavel] : "Qualquer"}</td>
<td>${pidIndEscapeHtml(PID_IND_CALCULO_LABELS[criterio.calculo_automatico] || criterio.calculo_automatico)}</td>
<td><span class="status-pill ${criterio.ativo ? "status-pill--ativo" : "status-pill--inativo"}">${criterio.ativo ? "Ativo" : "Inativo"}</span></td>
<td class="pa-row-actions">
<button type="button" class="icon-btn" data-ind-criterio-toggle="${criterio.id}" title="${criterio.ativo ? "Desativar" : "Ativar"}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v10" stroke-linecap="round"/><path d="M6 6a8 8 0 1 0 12 0" stroke-linecap="round"/></svg>
</button>
<button type="button" class="icon-btn" data-ind-criterio-excluir="${criterio.id}" title="Excluir">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</td>
</tr>`
)
.join("");
}
function renderPercentuais() {
const body = document.getElementById("ind-percentuais-body");
const filtrados =
percentualFiltroDepartamento === PID_IND_FILTRO_TODOS
? percentuais
: percentuais.filter((p) => p.departamento === percentualFiltroDepartamento);
if (!filtrados.length) {
body.innerHTML = pidIndLinhaVaziaHtml(8, percentuais.length, "Nenhum percentual cadastrado.");
return;
}
body.innerHTML = filtrados
.map(
(percentual) => `
<tr>
<td>${pidIndEscapeHtml(percentual.departamento_nome)}</td>
<td>${pidIndEscapeHtml(percentual.tipo_label)}</td>
<td>${pidIndFormatPercentual(percentual.percentual_individual)}</td>
<td>${pidIndFormatPercentual(percentual.percentual_grupo)}</td>
<td>${pidIndFormatPercentual(percentual.percentual_departamento)}</td>
<td>${pidIndFormatData(percentual.vigente_desde)}</td>
<td>${pidIndEscapeHtml(percentual.criado_por_nome || "-")}</td>
<td class="pa-row-actions">
<button type="button" class="icon-btn" data-ind-percentual-excluir="${percentual.id}" title="Excluir">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</td>
</tr>`
)
.join("");
}
// ---- Departamentos (cadastro + relação gerente→departamento) ----
// Substituiu o antigo "Apelidos de Setor": em vez de forçar o setor de um
// colaborador específico por nome, o departamento de cada colaborador vem
// do gerente dele (IndicadorDepartamentoGerente) — mantido manualmente por
// ora (ver CLAUDE.md), sem inferir mais nada da planilha.
function gerentesDoDepartamento(departamentoId) {
return departamentoGerentesConfig.filter((g) => g.departamento === departamentoId);
}
function renderDepartamentosConfig() {
const body = document.getElementById("ind-departamentos-body");
if (!departamentosConfig.length) {
body.innerHTML = pidIndLinhaVaziaHtml(4, 0, "Nenhum departamento cadastrado.");
return;
}
body.innerHTML = departamentosConfig
.map((departamento) => {
const gerentes = gerentesDoDepartamento(departamento.id);
const gerentesTexto = gerentes.length
? gerentes.map((g) => pidIndEscapeHtml(g.nome_gerente)).join(", ")
: "—";
return `
<tr>
<td>${pidIndEscapeHtml(departamento.nome)}</td>
<td>${gerentesTexto}</td>
<td><span class="status-pill ${departamento.ativo ? "status-pill--ativo" : "status-pill--inativo"}">${departamento.ativo ? "Ativo" : "Inativo"}</span></td>
<td class="pa-row-actions">
<button type="button" class="btn-outline" data-ind-departamento-gerentes="${departamento.id}">Gerenciar Gerentes</button>
<button type="button" class="icon-btn" data-ind-departamento-editar="${departamento.id}" title="Editar">
<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-4 12.5-12.5z" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button type="button" class="icon-btn" data-ind-departamento-excluir="${departamento.id}" title="Excluir">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</td>
</tr>`;
})
.join("");
}
document.getElementById("ind-criterios-body").addEventListener("click", async (event) => {
const toggleBtn = event.target.closest("[data-ind-criterio-toggle]");
if (toggleBtn) {
if (toggleBtn.disabled) return;
const id = Number(toggleBtn.getAttribute("data-ind-criterio-toggle"));
const criterio = criterios.find((c) => c.id === id);
toggleBtn.disabled = true;
try {
await pidAtualizarCriterioIndicador(id, { ativo: !criterio.ativo });
} catch (e) {
toggleBtn.disabled = false;
await pidAlert(e.message || "Tente de novo; se persistir, contate a Inovação.", {
titulo: criterio.ativo ? "Não foi possível desativar o critério" : "Não foi possível ativar o critério",
});
return;
}
await recarregarConfigAposAcao();
return;
}
const excluirBtn = event.target.closest("[data-ind-criterio-excluir]");
if (excluirBtn) {
if (excluirBtn.disabled) return;
if (!(await pidConfirm("Excluir este critério?", { perigoso: true }))) return;
excluirBtn.disabled = true;
try {
await pidExcluirCriterioIndicador(Number(excluirBtn.getAttribute("data-ind-criterio-excluir")));
} catch (e) {
excluirBtn.disabled = false;
await pidAlert(e.message || "Tente de novo; se persistir, contate a Inovação.", {
titulo: "Não foi possível excluir o critério",
});
return;
}
await recarregarConfigAposAcao();
}
});
document.getElementById("ind-criterio-departamento-filtro").addEventListener("change", (event) => {
criterioFiltroDepartamento = event.target.value === PID_IND_FILTRO_TODOS ? PID_IND_FILTRO_TODOS : Number(event.target.value);
renderCriterios();
});
document.getElementById("ind-percentuais-body").addEventListener("click", async (event) => {
const excluirBtn = event.target.closest("[data-ind-percentual-excluir]");
if (excluirBtn) {
if (excluirBtn.disabled) return;
if (!(await pidConfirm("Excluir este registro do histórico?", { perigoso: true }))) return;
excluirBtn.disabled = true;
try {
await pidExcluirPercentualIndicador(Number(excluirBtn.getAttribute("data-ind-percentual-excluir")));
} catch (e) {
excluirBtn.disabled = false;
await pidAlert(e.message || "Tente de novo; se persistir, contate a Inovação.", {
titulo: "Não foi possível excluir o percentual",
});
return;
}
await recarregarConfigAposAcao();
}
});
document.getElementById("ind-percentual-departamento-filtro").addEventListener("change", (event) => {
percentualFiltroDepartamento = event.target.value === PID_IND_FILTRO_TODOS ? PID_IND_FILTRO_TODOS : Number(event.target.value);
renderPercentuais();
});
// -- Modal: Critério --
const criterioModal = document.getElementById("ind-criterio-modal");
const PID_IND_CAMPOS_CRITERIO = [
"ind-criterio-departamento",
"ind-criterio-nome",
"ind-criterio-grupo",
"ind-criterio-peso",
"ind-criterio-periodo",
"ind-criterio-papel",
"ind-criterio-calculo",
"ind-criterio-limiar",
"ind-criterio-ativo",
];
let retratoCriterio = null;
document.getElementById("ind-criterio-add-btn").addEventListener("click", () => {
document.getElementById("ind-criterio-modal-title").textContent = "Adicionar Critério";
document.getElementById("ind-criterio-departamento").innerHTML = departamentosConfig
.map((d) => `<option value="${d.id}">${pidIndEscapeHtml(d.nome)}</option>`)
.join("");
if (criterioFiltroDepartamento !== PID_IND_FILTRO_TODOS) {
document.getElementById("ind-criterio-departamento").value = criterioFiltroDepartamento;
}
document.getElementById("ind-criterio-nome").value = "";
document.getElementById("ind-criterio-grupo").value = "individual";
document.getElementById("ind-criterio-peso").value = "";
document.getElementById("ind-criterio-periodo").value = "todos";
document.getElementById("ind-criterio-papel").value = "";
document.getElementById("ind-criterio-calculo").value = "";
document.getElementById("ind-criterio-limiar").value = "90";
document.getElementById("ind-criterio-ativo").checked = true;
document.getElementById("ind-criterio-error").textContent = "";
retratoCriterio = pidIndRetratoCampos(PID_IND_CAMPOS_CRITERIO);
criterioModal.hidden = false;
});
async function fecharCriterioModal() {
if (!(await pidIndConfirmarDescarte(retratoCriterio, PID_IND_CAMPOS_CRITERIO))) return;
criterioModal.hidden = true;
}
document.getElementById("ind-criterio-cancel-btn").addEventListener("click", fecharCriterioModal);
criterioModal.addEventListener("click", (event) => {
if (event.target === criterioModal) fecharCriterioModal();
});
document.getElementById("ind-criterio-save-btn").addEventListener("click", async (event) => {
const saveBtn = event.currentTarget;
const errorEl = document.getElementById("ind-criterio-error");
errorEl.textContent = "";
const nome = document.getElementById("ind-criterio-nome").value.trim();
const pesoTexto = document.getElementById("ind-criterio-peso").value;
const peso = pidIndParseDecimal(pesoTexto);
const limiarTexto = document.getElementById("ind-criterio-limiar").value;
const limiar = limiarTexto.trim() ? pidIndParseDecimal(limiarTexto) : "90";
if (!document.getElementById("ind-criterio-departamento").value) {
pidIndErroNoCampo(errorEl, "Cadastre um departamento antes (aba Departamentos).", "ind-criterio-departamento");
return;
}
if (!nome) {
pidIndErroNoCampo(errorEl, "Informe o nome do critério.", "ind-criterio-nome");
return;
}
if (!pesoTexto.trim()) {
pidIndErroNoCampo(errorEl, "Informe o peso do critério (0 a 100, ex.: 60).", "ind-criterio-peso");
return;
}
if (peso == null) {
pidIndErroNoCampo(errorEl, "Peso inválido. Use um número de 0 a 100, ex.: 60 ou 12,5.", "ind-criterio-peso");
return;
}
if (limiar == null) {
pidIndErroNoCampo(errorEl, 'Limiar para "Sim" inválido. Use um número de 0 a 100, ex.: 90.', "ind-criterio-limiar");
return;
}
await pidIndComBotaoOcupado(saveBtn, "Salvando…", async () => {
try {
await pidCriarCriterioIndicador({
departamento: Number(document.getElementById("ind-criterio-departamento").value),
nome,
grupo: document.getElementById("ind-criterio-grupo").value,
peso,
periodo: document.getElementById("ind-criterio-periodo").value,
papel_aplicavel: document.getElementById("ind-criterio-papel").value,
calculo_automatico: document.getElementById("ind-criterio-calculo").value,
limiar_percentual: limiar,
ativo: document.getElementById("ind-criterio-ativo").checked,
});
} catch (e) {
errorEl.textContent = e.message;
return;
}
criterioModal.hidden = true;
await recarregarConfigAposAcao();
});
});
// -- Modal: Percentual por tipo --
const percentualModal = document.getElementById("ind-percentual-modal");
const PID_IND_CAMPOS_PERCENTUAL = [
"ind-percentual-departamento",
"ind-percentual-tipo",
"ind-percentual-individual",
"ind-percentual-grupo",
"ind-percentual-departamento-valor",
"ind-percentual-vigente",
];
let retratoPercentual = null;
document.getElementById("ind-percentual-add-btn").addEventListener("click", () => {
document.getElementById("ind-percentual-departamento").innerHTML = departamentosConfig
.map((d) => `<option value="${d.id}">${pidIndEscapeHtml(d.nome)}</option>`)
.join("");
if (percentualFiltroDepartamento !== PID_IND_FILTRO_TODOS) {
document.getElementById("ind-percentual-departamento").value = percentualFiltroDepartamento;
}
document.getElementById("ind-percentual-tipo").value = "1";
document.getElementById("ind-percentual-individual").value = "";
document.getElementById("ind-percentual-grupo").value = "20";
document.getElementById("ind-percentual-departamento-valor").value = "20";
document.getElementById("ind-percentual-vigente").value = pidIndHojeIso();
document.getElementById("ind-percentual-error").textContent = "";
retratoPercentual = pidIndRetratoCampos(PID_IND_CAMPOS_PERCENTUAL);
percentualModal.hidden = false;
});
async function fecharPercentualModal() {
if (!(await pidIndConfirmarDescarte(retratoPercentual, PID_IND_CAMPOS_PERCENTUAL))) return;
percentualModal.hidden = true;
}
document.getElementById("ind-percentual-cancel-btn").addEventListener("click", fecharPercentualModal);
percentualModal.addEventListener("click", (event) => {
if (event.target === percentualModal) fecharPercentualModal();
});
document.getElementById("ind-percentual-save-btn").addEventListener("click", async (event) => {
const saveBtn = event.currentTarget;
const errorEl = document.getElementById("ind-percentual-error");
errorEl.textContent = "";
if (!document.getElementById("ind-percentual-departamento").value) {
pidIndErroNoCampo(errorEl, "Cadastre um departamento antes (aba Departamentos).", "ind-percentual-departamento");
return;
}
// Cada campo percentual validado na ordem da tela, com o cursor levado
// ao primeiro que estiver vazio ou inválido.
const camposPercentuais = [
{ id: "ind-percentual-individual", rotulo: "% Individual", exemplo: "1,42" },
{ id: "ind-percentual-grupo", rotulo: "% Grupo", exemplo: "20" },
{ id: "ind-percentual-departamento-valor", rotulo: "% Departamento", exemplo: "20" },
];
const valores = {};
for (const campo of camposPercentuais) {
const texto = document.getElementById(campo.id).value;
if (!texto.trim()) {
pidIndErroNoCampo(errorEl, `Informe o ${campo.rotulo} (ex.: ${campo.exemplo}).`, campo.id);
return;
}
const valor = pidIndParseDecimal(texto);
if (valor == null) {
pidIndErroNoCampo(errorEl, `${campo.rotulo} inválido. Use um número com vírgula decimal, ex.: ${campo.exemplo}.`, campo.id);
return;
}
valores[campo.id] = valor;
}
const vigente = document.getElementById("ind-percentual-vigente").value;
if (!vigente) {
pidIndErroNoCampo(errorEl, 'Informe a data em "Vigente desde".', "ind-percentual-vigente");
return;
}
await pidIndComBotaoOcupado(saveBtn, "Salvando…", async () => {
try {
await pidCriarPercentualIndicador({
departamento: Number(document.getElementById("ind-percentual-departamento").value),
tipo: document.getElementById("ind-percentual-tipo").value,
percentual_individual: valores["ind-percentual-individual"],
percentual_grupo: valores["ind-percentual-grupo"],
percentual_departamento: valores["ind-percentual-departamento-valor"],
vigente_desde: vigente,
});
} catch (e) {
errorEl.textContent = e.message;
return;
}
percentualModal.hidden = true;
await recarregarConfigAposAcao();
});
});
// -- Modal: Departamento --
const departamentoModal = document.getElementById("ind-departamento-modal");
const PID_IND_CAMPOS_DEPARTAMENTO = ["ind-departamento-nome", "ind-departamento-ativo"];
let departamentoEditandoId = null;
let retratoDepartamento = null;
function abrirDepartamentoModal(departamento) {
departamentoEditandoId = departamento ? departamento.id : null;
document.getElementById("ind-departamento-modal-title").textContent = departamento
? "Editar Departamento"
: "Adicionar Departamento";
document.getElementById("ind-departamento-nome").value = departamento ? departamento.nome : "";
document.getElementById("ind-departamento-ativo").checked = departamento ? departamento.ativo : true;
document.getElementById("ind-departamento-error").textContent = "";
retratoDepartamento = pidIndRetratoCampos(PID_IND_CAMPOS_DEPARTAMENTO);
departamentoModal.hidden = false;
}
async function fecharDepartamentoModal() {
if (!(await pidIndConfirmarDescarte(retratoDepartamento, PID_IND_CAMPOS_DEPARTAMENTO))) return;
departamentoModal.hidden = true;
}
document.getElementById("ind-departamento-add-btn").addEventListener("click", () => abrirDepartamentoModal(null));
document.getElementById("ind-departamento-cancel-btn").addEventListener("click", fecharDepartamentoModal);
departamentoModal.addEventListener("click", (event) => {
if (event.target === departamentoModal) fecharDepartamentoModal();
});
document.getElementById("ind-departamento-save-btn").addEventListener("click", async (event) => {
const saveBtn = event.currentTarget;
const errorEl = document.getElementById("ind-departamento-error");
errorEl.textContent = "";
const nome = document.getElementById("ind-departamento-nome").value.trim();
if (!nome) {
pidIndErroNoCampo(errorEl, "Informe o nome do departamento.", "ind-departamento-nome");
return;
}
const ativo = document.getElementById("ind-departamento-ativo").checked;
await pidIndComBotaoOcupado(saveBtn, "Salvando…", async () => {
try {
if (departamentoEditandoId) {
await pidAtualizarDepartamentoIndicador(departamentoEditandoId, { nome, ativo });
} else {
await pidCriarDepartamentoIndicador({ nome, ativo });
}
} catch (e) {
errorEl.textContent = e.message;
return;
}
departamentoModal.hidden = true;
await recarregarConfigAposAcao();
});
});
// -- Modal: Gerenciar Gerentes (relação gerente→departamento) --
const departamentoGerentesModal = document.getElementById("ind-departamento-gerentes-modal");
let departamentoGerentesAtualId = null;
function renderDepartamentoGerentesLista() {
const box = document.getElementById("ind-departamento-gerentes-lista");
const gerentes = gerentesDoDepartamento(departamentoGerentesAtualId);
box.innerHTML = gerentes.length
? gerentes
.map(
(g) => `
<div class="checklist-item">
<span class="checklist-item__nome">${pidIndEscapeHtml(g.nome_gerente)}</span>
<button type="button" class="icon-btn" data-ind-departamento-gerente-remover="${g.id}" title="Remover">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>`
)
.join("")
: '<p class="checklist-empty">Nenhum gerente cadastrado ainda.</p>';
}
// Um clique já relaciona o nome sugerido a este departamento — o RH só
// precisa reconhecer o nome (visto na última apuração) e clicar, em vez de
// digitar de novo (ver carregarGerentesSugeridos). Se a busca de
// sugestões falhou (gerentesSugeridos === null), diz isso em vez de
// "nenhum gerente novo", que daria a entender que não falta ninguém.
function renderDepartamentoGerentesSugestoes() {
const box = document.getElementById("ind-departamento-gerentes-sugestoes");
if (gerentesSugeridos === null) {
box.innerHTML = `<p class="checklist-empty ind-checklist-erro">Não foi possível carregar as sugestões. Recarregue a página; se persistir, contate a Inovação. O campo abaixo continua disponível.</p>`;
return;
}
box.innerHTML = gerentesSugeridos.length
? gerentesSugeridos
.map(
(nome) => `
<button type="button" class="checklist-item ind-departamento-gerente-sugestao" data-ind-departamento-gerente-sugerido="${pidIndEscapeAttr(nome)}">
<span class="checklist-item__nome">${pidIndEscapeHtml(nome)}</span>
<span class="ind-departamento-gerente-sugestao__acao">+ Adicionar</span>
</button>`
)
.join("")
: '<p class="checklist-empty">Nenhum gerente novo encontrado na última apuração.</p>';
}
// Depois de incluir/remover um gerente: recarrega a relação e as
// sugestões. Falha só nessa recarga não desfaz o que já foi salvo.
async function atualizarGerentesDoDepartamento(errorEl) {
try {
departamentoGerentesConfig = await pidFetchDepartamentoGerentesIndicador();
} catch (e) {
errorEl.textContent = "A alteração foi salva, mas não foi possível atualizar a lista. Recarregue a página.";
return;
}
await carregarGerentesSugeridos();
renderDepartamentoGerentesLista();
renderDepartamentoGerentesSugestoes();
renderDepartamentosConfig();
}
document.getElementById("ind-departamentos-body").addEventListener("click", async (event) => {
const gerentesBtn = event.target.closest("[data-ind-departamento-gerentes]");
if (gerentesBtn) {
departamentoGerentesAtualId = Number(gerentesBtn.getAttribute("data-ind-departamento-gerentes"));
const departamento = departamentosConfig.find((d) => d.id === departamentoGerentesAtualId);
document.getElementById("ind-departamento-gerentes-modal-title").textContent = `Gerenciar Gerentes — ${departamento.nome}`;
document.getElementById("ind-departamento-gerente-novo").value = "";
document.getElementById("ind-departamento-gerentes-error").textContent = "";
renderDepartamentoGerentesLista();
renderDepartamentoGerentesSugestoes();
departamentoGerentesModal.hidden = false;
return;
}
const editarBtn = event.target.closest("[data-ind-departamento-editar]");
if (editarBtn) {
abrirDepartamentoModal(
departamentosConfig.find((d) => d.id === Number(editarBtn.getAttribute("data-ind-departamento-editar")))
);
return;
}
const excluirBtn = event.target.closest("[data-ind-departamento-excluir]");
if (excluirBtn) {
if (excluirBtn.disabled) return;
if (
!(await pidConfirm("Excluir este departamento? Os critérios e percentuais dele também serão excluídos.", {
perigoso: true,
}))
)
return;
excluirBtn.disabled = true;
try {
await pidExcluirDepartamentoIndicador(Number(excluirBtn.getAttribute("data-ind-departamento-excluir")));
} catch (e) {
excluirBtn.disabled = false;
await pidAlert(e.message || "Tente de novo; se persistir, contate a Inovação.", {
titulo: "Não foi possível excluir o departamento",
});
return;
}
await recarregarConfigAposAcao();
}
});
// Fechar o popup com um nome digitado e ainda não adicionado pede
// confirmação; inclusões/remoções já feitas foram salvas na hora.
async function fecharDepartamentoGerentesModal() {
if (document.getElementById("ind-departamento-gerente-novo").value.trim()) {
const sair = await pidConfirm("Sair sem salvar as alterações?", {
titulo: "Sair sem salvar",
textoConfirmar: "Sair sem salvar",
perigoso: true,
});
if (!sair) return;
}
departamentoGerentesModal.hidden = true;
}
document.getElementById("ind-departamento-gerentes-close-btn").addEventListener("click", fecharDepartamentoGerentesModal);
departamentoGerentesModal.addEventListener("click", (event) => {
if (event.target === departamentoGerentesModal) fecharDepartamentoGerentesModal();
});
document.getElementById("ind-departamento-gerente-add-btn").addEventListener("click", async (event) => {
const addBtn = event.currentTarget;
const errorEl = document.getElementById("ind-departamento-gerentes-error");
errorEl.textContent = "";
const nomeGerente = document.getElementById("ind-departamento-gerente-novo").value.trim();
if (!nomeGerente) {
pidIndErroNoCampo(errorEl, "Informe o nome do gerente, exatamente como aparece na planilha.", "ind-departamento-gerente-novo");
return;
}
await pidIndComBotaoOcupado(addBtn, "Adicionando…", async () => {
try {
await pidCriarDepartamentoGerenteIndicador({ departamento: departamentoGerentesAtualId, nome_gerente: nomeGerente });
} catch (e) {
errorEl.textContent = e.message;
return;
}
document.getElementById("ind-departamento-gerente-novo").value = "";
await atualizarGerentesDoDepartamento(errorEl);
});
});
document.getElementById("ind-departamento-gerentes-sugestoes").addEventListener("click", async (event) => {
const sugestaoBtn = event.target.closest("[data-ind-departamento-gerente-sugerido]");
if (!sugestaoBtn || sugestaoBtn.disabled) return;
const errorEl = document.getElementById("ind-departamento-gerentes-error");
errorEl.textContent = "";
const acaoEl = sugestaoBtn.querySelector(".ind-departamento-gerente-sugestao__acao");
sugestaoBtn.disabled = true;
if (acaoEl) acaoEl.textContent = "Adicionando…";
try {
await pidCriarDepartamentoGerenteIndicador({
departamento: departamentoGerentesAtualId,
nome_gerente: sugestaoBtn.getAttribute("data-ind-departamento-gerente-sugerido"),
});
} catch (e) {
sugestaoBtn.disabled = false;
if (acaoEl) acaoEl.textContent = "+ Adicionar";
errorEl.textContent = e.message;
return;
}
await atualizarGerentesDoDepartamento(errorEl);
});
document.getElementById("ind-departamento-gerentes-lista").addEventListener("click", async (event) => {
const removerBtn = event.target.closest("[data-ind-departamento-gerente-remover]");
if (!removerBtn || removerBtn.disabled) return;
const errorEl = document.getElementById("ind-departamento-gerentes-error");
errorEl.textContent = "";
const gerenteId = Number(removerBtn.getAttribute("data-ind-departamento-gerente-remover"));
const gerente = departamentoGerentesConfig.find((g) => g.id === gerenteId);
const confirmado = await pidConfirm(
`Remover "${gerente ? gerente.nome_gerente : "este gerente"}" deste departamento? Apurações novas deixam de associar os colaboradores dele a este departamento.`,
{ titulo: "Remover gerente", textoConfirmar: "Remover", perigoso: true }
);
if (!confirmado) return;
removerBtn.disabled = true;
try {
await pidExcluirDepartamentoGerenteIndicador(gerenteId);
} catch (e) {
removerBtn.disabled = false;
errorEl.textContent = e.message;
return;
}
await atualizarGerentesDoDepartamento(errorEl);
});
await carregarLista();
});