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 = ` ${pidIndFormatCompetencia(apuracao.competencia)} ${apuracao.total_colaboradores} ${PID_IND_STATUS_LABELS[apuracao.status] || apuracao.status} ${pidIndEscapeHtml(apuracao.criado_por_nome || "-")} ${pidIndFormatData(apuracao.criado_em)} `; 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 (!window.confirm("Excluir esta apuração? Essa ação não pode ser desfeita.")) 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:`; } 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 '

Nenhum critério cadastrado pra este nível.

'; } 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 ` ${ajustado ? 'ajustado manualmente' : ""} ${ ajustado ? `` : "" } `; } 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 = ""; 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); } apuracaoAtual = await pidFetchApuracaoIndicador(apuracaoAtual.id); renderRevisao(); } catch (e) { errorEl.textContent = e.message; } }); 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) { errorEl.textContent = ""; try { await pidRecalcularDepartamentoIndicador( apuracaoAtual.id, Number(recalcularDepartamentoBtn.getAttribute("data-ind-meta-recalcular-departamento")) ); apuracaoAtual = await pidFetchApuracaoIndicador(apuracaoAtual.id); renderRevisao(); } catch (e) { errorEl.textContent = e.message; } return; } const recalcularGrupoBtn = event.target.closest("[data-ind-meta-recalcular-grupo]"); if (recalcularGrupoBtn) { errorEl.textContent = ""; try { await pidRecalcularGrupoIndicador(apuracaoAtual.id, recalcularGrupoBtn.getAttribute("data-ind-meta-recalcular-grupo")); apuracaoAtual = await pidFetchApuracaoIndicador(apuracaoAtual.id); renderRevisao(); } catch (e) { errorEl.textContent = e.message; } } }); // ---- 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) => ``; 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 `
Código ${pidIndEscapeHtml(grupo.codigo_empresa)} ${pidIndEscapeHtml(grupo.nome_empresa || grupo.codigo_empresa)}
`; } // 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))) .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 = input.value.replace(",", ".").trim(); if (!valor || Number.isNaN(Number(valor))) { errorEl.textContent = "Informe um honorário válido."; return; } try { await pidAjustarHonorarioEmpresaEmLoteIndicador(apuracaoAtual.id, btn.getAttribute("data-ind-esh-salvar"), valor); apuracaoAtual = await pidFetchApuracaoIndicador(apuracaoAtual.id); renderRevisao(); 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) => ` ` ) .join("") : '

Nenhuma empresa encontrada.

'; } // Uma ${opcoes} `; }) .join("")} `; } 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 um colaborador."; return; } try { await pidTrocarResponsavelEmpresaIndicador(empresaRowId, novoColaboradorId); apuracaoAtual = await pidFetchApuracaoIndicador(apuracaoAtual.id); renderRevisao(); renderCorrigirEmpresaDetalhe(); } catch (e) { errorEl.textContent = e.message; } }); function renderPctResumoHtml({ label, valor, ajustado, editarAttrs, recalcularAttrs, corClasse }) { return ` ${label}: ${pidIndFormatPercentual(valor)} ${ ajustado ? `ajustado manualmente ` : "" } `; } // "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}: ${pidIndFormatPercentual(dados.percentual)} (peso ${pidIndFormatPeso(dados.peso)})`; }) .join(" · "); return `
${niveis}
`; } // 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 ` `; } 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 = `
${pidIndEscapeHtml(colaborador.nome)}
Gerente: ${pidIndEscapeHtml(colaborador.gerente || "Sem gerente definido")}
${composicao ? renderComposicaoIndicadorHtml(composicao) : "
"}
${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}"`, })}
${pidIndFormatMoeda(colaborador.valor_total)}
`; header.addEventListener("click", (event) => { // .ind-colaborador-card__validado (o