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" });
}
function pidIndFormatPercentual(valor) {
return `${Number(valor || 0).toFixed(2)}%`;
}
// 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 `${parseFloat(Number(valor || 0).toFixed(2))}%`;
}
// 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 "";
return new Date(iso).toLocaleDateString("pt-BR");
}
function pidIndEscapeHtml(texto) {
const div = document.createElement("div");
div.textContent = texto == null ? "" : String(texto);
return div.innerHTML;
}
function pidIndEscapeAttr(texto) {
return String(texto == null ? "" : texto)
.replace(/&/g, "&")
.replace(/"/g, """);
}
// "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 ----
async function carregarLista() {
const apuracoes = await pidFetchApuracoesIndicador();
const body = document.getElementById("ind-list-body");
const empty = document.getElementById("ind-list-empty");
body.innerHTML = "";
empty.hidden = apuracoes.length > 0;
apuracoes.forEach((apuracao) => {
const tr = document.createElement("tr");
tr.innerHTML = `
`;
body.appendChild(tr);
});
}
document.getElementById("ind-list-body").addEventListener("click", async (event) => {
const abrirBtn = event.target.closest("[data-ind-abrir]");
if (abrirBtn) {
await abrirRevisao(Number(abrirBtn.getAttribute("data-ind-abrir")));
return;
}
const excluirBtn = event.target.closest("[data-ind-excluir]");
if (excluirBtn) {
if (!(await pidConfirm("Excluir esta apuração? Essa ação não pode ser desfeita.", { perigoso: true }))) return;
await pidExcluirApuracaoIndicador(Number(excluirBtn.getAttribute("data-ind-excluir")));
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);
});
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 = "";
showView("form");
});
document.getElementById("ind-form-back-btn").addEventListener("click", () => showView("list"));
document.getElementById("ind-form-cancel-btn").addEventListener("click", () => showView("list"));
document.getElementById("ind-form-submit-btn").addEventListener("click", async () => {
const errorEl = document.getElementById("ind-form-error");
errorEl.textContent = "";
const competenciaIso = pidIndCompetenciaParaIso(document.getElementById("ind-form-competencia").value);
const tareffa = document.getElementById("ind-form-tareffa").files[0];
const honorarios = document.getElementById("ind-form-honorarios").files[0];
if (!competenciaIso || !tareffa || !honorarios) {
errorEl.textContent = "Preencha a competência (MM/AAAA) e anexe as duas planilhas.";
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");
submitBtn.disabled = true;
try {
const apuracao = await pidCriarApuracaoIndicador(formData);
apuracaoAtual = apuracao;
renderRevisao();
showView("review");
await carregarLista();
} catch (e) {
errorEl.textContent = e.message;
} finally {
submitBtn.disabled = false;
}
});
// ---- Revisão ----
const PID_IND_FILTRO_TODOS = "__todos__";
let filtroDepartamento = PID_IND_FILTRO_TODOS;
async function abrirRevisao(id) {
apuracaoAtual = await pidFetchApuracaoIndicador(id);
respostasSelecionadas.clear();
filtroDepartamento = PID_IND_FILTRO_TODOS;
renderRevisao();
showView("review");
}
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 = `Casos pra confirmar manualmente:
${apuracao.avisos
.map((aviso) => `
${pidIndEscapeHtml(aviso)}
`)
.join("")}
`;
} 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 '
`;
}
// 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 `