const PID_DC_STATUS_LABELS = { revisao: "Em revisão", concluida: "Concluída" };
const PID_DC_SEVERIDADE_LABELS = { alta: "Alta", media: "Média", baixa: "Baixa" };
const PID_DC_ACHADO_STATUS_LABELS = { pendente: "Pendente", tratado: "Tratado", ignorado: "Ignorado" };
// Ordem de exibição do donut/legenda de severidade no resumo — mesmas cores
// já usadas pelos badges (.dc-badge--alta/--media/--baixa, dashboard-contabil.css).
const PID_DC_SEVERIDADE_LISTA = ["alta", "media", "baixa"];
const PID_DC_SEVERIDADE_CORES = { alta: "var(--danger)", media: "var(--gold)", baixa: "rgb(var(--slate-rgb))" };
// Mesmas 9 regras/chaves de regras.py (REGRAS) — enumeradas aqui pra sempre
// mostrar as 9 categorias no resumo, mesmo as que não geraram achado nesta
// apuração (mesmo espírito do "Nenhum registro encontrado" do sistema externo
// que inspirou esta visão). A 10ª (`item_removido_reprocessamento`) não é uma
// regra sobre o PDF: nasce na sincronização do reprocessamento, quando uma
// conta/linha some do arquivo novo (ver regras.achados_itens_removidos()).
const PID_DC_REGRAS = [
{ chave: "balanceamento_ativo_passivo", label: "Balanceamento Ativo x Passivo" },
{ chave: "debito_credito_divergente", label: "Débito x Crédito" },
{ chave: "saldo_negativo_caixa", label: "Caixa Negativo" },
{ chave: "saldo_sinal_invertido", label: "Sinal de Saldo Invertido" },
{ chave: "lucro_balancete_diverge_dre", label: "Lucro Balancete x DRE" },
{ chave: "conta_transitoria_com_saldo", label: "Contas Transitórias" },
{ chave: "conta_deveria_zerar", label: "Contas que Deveriam Zerar" },
{ chave: "descricao_generica", label: "Descrição Genérica" },
{ chave: "variacao_atipica_dre", label: "Variação atípica na DRE - Análise Vertical" },
{ chave: "item_removido_reprocessamento", label: "Contas e Linhas Removidas" },
];
// Agrupamento temático das 9 regras acima pro resumo da aba Observações —
// evita 9 cards soltos no grid (poluído visualmente com uma tabela por
// conta em cada um); cada grupo vira 1 card com as regras dele listadas por
// linha (label + contagem), clique numa linha com contagem > 0 continua
// isolando achado.regra na lista abaixo, igual antes.
const PID_DC_GRUPOS = [
{
label: "Divergências de Saldo",
regras: [
"balanceamento_ativo_passivo",
"debito_credito_divergente",
"saldo_negativo_caixa",
"saldo_sinal_invertido",
"lucro_balancete_diverge_dre",
],
},
{
label: "Contas Atípicas",
regras: ["conta_transitoria_com_saldo", "conta_deveria_zerar", "descricao_generica"],
},
// "Variações e Indicadores" (variacao_atipica_dre) saiu daqui: essas
// variações só aparecem no fim da aba Análise Vertical.
// `somenteComAchados`: só aparece na apuração em que algo foi de fato
// removido — um card fixo em "0" em toda análise nunca reprocessada seria
// ruído, diferente das regras acima, cujo "0" é a informação de que aquela
// checagem rodou e passou.
{
label: "Mudanças no Reprocessamento",
regras: ["item_removido_reprocessamento"],
somenteComAchados: true,
},
];
// Paleta de 10 cores distintas reaproveitando os tokens *-rgb já existentes
// (theme-aware — acompanham o tema claro/escuro e a cor de tema escolhida
// pelo usuário), com color-mix() pra completar até 10 sem hardcoded hex novo.
const PID_DC_CATEGORIA_CORES = [
"rgb(var(--accent-rgb))",
"rgb(var(--danger-rgb))",
"rgb(var(--gold-rgb))",
"rgb(var(--teal-rgb))",
"rgb(var(--slate-rgb))",
"rgb(var(--coral-rgb))",
"color-mix(in srgb, rgb(var(--accent-rgb)) 55%, white)",
"color-mix(in srgb, rgb(var(--danger-rgb)) 55%, white)",
"color-mix(in srgb, rgb(var(--gold-rgb)) 55%, black)",
"color-mix(in srgb, rgb(var(--teal-rgb)) 55%, black)",
];
function pidDcFormatMoeda(valor) {
const numero = Number(valor || 0);
return numero.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
}
// Os 3 formatos abaixo espelham os filtros moeda/percentual/indice de
// portal_api/templatetags/contabil_extras.py (mesmo relatório "Gerar
// Dashboard") — diferente de pidDcFormatMoeda acima, `null`/`undefined`
// sempre vira "—", nunca "R$ 0,00": em IndicadoresFinanceiros, `None`
// significa "indisponível" (ex. EBITDA sem histórico), não zero.
function pidDcFormatIndicadorMoeda(valor) {
if (valor === null || valor === undefined) return "—";
return Number(valor).toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
}
function pidDcFormatIndicadorPercentual(valor) {
if (valor === null || valor === undefined) return "—";
return `${(Number(valor) * 100).toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`;
}
function pidDcFormatIndicadorIndice(valor) {
if (valor === null || valor === undefined) return "—";
return Number(valor).toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// Label/grupo/formato de cada indicador (de sistema ou personalizado) vêm
// sempre do servidor agora (`GET /contabil-apuracoes/{id}/indicadores/`,
// campo `metadados`) — única fonte de verdade, já que um indicador
// personalizado não tem como ter seu formato hardcoded aqui. Só a ORDEM de
// exibição dos grupos continua fixa no frontend (grupo desconhecido, se
// algum dia existir, cai no fim).
const PID_DC_DASH_ORDEM_GRUPOS = [
"Indicadores de Resultado",
"Indicadores de Liquidez e Endividamento",
"Indicadores Personalizados",
];
function pidDcFormatIndicadorGenerico(formato, valor) {
if (formato === "moeda") return pidDcFormatIndicadorMoeda(valor);
if (formato === "percentual") return pidDcFormatIndicadorPercentual(valor);
return pidDcFormatIndicadorIndice(valor);
}
const PID_DC_ICON_OLHO_ABERTO =
'';
const PID_DC_ICON_OLHO_FECHADO =
'';
const PID_DC_ICON_EDITAR =
'';
const PID_DC_ICON_LIXEIRA =
'';
const PID_DC_ICON_HISTORICO =
'';
// As duas faces do botão de árvore no cabeçalho da primeira coluna das 3
// tabelas da revisão (ver dcAtualizaBotaoArvore) — é um toggle: "−" enquanto
// a árvore está toda aberta (o padrão), "+" assim que existe qualquer grupo
// recolhido.
const PID_DC_ICON_RECOLHER_ARVORE =
'';
const PID_DC_ICON_EXPANDIR_ARVORE =
'';
// Usado pelo botão "Ver na tabela" dos cards de achado (ver renderAchados) —
// mesma semântica de "localizar um ponto", reaproveitado como pin de mapa.
const PID_DC_ICON_LOCALIZAR =
'';
// Percentual da Análise Vertical já vem "pronto" do PDF (ex. "-7.33" quer
// dizer -7,33%, não -733%) — diferente de pidDcFormatIndicadorPercentual
// (usado pelos cards de indicador), que recebe uma fração e multiplica por
// 100 antes de exibir. Reusar aquele aqui exibiria "-733,00%" errado.
function pidDcFormatPercentualAnaliseVertical(valor) {
const numero = Number(valor || 0);
return `${numero.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`;
}
// "mai/2026" (formato salvo pelo parser) -> "Mai/2026" (rótulo de coluna).
function pidDcFormatMesAnaliseVertical(mes) {
if (!mes) return "";
return mes.charAt(0).toUpperCase() + mes.slice(1);
}
function pidDcFormatCompetencia(iso) {
if (!iso) return "";
const [ano, mes] = iso.split("-");
return `${mes}/${ano}`;
}
function pidDcFormatData(iso) {
if (!iso) return "";
return new Date(iso).toLocaleDateString("pt-BR");
}
function pidDcFormatDataHora(iso) {
if (!iso) return "";
return new Date(iso).toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// Delegado ao `pidEscapeHtml` (api.js), que também escapa aspas: esta função
// é usada dentro de atributos (`aria-label`, `value`), e a versão antiga por
// `textContent` deixava `"` passar (XSS por texto de observação).
function pidDcEscapeHtml(texto) {
return pidEscapeHtml(texto);
}
// Barra de progresso de "Nova Análise"/"Reprocessar" — o processamento roda
// de forma síncrona numa única requisição (sem fila de tarefas), então não
// existe um percentual real pra reportar. Simula a subida: cresce rápido no
// início e desacelera perto de 90%, sem nunca chegar lá sozinha — só
// `completar()` (chamado quando a resposta volta) fecha os últimos 10% e
// segura em 100% por um instante, pra sempre refletir o fim de verdade do
// processamento, nunca um término fake antes da hora.
// O avanço é um `transform: scaleX()` sobre uma barra de largura 100% (ver
// `.dc-progress__fill` no CSS), não `width`: só `transform` anima sem refazer
// o layout a cada passo.
function pidDcIniciarProgressoSimulado(fillEl) {
let pct = 0;
fillEl.style.transform = "scaleX(0)";
const intervalId = setInterval(() => {
pct += (90 - pct) * 0.1;
fillEl.style.transform = `scaleX(${pct / 100})`;
}, 200);
return {
parar() {
clearInterval(intervalId);
},
async completar() {
clearInterval(intervalId);
fillEl.style.transform = "scaleX(1)";
await new Promise((resolve) => setTimeout(resolve, 300));
},
};
}
// Posiciona o `` (fixed, ver
// dashboard-contabil.css) relativo à viewport, calculado no momento do
// hover/foco — nasce acima do gatilho, centralizado; sem espaço acima
// (badge perto do topo da tela) desce pra baixo do gatilho; clampado nas
// laterais pra nunca vazar pra fora da tela (badge perto da borda da
// tabela). Necessário porque os badges deste pacote vivem dentro de
// `.pa-table-wrap`, que corta (`overflow:hidden`) qualquer coisa
// posicionada com `position:absolute` que escape da própria tabela —
// `position:fixed` já resolve o corte sozinho, isto aqui só calcula onde.
// Delegado no `document` com `capture:true` porque `mouseenter`/`focus` não
// borbulham — um único listener cobre todo badge atual e futuro, mesmo os
// recriados a cada re-render da tabela.
function pidDcPosicionaTooltip(event) {
const gatilho = event.target.closest && event.target.closest(".dc-hover-tooltip__gatilho");
if (!gatilho) return;
const texto = gatilho.nextElementSibling;
if (!texto || !texto.classList.contains("dc-hover-tooltip__texto")) return;
pidDcPosicionaBalao(gatilho, texto);
}
// Cálculo de posição compartilhado pelo tooltip dos badges (acima) e pela
// dica dos botões (`data-dc-dica`, abaixo).
function pidDcPosicionaBalao(gatilho, texto) {
const margem = 8;
const rect = gatilho.getBoundingClientRect();
const largura = texto.offsetWidth;
const altura = texto.offsetHeight;
let esquerda = rect.left + rect.width / 2 - largura / 2;
esquerda = Math.max(margem, Math.min(esquerda, window.innerWidth - largura - margem));
let topo = rect.top - altura - margem;
if (topo < margem) topo = rect.bottom + margem;
texto.style.left = `${esquerda}px`;
texto.style.top = `${topo}px`;
}
document.addEventListener("mouseenter", pidDcPosicionaTooltip, true);
document.addEventListener("focus", pidDcPosicionaTooltip, true);
// Em tela de toque não existe hover: tocar num badge informativo (valor
// antes do reprocessamento, fonte de PDF atípica) abre o tooltip dele, tocar
// de novo ou em qualquer outro ponto fecha. Gatilho que contém um botão (o
// ícone de variação atípica da Análise Vertical) fica de fora, porque o toque
// nele já tem ação própria (levar ao card da variação).
function pidDcAlternaTooltipToque(event) {
if (!window.matchMedia || !window.matchMedia("(hover: none)").matches) return;
const gatilho = event.target.closest && event.target.closest(".dc-hover-tooltip__gatilho");
const tooltip = gatilho && !gatilho.querySelector("button") ? gatilho.parentElement : null;
document.querySelectorAll(".dc-hover-tooltip.is-aberto").forEach((aberto) => {
if (aberto !== tooltip) aberto.classList.remove("is-aberto");
});
if (!tooltip) return;
const abriu = tooltip.classList.toggle("is-aberto");
if (abriu) pidDcPosicionaTooltip({ target: gatilho });
}
document.addEventListener("click", pidDcAlternaTooltipToque);
// Dica dos botões da tela, no lugar do `title` nativo (o balão do navegador
// quebra a identidade visual do Portal, mesmo motivo de pidDcHoverTooltipHtml):
// qualquer elemento com `data-dc-dica="texto"` mostra um balão único, com o
// mesmo visual de `.dc-hover-tooltip__texto`, criado uma vez em
// `document.body`. Diferente de pidDcHoverTooltipHtml, não envolve o botão em
// outro elemento (não mexe no layout das linhas de ação) e o texto pode mudar
// depois de desenhado (ex.: dcAtualizaBotaoArvore troca a dica do botão "−"/"+").
let pidDcDicaEl = null;
function pidDcEscondeDica() {
if (pidDcDicaEl) pidDcDicaEl.classList.remove("is-visivel");
}
function pidDcMostraDica(event) {
const alvo = event.target.closest && event.target.closest("[data-dc-dica]");
if (!alvo) return;
const texto = alvo.getAttribute("data-dc-dica");
if (!texto) return;
if (!pidDcDicaEl) {
pidDcDicaEl = document.createElement("span");
pidDcDicaEl.className = "dc-hover-tooltip__texto dc-dica-flutuante";
document.body.appendChild(pidDcDicaEl);
}
pidDcDicaEl.textContent = texto;
pidDcPosicionaBalao(alvo, pidDcDicaEl);
pidDcDicaEl.classList.add("is-visivel");
}
document.addEventListener("mouseover", pidDcMostraDica);
document.addEventListener("mouseout", (event) => {
const alvo = event.target.closest && event.target.closest("[data-dc-dica]");
if (!alvo) return;
if (event.relatedTarget && alvo.contains(event.relatedTarget)) return;
pidDcEscondeDica();
});
// O clique costuma redesenhar a tabela (o botão some sem disparar `mouseout`)
// e a rolagem tira o balão do lugar: nos dois casos, esconde.
document.addEventListener("click", pidDcEscondeDica, true);
window.addEventListener("scroll", pidDcEscondeDica, true);
async function pidFetchApuracoesContabil() {
return pidApiRequest("/contabil-apuracoes/");
}
async function pidFetchApuracaoContabil(id) {
return pidApiRequest(`/contabil-apuracoes/${id}/`);
}
async function pidCriarApuracaoContabil(formData) {
return pidApiRequest("/contabil-apuracoes/", { method: "POST", body: formData });
}
async function pidExcluirApuracaoContabil(id) {
return pidApiRequest(`/contabil-apuracoes/${id}/`, { method: "DELETE" });
}
async function pidConcluirApuracaoContabil(id) {
return pidApiRequest(`/contabil-apuracoes/${id}/concluir/`, { method: "POST" });
}
async function pidReprocessarApuracaoContabil(id, formData) {
return pidApiRequest(`/contabil-apuracoes/${id}/reprocessar/`, { method: "POST", body: formData });
}
/**
* /dashboard/ é GET (não POST, diferente do padrão /gerar/ de outras
* ferramentas) exatamente pra poder ser aberto assim — navegação de
* verdade numa nova aba, não um blob em memória. Um blob criado via fetch
* (`URL.createObjectURL`) tem sua própria origem sintética: URLs relativas
* dentro do HTML (como `{% static %}` da logo do escritório, que gera
* "/static/...") não resolvem de forma confiável contra a origem do Portal
* dentro de um documento carregado de um `blob:`, quebrando a imagem — com
* navegação de verdade pra URL da API, isso deixa de ser um problema.
*/
function pidGerarDashboardContabil(id) {
window.open(`/api/contabil-apuracoes/${id}/dashboard/`, "_blank");
}
// ---- Observações (histórico por empresa+conta, atravessa competências) ----
// A observação não pertence mais à linha da apuração (era um campo de
// ContabilConta/ContabilLinhaDre/ContabilLinhaAnaliseVertical até a migração
// 0071) — é um registro próprio (`ContabilObservacao`), vinculado à empresa +
// chave natural da conta, que continua aparecendo nas competências seguintes
// até o contador encerrá-la. Por isso todas as chamadas abaixo são contra
// `/contabil-observacoes/`, nunca contra a linha.
async function pidBuscarObservacoesContabil(apuracaoId) {
return pidApiRequest(`/contabil-apuracoes/${apuracaoId}/observacoes/`);
}
// `alvoId` é o id da conta/linha DESTA apuração — o servidor deriva empresa,
// competência, chave natural e rótulo a partir dele (ver
// ContabilObservacaoViewSet.create).
async function pidCriarObservacaoContabil(apuracaoId, alvoTipo, alvoId, texto, mostrarAoCliente) {
return pidApiRequest("/contabil-observacoes/", {
method: "POST",
body: {
apuracao: apuracaoId,
alvo_tipo: alvoTipo,
alvo_id: alvoId,
texto,
mostrar_ao_cliente: mostrarAoCliente,
},
});
}
// `payload` aceita {texto} e/ou {mostrar_ao_cliente} — o texto só é aceito
// enquanto a observação ainda for da competência aberta; a visibilidade ao
// cliente é sempre alternável, inclusive numa observação histórica.
async function pidAtualizarObservacaoContabil(id, payload) {
return pidApiRequest(`/contabil-observacoes/${id}/`, { method: "PATCH", body: payload });
}
// "Ocultar das próximas execuções": continua visível nesta competência e
// some a partir da seguinte. `reativar` desfaz.
async function pidEncerrarObservacaoContabil(id, apuracaoId) {
return pidApiRequest(`/contabil-observacoes/${id}/encerrar/`, { method: "POST", body: { apuracao: apuracaoId } });
}
async function pidReativarObservacaoContabil(id, apuracaoId) {
return pidApiRequest(`/contabil-observacoes/${id}/reativar/`, { method: "POST", body: { apuracao: apuracaoId } });
}
// Excluir de verdade — botão voltou a existir na tela (rodada 145), restrito
// a quem criou a observação e só enquanto a apuração de origem estiver em
// revisão (mesmas duas regras checadas de novo no servidor, ver
// ContabilObservacaoViewSet.perform_destroy()).
async function pidExcluirObservacaoContabil(id) {
return pidApiRequest(`/contabil-observacoes/${id}/`, { method: "DELETE" });
}
// `alterada_reprocessamento` nunca é tocado por aqui (pedido explícito do
// usuário) — o badge de alerta continua visível mesmo depois de validado,
// só muda de cor (ver pidDcAlteradaBadgeHtml), pra o contador conseguir
// identificar depois quais itens já foram reprocessados E revalidados. Só
// um reprocessamento novo (sem mudança daquela conta/linha desta vez) limpa
// o campo de verdade, em `_contabil_sincroniza_*` (views.py).
async function pidAtualizarValidadoContaContabil(id, validado) {
return pidApiRequest(`/contabil-contas/${id}/`, { method: "PATCH", body: { validado } });
}
async function pidAtualizarValidadoLinhaDreContabil(id, validado) {
return pidApiRequest(`/contabil-linhas-dre/${id}/`, { method: "PATCH", body: { validado } });
}
async function pidAtualizarValidadoLinhaAnaliseVerticalContabil(id, validado) {
return pidApiRequest(`/contabil-linhas-analise-vertical/${id}/`, { method: "PATCH", body: { validado } });
}
async function pidAtualizarAchadoContabil(id, status, observacaoContador) {
return pidApiRequest(`/contabil-achados/${id}/`, {
method: "PATCH",
body: { status, observacao_contador: observacaoContador },
});
}
// Só variação atípica da Análise Vertical: validar trata o apontamento sem
// justificativa escrita (o texto vai na observação da linha), desvalidar
// volta a "pendente" — ver ContabilAchadoViewSet.validar().
async function pidValidarAchadoContabil(id, validado) {
return pidApiRequest(`/contabil-achados/${id}/validar/`, { method: "POST", body: { validado } });
}
// ---- Aba "Dashboard" (pré-visualização do relatório "Gerar Dashboard") ----
async function pidBuscarIndicadoresContabil(id) {
return pidApiRequest(`/contabil-apuracoes/${id}/indicadores/`);
}
async function pidAtualizarIndicadoresOcultosContabil(id, indicadoresOcultos) {
return pidApiRequest(`/contabil-apuracoes/${id}/indicadores-ocultos/`, {
method: "POST",
body: { indicadores_ocultos: indicadoresOcultos },
});
}
async function pidAlternarOcultoAchadoContabil(id, oculto) {
return pidApiRequest(`/contabil-achados/${id}/alternar-oculto/`, {
method: "POST",
body: { oculto_no_relatorio: oculto },
});
}
async function pidBuscarIndicadorContabilDefinicao(id) {
return pidApiRequest(`/contabil-indicadores-definicoes/${id}/`);
}
async function pidCriarIndicadorContabil(payload) {
return pidApiRequest("/contabil-indicadores-definicoes/", { method: "POST", body: payload });
}
async function pidAtualizarIndicadorContabil(id, payload) {
return pidApiRequest(`/contabil-indicadores-definicoes/${id}/`, { method: "PATCH", body: payload });
}
async function pidExcluirIndicadorContabil(id) {
return pidApiRequest(`/contabil-indicadores-definicoes/${id}/`, { method: "DELETE" });
}
async function pidBuscarDefinicoesIndicadoresContabil() {
return pidApiRequest("/contabil-indicadores-definicoes/");
}
async function pidPreVisualizarIndicadorContabil(apuracaoId, payload) {
return pidApiRequest(`/contabil-apuracoes/${apuracaoId}/pre-visualizar-indicador/`, {
method: "POST",
body: payload,
});
}
async function pidAtualizarIndicadoresSelecionadosContabil(id, indicadoresSelecionados) {
return pidApiRequest(`/contabil-apuracoes/${id}/indicadores-selecionados/`, {
method: "POST",
body: { indicadores_selecionados: indicadoresSelecionados },
});
}
async function pidAtualizarResumoFechamentoContabil(id, resumoFechamento) {
return pidApiRequest(`/contabil-apuracoes/${id}/resumo-fechamento/`, {
method: "POST",
body: { resumo_fechamento: resumoFechamento },
});
}
document.addEventListener("DOMContentLoaded", async () => {
const listView = document.getElementById("dc-list-view");
if (!listView) return;
const me = await pidGetMe();
if (!me) return;
const relatoriosPerms = (me.permissoes_efetivas && me.permissoes_efetivas["relatorios"]) || {};
const canUse = !!(relatoriosPerms.enabled && relatoriosPerms.apps && relatoriosPerms.apps["dashboard-contabil"]);
const noAccessEl = document.getElementById("dc-no-access");
const contentEl = document.getElementById("dc-content");
if (noAccessEl) noAccessEl.hidden = canUse;
if (contentEl) contentEl.hidden = !canUse;
if (!canUse) return;
// ---- Botão "voltar ao topo" ----
// Página tem tabelas longas (Balancete/DRE/Análise Vertical, achados) —
// botão flutuante só aparece depois que o início da página já saiu da
// viewport (scroll da própria janela, `.page-content` não tem overflow
// próprio), pra não poluir a tela quando não faz falta.
const PID_DC_BACK_TO_TOP_LIMIAR_PX = 400;
const backToTopBtn = document.getElementById("dc-back-to-top-btn");
if (backToTopBtn) {
window.addEventListener("scroll", () => {
backToTopBtn.hidden = window.scrollY < PID_DC_BACK_TO_TOP_LIMIAR_PX;
});
backToTopBtn.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: "smooth" });
});
}
const formView = document.getElementById("dc-form-view");
const reviewView = document.getElementById("dc-review-view");
let apuracaoAtual = null;
let filtroSeveridade = "todas";
let filtroStatusAchado = "pendente";
// Categoria (achado.regra) selecionada clicando num card do resumo — null
// = nenhuma categoria isolada. Filtro independente de severidade/status,
// os três se combinam (E lógico) na lista detalhada abaixo do resumo.
let filtroRegra = null;
let achadoEmEdicao = null;
// Ids de ContabilConta com filhos recolhidos na árvore do Balancete —
// reiniciado toda vez que uma apuração é aberta/criada (ver renderRevisao).
let dcContasColapsadas = new Set();
// Mesma ideia, mas para a árvore da DRE (ContabilLinhaDre.id) — nível de
// indentação já vem pronto do backend (linha.nivel), sem precisar derivar
// de um código de classificação como no Balancete.
let dcDreColapsadas = new Set();
// Observações vigentes nesta apuração (as desta competência + as herdadas
// de competências anteriores ainda não encerradas) — carregadas à parte da
// apuração, porque não pertencem a ela: `GET /contabil-apuracoes/{id}/
// observacoes/`, ver `ContabilObservacao` no backend. `dcObsIndice` é o
// mesmo conteúdo indexado pela chave natural do alvo, pra cada linha da
// tabela achar as suas sem varrer a lista inteira.
let dcObservacoes = [];
let dcObsIndice = { conta: new Map(), dre: new Map(), analise_vertical: new Map() };
// Mesma ideia de dcDreColapsadas, agora pra árvore da Análise Vertical
// (ContabilLinhaAnaliseVertical) — mesmo nível de indentação da DRE
// (linha.nivel), só que cada linha tem um valor+percentual por mês em vez
// de um valor único.
let dcAvColapsadas = new Set();
// Ids de ContabilAchado com o detalhe da conta usada no apontamento
// expandido (ver renderAchados) — só achados com `conta` preenchido (as
// regras gerais, ex. balanceamento Ativo x Passivo, não têm uma conta
// única por trás, então não ganham o toggle).
let dcAchadosContaExpandida = new Set();
// { indicadores, indicadores_ocultos } da aba "Dashboard" — carregado sob
// demanda (só quando essa aba é aberta pela primeira vez após abrir/criar
// a apuração, ver renderDashboardTab), null até lá.
let dcIndicadoresAtual = null;
// Ordenação + filtro "estilo Excel" do histórico (#dc-list-table) — mesmo
// mecanismo de listSortKey/listSortDir/listFiltros em
// importacao-plano-saude.js: client-side, sobre o array já carregado
// (dcListaApuracoes), sem refazer fetch a cada mudança. A paginação
// (dcListPage/DC_LIST_PAGE_SIZE) também segue o mesmo padrão de
// importacao-plano-saude.js (IPS_LIST_PAGE_SIZE/renderListPagination).
let dcListaApuracoes = [];
let dcListSortKey = "criado_em";
let dcListSortDir = -1;
// null = sem filtro nessa coluna; senão, um Set com os valores marcados no
// popup daquela coluna (combinam entre si por E lógico).
let dcListFiltros = { empresa: null, competencia: null, status: null, criado_por: null };
let dcListPage = 1;
const DC_LIST_PAGE_SIZE = 10;
const dcListSortHeaders = Array.from(document.querySelectorAll("#dc-list-table th[data-sort]"));
const dcListPagination = document.getElementById("dc-list-pagination");
const dcListCount = document.getElementById("dc-list-count");
function showView(view) {
listView.hidden = view !== "list";
formView.hidden = view !== "form";
reviewView.hidden = view !== "review";
}
// ---- Histórico ----
// Compara numericamente quando os dois valores parecem número (evita
// "218" ficando antes de "9" por comparação de string pura), senão cai
// pra comparação de texto (acentuada/case-insensitive) — mesma função de
// importacao-plano-saude.js (comparaValoresLista).
function dcComparaValoresLista(a, b) {
const an = Number(a);
const bn = Number(b);
const ambosNumeros = a !== "" && b !== "" && !Number.isNaN(an) && !Number.isNaN(bn);
if (ambosNumeros) return an - bn;
return String(a).localeCompare(String(b), "pt-BR", { sensitivity: "base" });
}
const PID_DC_LISTA_SORT_VALOR = {
empresa: (a) => a.codigo_empresa || "",
competencia: (a) => a.competencia || "",
observacoes: (a) => a.total_achados_pendentes ?? 0,
status: (a) => a.status || "",
criado_por: (a) => a.criado_por_nome || "",
criado_em: (a) => a.criado_em || "",
};
// Colunas com filtro "estilo Excel" no cabeçalho do histórico — mesma
// chave de PID_DC_LISTA_SORT_VALOR, reaproveitado como extrator do valor
// bruto de cada linha pro popup também (ver dcCriarFiltroColuna).
// "observacoes"/"criado_em" só ordenam, não filtram — a primeira é uma
// contagem, a segunda uma data/hora de criação, granularidade fina demais
// pra um checklist de valores distintos.
function dcLabelValorFiltroLista(campo, valor) {
if (campo === "status") return PID_DC_STATUS_LABELS[valor] || valor;
if (campo === "competencia") return pidDcFormatCompetencia(valor) || "(Vazio)";
if (campo === "empresa") {
const apuracao = dcListaApuracoes.find((a) => (a.codigo_empresa || "") === valor);
return apuracao ? `${valor || "(Vazio)"} - ${apuracao.nome_empresa}` : valor || "(Vazio)";
}
return valor || "(Vazio)";
}
function dcApuracaoPassaNosFiltros(apuracao) {
return Object.entries(dcListFiltros).every(([campo, valoresPermitidos]) => {
if (!valoresPermitidos) return true; // null = sem filtro nessa coluna
return valoresPermitidos.has(PID_DC_LISTA_SORT_VALOR[campo](apuracao));
});
}
async function carregarLista() {
dcListaApuracoes = await pidFetchApuracoesContabil();
renderList();
}
function dcRenderListPagination(totalPaginas) {
if (!dcListPagination) return;
if (totalPaginas <= 1) {
dcListPagination.innerHTML = "";
return;
}
dcListPagination.innerHTML = `
Página ${dcListPage} de ${totalPaginas}
`;
}
function renderList() {
const body = document.getElementById("dc-list-body");
const empty = document.getElementById("dc-list-empty");
const filtradas = dcListaApuracoes.filter(dcApuracaoPassaNosFiltros);
empty.hidden = filtradas.length !== 0;
empty.textContent =
dcListaApuracoes.length === 0
? "Nenhuma análise realizada ainda."
: "Nenhuma análise encontrada com esse filtro.";
const extrator = PID_DC_LISTA_SORT_VALOR[dcListSortKey];
const ordenadas = [...filtradas].sort((a, b) => dcComparaValoresLista(extrator(a), extrator(b)) * dcListSortDir);
const totalPaginas = Math.max(1, Math.ceil(ordenadas.length / DC_LIST_PAGE_SIZE));
dcListPage = Math.min(dcListPage, totalPaginas);
const inicio = (dcListPage - 1) * DC_LIST_PAGE_SIZE;
const pagina = ordenadas.slice(inicio, inicio + DC_LIST_PAGE_SIZE);
body.innerHTML = "";
pagina.forEach((apuracao) => {
const tr = document.createElement("tr");
tr.innerHTML = `
`;
body.appendChild(tr);
});
dcListSortHeaders.forEach((th) => {
const key = th.getAttribute("data-sort");
th.classList.toggle("is-sort-asc", key === dcListSortKey && dcListSortDir === 1);
th.classList.toggle("is-sort-desc", key === dcListSortKey && dcListSortDir === -1);
});
dcRenderListPagination(totalPaginas);
if (dcListCount) {
if (ordenadas.length === 0) {
dcListCount.textContent = "";
} else {
const fim = Math.min(inicio + DC_LIST_PAGE_SIZE, ordenadas.length);
dcListCount.textContent = `Mostrando ${inicio + 1}–${fim} de ${ordenadas.length} registro${ordenadas.length === 1 ? "" : "s"}`;
}
}
}
dcListSortHeaders.forEach((th) => {
th.addEventListener("click", (event) => {
// Ignora cliques vindos de dentro do popup de filtro — sem isso, abrir
// o filtro ou marcar um checkbox dele também reordenava a coluna, já
// que o filtro mora dentro do mesmo
clicável.
if (event.target.closest(".dc-col-filter")) return;
const key = th.getAttribute("data-sort");
if (dcListSortKey === key) {
dcListSortDir *= -1;
} else {
dcListSortKey = key;
dcListSortDir = 1;
}
dcListPage = 1;
renderList();
});
});
// Filtro "estilo Excel" por coluna do histórico — mesmo mecanismo de
// criarFiltroColuna em importacao-plano-saude.js.
const dcFiltrosColuna = [];
function dcFecharTodosOsFiltrosColuna() {
dcFiltrosColuna.forEach((f) => f.fechar());
}
function dcEscapeAttr(value) {
return pidEscapeHtml(value);
}
function dcCriarFiltroColuna(campo) {
const th = dcListSortHeaders.find((el) => el.getAttribute("data-sort") === campo);
if (!th) return;
const wrapper = document.createElement("span");
wrapper.className = "dc-col-filter";
wrapper.innerHTML = `
`;
th.appendChild(wrapper);
const btn = wrapper.querySelector(".dc-col-filter__btn");
const popup = wrapper.querySelector(".dc-col-filter__popup");
const searchInput = wrapper.querySelector(".checklist-search");
const selectAllInput = wrapper.querySelector(".checklist-select-all input");
const box = wrapper.querySelector(".checklist-box");
const limparBtn = wrapper.querySelector(".dc-col-filter__limpar");
const aplicarBtn = wrapper.querySelector(".dc-col-filter__aplicar");
const extrator = PID_DC_LISTA_SORT_VALOR[campo];
let marcados = null; // Set em edição — só existe enquanto o popup está aberto
// Ordena pelo valor bruto (dcComparaValoresLista), não pelo rótulo
// formatado — importa pra "competencia" (ISO "AAAA-MM-DD", ordena certo
// cronologicamente por string; o rótulo "MM/AAAA" ordenaria por mês
// primeiro, misturando anos).
function valoresDistintos() {
return Array.from(new Set(dcListaApuracoes.map(extrator))).sort(dcComparaValoresLista);
}
function valoresVisiveis() {
const termo = searchInput.value.trim().toLowerCase();
return valoresDistintos().filter((v) => dcLabelValorFiltroLista(campo, v).toLowerCase().includes(termo));
}
function renderItens() {
const visiveis = valoresVisiveis();
box.innerHTML = visiveis.length
? visiveis
.map(
(v) =>
``
)
.join("")
: `
Nenhum valor encontrado.
`;
selectAllInput.checked = visiveis.length > 0 && visiveis.every((v) => marcados.has(v));
}
function abrir() {
// `marcados` sempre recomeça a partir do filtro já aplicado (ou de
// todos os valores existentes, se a coluna ainda não tem filtro) —
// nunca do que ficou de uma edição anterior cancelada sem "Aplicar".
marcados = dcListFiltros[campo] ? new Set(dcListFiltros[campo]) : new Set(valoresDistintos());
searchInput.value = "";
renderItens();
popup.hidden = false;
btn.setAttribute("aria-expanded", "true");
}
function fechar() {
popup.hidden = true;
marcados = null;
btn.setAttribute("aria-expanded", "false");
}
function atualizarBotaoAtivo() {
btn.classList.toggle("is-active", !!dcListFiltros[campo]);
}
btn.addEventListener("click", (event) => {
event.stopPropagation();
const estavaAberto = !popup.hidden;
dcFecharTodosOsFiltrosColuna();
if (!estavaAberto) abrir();
});
popup.addEventListener("click", (event) => event.stopPropagation());
searchInput.addEventListener("input", renderItens);
searchInput.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
const visiveis = valoresVisiveis();
if (visiveis.length) marcados = new Set(visiveis);
aplicarBtn.click();
});
selectAllInput.addEventListener("change", () => {
const visiveis = valoresVisiveis();
if (selectAllInput.checked) visiveis.forEach((v) => marcados.add(v));
else visiveis.forEach((v) => marcados.delete(v));
renderItens();
});
box.addEventListener("change", (event) => {
const input = event.target.closest("[data-valor-filtro]");
if (!input) return;
const valor = input.getAttribute("data-valor-filtro");
if (input.checked) marcados.add(valor);
else marcados.delete(valor);
renderItens();
});
limparBtn.addEventListener("click", () => {
dcListFiltros[campo] = null;
fechar();
atualizarBotaoAtivo();
dcListPage = 1;
renderList();
});
aplicarBtn.addEventListener("click", () => {
const todos = valoresDistintos();
// Marcar tudo equivale a "sem filtro" — mantém null nesse caso, senão
// um valor novo que apareça depois ficaria de fora até o usuário
// marcá-lo manualmente de novo.
const marcouTudo = todos.length === marcados.size && todos.every((v) => marcados.has(v));
dcListFiltros[campo] = marcouTudo ? null : new Set(marcados);
fechar();
atualizarBotaoAtivo();
dcListPage = 1;
renderList();
});
dcFiltrosColuna.push({ fechar, atualizarBotaoAtivo });
}
["empresa", "competencia", "status", "criado_por"].forEach(dcCriarFiltroColuna);
const dcListResetBtn = document.getElementById("dc-list-reset-btn");
if (dcListResetBtn) {
dcListResetBtn.addEventListener("click", () => {
dcFecharTodosOsFiltrosColuna();
dcListSortKey = "criado_em";
dcListSortDir = -1;
dcListFiltros = { empresa: null, competencia: null, status: null, criado_por: null };
dcFiltrosColuna.forEach((f) => f.atualizarBotaoAtivo());
dcListPage = 1;
renderList();
});
}
document.addEventListener("click", (event) => {
if (!event.target.closest(".dc-col-filter")) dcFecharTodosOsFiltrosColuna();
});
if (dcListPagination) {
dcListPagination.addEventListener("click", (event) => {
if (event.target.closest("#dc-list-prev-btn")) {
dcListPage -= 1;
renderList();
} else if (event.target.closest("#dc-list-next-btn")) {
dcListPage += 1;
renderList();
}
});
}
document.getElementById("dc-list-body").addEventListener("click", async (event) => {
const abrirBtn = event.target.closest("[data-dc-abrir]");
if (abrirBtn) {
if (abrirBtn.disabled) return;
if (!(await dcPodeDescartarTextoPendente())) return;
const rotulo = abrirBtn.textContent;
abrirBtn.disabled = true;
abrirBtn.textContent = "Abrindo…";
try {
await abrirRevisao(Number(abrirBtn.getAttribute("data-dc-abrir")));
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível abrir a análise" });
} finally {
abrirBtn.disabled = false;
abrirBtn.textContent = rotulo;
}
return;
}
const excluirBtn = event.target.closest("[data-dc-excluir]");
if (excluirBtn) {
if (!(await pidConfirm("Excluir esta análise? Essa ação não pode ser desfeita.", { perigoso: true }))) return;
// O botão nem aparece numa análise concluída, mas o servidor recusa de
// novo (ver ContabilApuracaoViewSet.perform_destroy) — pode ter sido
// concluída por outra pessoa depois desta lista ter sido carregada, e aí
// o motivo precisa chegar na tela em vez de o clique não fazer nada.
try {
await pidExcluirApuracaoContabil(Number(excluirBtn.getAttribute("data-dc-excluir")));
} catch (e) {
await pidAlert(e.message);
}
try {
await carregarLista();
} catch (e) {
await pidAlert(e.message, { titulo: "Não foi possível atualizar a lista" });
}
return;
}
const reprocessarBtn = event.target.closest("[data-dc-reprocessar-abrir]");
if (reprocessarBtn) {
abrirReprocessarModal(Number(reprocessarBtn.getAttribute("data-dc-reprocessar-abrir")));
}
});
// ---- Reprocessar (anexar outro PDF pra mesma empresa/competência) ----
// Diferente de "Nova Análise" (cria uma apuração do zero), reprocessar
// resincroniza a apuração já existente — o backend (`_contabil_sincroniza_*`
// em views.py) preserva observação/validação de conta/linha sem mudança e
// só reseta validado + marca `alterada_reprocessamento` no que realmente
// mudou; achados nunca são apagados nem têm status/justificativa
// sobrescritos, mesmo os que não disparam mais. Aberto direto da lista
// (não precisa abrir a apuração antes), por isso tem seu próprio `id` em
// vez de depender de `apuracaoAtual`.
const resetReprocessarArquivoField = wireArquivoField("dc-reprocessar-arquivo");
let dcReprocessarApuracaoId = null;
// Log de reprocessamentos anteriores (quem + quando) — pedido explícito do
// usuário, "identificar quem reprocessou e quantos reprocessamentos já
// ocorreu". `dcListaApuracoes` já carrega `reprocessamentos` (ordenado do
// mais recente pro mais antigo, ver Meta.ordering do model) junto do resto
// da lista, então não precisa de uma chamada de API própria — só procurar
// a apuração pelo id.
function renderReprocessarLog(id) {
const secao = document.getElementById("dc-reprocessar-log");
const apuracao = dcListaApuracoes.find((a) => a.id === id);
const reprocessamentos = apuracao ? apuracao.reprocessamentos || [] : [];
secao.hidden = reprocessamentos.length === 0;
if (reprocessamentos.length === 0) return;
document.getElementById("dc-reprocessar-log-count").textContent = reprocessamentos.length;
document.getElementById("dc-reprocessar-log-lista").innerHTML = reprocessamentos
.map(
(r) =>
`
`;
lista.appendChild(card);
});
}
document.getElementById("dc-achados-list").addEventListener("click", (event) => {
const toggleBtn = event.target.closest("[data-dc-achado-toggle-conta]");
if (toggleBtn) {
const id = Number(toggleBtn.getAttribute("data-dc-achado-toggle-conta"));
if (dcAchadosContaExpandida.has(id)) dcAchadosContaExpandida.delete(id);
else dcAchadosContaExpandida.add(id);
renderAchados();
return;
}
const irParaBtn = event.target.closest("[data-dc-achado-ir-para]");
if (irParaBtn) {
const id = Number(irParaBtn.getAttribute("data-dc-achado-ir-para"));
const achado = apuracaoAtual.achados.find((a) => a.id === id);
if (!achado) return;
if (achado.conta != null) dcIrParaLinha("conta", achado.conta);
else if (achado.linha_analise_vertical != null) dcIrParaLinha("av", achado.linha_analise_vertical);
return;
}
const btn = event.target.closest("[data-dc-achado-editar]");
if (!btn) return;
abrirModalAchado(Number(btn.getAttribute("data-dc-achado-editar")));
});
// Reaproveitado tanto pelos chips de severidade quanto pelo clique no
// donut/legenda do resumo (ver renderAchadosResumo) — mesmo efeito de
// clicar o chip correspondente, sem duplicar a lógica de estado+re-render.
function pidDcSelecionarSeveridade(severidade) {
filtroSeveridade = severidade;
document
.querySelectorAll("#dc-achados-severidade-chips .dc-chip")
.forEach((c) => c.classList.toggle("is-active", c.getAttribute("data-dc-severidade") === severidade));
renderAchados();
}
document.querySelectorAll("#dc-achados-severidade-chips [data-dc-severidade]").forEach((chip) => {
chip.addEventListener("click", () => pidDcSelecionarSeveridade(chip.getAttribute("data-dc-severidade")));
});
document.querySelectorAll("#dc-achados-status-chips [data-dc-status]").forEach((chip) => {
chip.addEventListener("click", () => {
filtroStatusAchado = chip.getAttribute("data-dc-status");
document.querySelectorAll("#dc-achados-status-chips .dc-chip").forEach((c) => c.classList.toggle("is-active", c === chip));
renderAchados();
});
});
document.getElementById("dc-regra-filtro-limpar-btn").addEventListener("click", () => {
filtroRegra = null;
renderAchados();
});
// O código da conta (ex. "1.01.01.002.001") já chega do backend na mesma
// ordem hierárquica/sequencial do PDF (uma sintética seguida das suas
// analíticas, ver portal_api/dashboard_contabil/CLAUDE.md) — o nível de
// indentação vem direto da contagem de segmentos separados por ".", sem
// precisar reconstruir uma árvore por id/parent (o mesmo código pode se
// repetir em várias linhas irmãs, ex. três bancos diferentes sob o mesmo
// "1.01.01.002.001", então não dá pra usar o código como chave única).
function dcContaNivel(conta) {
return conta.codigo.split(".").length - 1;
}
// Nível (equivalente ao 3º segmento do código de classificação, ex.
// "1.01.01") a partir do qual o botão "−" do cabeçalho recolhe os grupos —
// a visão compacta, só com os totais de cada nível. Até uma rodada anterior
// este era o estado em que as tabelas **nasciam**; hoje elas abrem com tudo
// expandido (pedido explícito do usuário, ver renderRevisao) e esta
// constante só vale sob demanda. O relatório "Gerar Dashboard" segue com
// seu próprio padrão, server-side e inalterado
// (_CONTABIL_NIVEL_ABERTO_PADRAO em views.py, mesmo valor).
const PID_DC_NIVEL_ABERTO_PADRAO = 2;
function dcColapsoPadrao(itens, nivelFn) {
const niveis = itens.map(nivelFn);
const colapsadas = new Set();
itens.forEach((item, i) => {
const temFilhos = i + 1 < itens.length && niveis[i + 1] > niveis[i];
if (temFilhos && niveis[i] >= PID_DC_NIVEL_ABERTO_PADRAO) colapsadas.add(item.id);
});
return colapsadas;
}
// Destaque inicial (antes de qualquer clique) — pedido explícito do
// usuário depois de notar que folhas genuínas (conta/linha sem filho
// nenhum, ex. "(-) SIMPLES NACIONAL") ficavam de fora do destaque mesmo
// sendo visualmente tão "de baixo" quanto um grupo colapsado por padrão
// no mesmo nível. Usar só `colapsadas` (que só marca linhas que TÊM
// filhos escondidos) deixa de fora exatamente essas folhas, já que elas
// nunca entram nesse conjunto (não têm filho pra esconder).
//
// Critério correto, que cobre os dois casos com a mesma regra: entre as
// linhas realmente visíveis com este `colapsadas`, uma linha entra no
// destaque se a próxima linha visível **não** for mais profunda que ela
// — ou seja, nada foi revelado logo abaixo dela agora, seja porque ela
// está colapsada (filhos escondidos) ou porque é uma folha sem filho.
function dcUltimaLevaVisivel(itens, nivelFn, colapsadas) {
const niveis = itens.map(nivelFn);
const temFilhos = itens.map((_, i) => i + 1 < itens.length && niveis[i + 1] > niveis[i]);
const visiveis = [];
const pilha = [];
itens.forEach((item, i) => {
const nivel = niveis[i];
while (pilha.length && pilha[pilha.length - 1] >= nivel) pilha.pop();
const oculta = pilha.length > 0;
const temFilhoOculto = temFilhos[i] && colapsadas.has(item.id);
if (temFilhoOculto) pilha.push(nivel);
if (!oculta) visiveis.push({ id: item.id, nivel, temFilhoOculto });
});
const destaque = new Set();
visiveis.forEach((visivel, i) => {
const proximo = visiveis[i + 1];
const eraFimDoRamo = !proximo || proximo.nivel <= visivel.nivel;
if (!eraFimDoRamo) return;
// Nível 0 só entra no destaque quando realmente esconde algo (grupo
// colapsado manualmente) — pedido explícito do usuário: uma sequência
// de linhas de resultado irmãs no nível raiz da DRE (ex. "RESULTADO
// ANTES DA CS E IR", "(=) PREJUÍZO LÍQUIDO DO EXERCÍCIO") não é o fim
// de um ramo aninhado, são linhas soltas lado a lado — não deviam
// herdar o destaque pensado pra folha/grupo aninhado.
if (visivel.nivel === 0 && !visivel.temFilhoOculto) return;
destaque.add(visivel.id);
});
return destaque;
}
// Ids das linhas de fato destacadas agora (`.dc-conta-row--destaque`) —
// derivado, nunca editado à mão: sai sempre de dcDestaqueGrupos() abaixo.
let dcContasDestaque = new Set();
let dcDreDestaque = new Set();
let dcAvDestaque = new Set();
// Ids dos grupos que o contador expandiu manualmente e que continuam
// expandidos — entram no conjunto ao expandir, saem ao recolher (junto com
// qualquer descendente do grupo recolhido, que volta a ficar invisível).
// Um conjunto por árvore, já que Balancete/DRE/Análise Vertical são
// independentes.
let dcContasExpandidos = new Set();
let dcDreExpandidos = new Set();
let dcAvExpandidos = new Set();
// Destaque atual de uma árvore. Enquanto o contador não tiver expandido
// nada, vale a leva padrão (dcUltimaLevaVisivel — toda linha que esconde
// algo ou é folha genuína). A partir do primeiro clique de expandir, o
// destaque passa a ser **só** a união dos filhos diretos dos grupos
// expandidos manualmente, e mais nada.
//
// Esse "e mais nada" é o ponto: a leva padrão já marca praticamente toda
// conta de nível ≥ 2 (nascem recolhidas, então toda uma é "fim de ramo"),
// então somar os filhos revelados a ela deixava a tabela inteira destacada
// — com tudo destacado, nada se destaca, que foi exatamente o efeito
// reportado pelo usuário ("os filhos não ficaram em destaque"). Limpando o
// resto, sobra o contraste de sempre; a diferença pro comportamento antigo
// é que agora vários grupos abertos convivem, em vez de o mais recente
// substituir o anterior.
function dcDestaqueGrupos(itens, nivelFn, colapsadas, expandidos) {
if (!expandidos.size) return dcUltimaLevaVisivel(itens, nivelFn, colapsadas);
const destaque = new Set();
expandidos.forEach((id) => {
dcFilhosDiretos(itens, nivelFn, id).forEach((filhoId) => destaque.add(filhoId));
});
return destaque;
}
// Filhos DIRETOS de um item (mesmo próximo nível, não os netos) — usado
// pra saber quais linhas acabaram de ficar visíveis ao expandir `id`.
function dcFilhosDiretos(itens, nivelFn, id) {
const niveis = itens.map(nivelFn);
const idx = itens.findIndex((item) => item.id === id);
const filhos = new Set();
if (idx === -1) return filhos;
const nivelPai = niveis[idx];
for (let j = idx + 1; j < itens.length; j++) {
if (niveis[j] <= nivelPai) break;
if (niveis[j] === nivelPai + 1) filhos.add(itens[j].id);
}
return filhos;
}
// TODOS os descendentes de um item (não só os diretos — filhos, netos,
// bisnetos...) — usado pelo estado tri-state do botão "validado" de uma
// conta/linha sintética (com filhos), ver dcEstadoValidacaoGrupo() logo
// abaixo e o clique em [data-dc-conta-validado]/[data-dc-dre-validado].
// `somenteFolhas` restringe aos descendentes SEM filhos (contas/linhas
// reais, nunca sintéticas) — usado pelo cálculo de "completo" de
// dcEstadoValidacaoGrupo (ver comentário lá) pra ignorar o campo
// `validado` de um descendente sintético intermediário, que nunca é
// marcado sozinho quando só os netos (folhas) são validados um a um.
function dcDescendentes(itens, nivelFn, id, somenteFolhas = false) {
const niveis = itens.map(nivelFn);
const idx = itens.findIndex((item) => item.id === id);
const descendentes = [];
if (idx === -1) return descendentes;
const nivelPai = niveis[idx];
for (let j = idx + 1; j < itens.length; j++) {
if (niveis[j] <= nivelPai) break;
const temFilho = j + 1 < itens.length && niveis[j + 1] > niveis[j];
if (!somenteFolhas || !temFilho) descendentes.push(itens[j]);
}
return descendentes;
}
// Verdadeiro se algum descendente (filho, neto...) de uma conta/linha
// sintética tem observação vigente — usado pra destacar o ícone de
// observação de um grupo recolhido mesmo quando a própria sintética não
// tem observação própria (pedido explícito do usuário: "caso esteja
// recolhida, o usuário consegue visualizar se há ou não observações
// realizadas" nas contas/linhas dentro dela).
function dcTemObservacaoDescendente(tipo, itens, nivelFn, chaveFn, id) {
return dcDescendentes(itens, nivelFn, id).some((item) => dcObservacoesDe(tipo, chaveFn(item)).length > 0);
}
// Estado do botão "validado" de uma conta/linha COM filhos (sintética) —
// pedido explícito do usuário: "nenhum" (cor padrão) quando nem ela nem
// nenhum descendente está validado; "completo" (verde, mesma cor de uma
// folha validada) quando TODO descendente está validado; "parcial"
// (amarelo) em qualquer combinação intermediária — inclui o caso de só a
// própria sintética ter sido marcada (primeiro clique), sinalizando "em
// andamento" até os descendentes acompanharem.
//
// "completo" usa só os descendentes FOLHA (`descendentesFolhas`, sem
// filhos), não todo `descendentes` (que inclui sintéticas intermediárias
// — ex.: uma "mãe" com "filhas" que por sua vez têm "filhos"/netos): o
// campo `validado` de uma sintética intermediária nunca é marcado sozinho
// quando o usuário valida os netos um a um direto (sem clicar na própria
// filha) — usar todo `descendentes` nesse caso fazia a mãe nunca virar
// "completo" mesmo com todos os netos validados, já que a filha
// intermediária ficava com `validado=false` na checagem. `descendentes`
// (todo a árvore) continua valendo pro "parcial" — uma sintética marcada
// sozinha (sem cascatear) ainda deve sinalizar "em andamento" no ancestral.
function dcEstadoValidacaoGrupo(item, descendentes, descendentesFolhas) {
if (descendentesFolhas.length && descendentesFolhas.every((d) => d.validado)) return "completo";
if (item.validado || descendentes.some((d) => d.validado)) return "parcial";
return "nenhum";
}
// Classe/título/aria do botão "validado" de uma linha — folha (`!temFilhosItem`)
// continua o toggle simples de sempre; sintética (com filhos) usa o
// tri-state de dcEstadoValidacaoGrupo (nenhum/parcial/completo).
function dcValidadoInfo(item, itens, nivelFn, temFilhosItem) {
if (!temFilhosItem) {
return {
classe: item.validado ? " dc-conta-validado-btn--validado" : "",
title: item.validado ? "Validada — clique para desmarcar" : "Marcar como validada",
label: item.validado ? "Desmarcar validação" : "Marcar como validada",
pressed: item.validado,
};
}
const estado = dcEstadoValidacaoGrupo(
item,
dcDescendentes(itens, nivelFn, item.id),
dcDescendentes(itens, nivelFn, item.id, true)
);
if (estado === "completo") {
return {
classe: " dc-conta-validado-btn--validado",
title: "Grupo totalmente validado — clique para desmarcar tudo",
label: "Desmarcar validação de todo o grupo",
pressed: true,
};
}
if (estado === "parcial") {
return {
classe: " dc-conta-validado-btn--parcial",
title: "Algumas contas do grupo já validadas — clique para validar todas",
label: "Validar todas as contas do grupo",
pressed: false,
};
}
return { classe: "", title: "Marcar como validada", label: "Marcar como validada", pressed: false };
}
// Clique no botão "validado" de uma conta do Balancete — pedido explícito
// do usuário: numa folha (sem filhos) é só um toggle simples; numa
// sintética (com filhos) o clique se comporta em 3 etapas conforme o
// estado atual do grupo (ver dcEstadoValidacaoGrupo):
// "nenhum" → marca só a própria sintética (passa a "parcial").
// "parcial" → pergunta antes de validar TODAS as contas do grupo de
// uma vez (passa a "completo").
// "completo" → desmarca a própria sintética + todas as contas do grupo,
// sem perguntar (mesmo pedido explícito: "apertar
// novamente desmarca todo o grupo").
// Sempre termina com renderContas() — inclusive no caso simples de folha —
// pra recalcular a cor de qualquer sintética ancestral que dependa deste
// item (o estado dela é derivado dos descendentes a cada render, nunca
// armazenado à parte).
async function dcClicarValidadoConta(id) {
const contas = apuracaoAtual.contas;
const idx = contas.findIndex((c) => c.id === id);
if (idx === -1) return;
const niveis = contas.map(dcContaNivel);
const temFilhosItem = idx + 1 < contas.length && niveis[idx + 1] > niveis[idx];
const conta = contas[idx];
if (!temFilhosItem) {
const atualizada = await pidAtualizarValidadoContaContabil(id, !conta.validado);
contas[idx] = atualizada;
renderContas();
return;
}
const descendentes = dcDescendentes(contas, dcContaNivel, id);
const descendentesFolhas = dcDescendentes(contas, dcContaNivel, id, true);
const estado = dcEstadoValidacaoGrupo(conta, descendentes, descendentesFolhas);
if (estado === "completo") {
const alvos = [conta, ...descendentes].filter((item) => item.validado);
const respostas = await Promise.all(alvos.map((item) => pidAtualizarValidadoContaContabil(item.id, false)));
respostas.forEach((atualizada) => {
const i = contas.findIndex((c) => c.id === atualizada.id);
if (i >= 0) contas[i] = atualizada;
});
renderContas();
return;
}
if (estado === "parcial") {
if (!(await pidConfirm("Deseja validar todas as contas deste grupo?"))) return;
const alvos = [conta, ...descendentes].filter((item) => !item.validado);
const respostas = await Promise.all(alvos.map((item) => pidAtualizarValidadoContaContabil(item.id, true)));
respostas.forEach((atualizada) => {
const i = contas.findIndex((c) => c.id === atualizada.id);
if (i >= 0) contas[i] = atualizada;
});
renderContas();
return;
}
const atualizada = await pidAtualizarValidadoContaContabil(id, true);
contas[idx] = atualizada;
renderContas();
}
// Mesma lógica de dcClicarValidadoConta(), sobre ContabilLinhaDre.
async function dcClicarValidadoLinha(id) {
const linhas = apuracaoAtual.linhas_dre;
const nivelFn = (l) => Math.max(0, l.nivel);
const idx = linhas.findIndex((l) => l.id === id);
if (idx === -1) return;
const niveis = linhas.map(nivelFn);
const temFilhosItem = idx + 1 < linhas.length && niveis[idx + 1] > niveis[idx];
const linha = linhas[idx];
if (!temFilhosItem) {
const atualizada = await pidAtualizarValidadoLinhaDreContabil(id, !linha.validado);
linhas[idx] = atualizada;
renderDre();
return;
}
const descendentes = dcDescendentes(linhas, nivelFn, id);
const descendentesFolhas = dcDescendentes(linhas, nivelFn, id, true);
const estado = dcEstadoValidacaoGrupo(linha, descendentes, descendentesFolhas);
if (estado === "completo") {
const alvos = [linha, ...descendentes].filter((item) => item.validado);
const respostas = await Promise.all(alvos.map((item) => pidAtualizarValidadoLinhaDreContabil(item.id, false)));
respostas.forEach((atualizada) => {
const i = linhas.findIndex((l) => l.id === atualizada.id);
if (i >= 0) linhas[i] = atualizada;
});
renderDre();
return;
}
if (estado === "parcial") {
if (!(await pidConfirm("Deseja validar todas as contas deste grupo?"))) return;
const alvos = [linha, ...descendentes].filter((item) => !item.validado);
const respostas = await Promise.all(alvos.map((item) => pidAtualizarValidadoLinhaDreContabil(item.id, true)));
respostas.forEach((atualizada) => {
const i = linhas.findIndex((l) => l.id === atualizada.id);
if (i >= 0) linhas[i] = atualizada;
});
renderDre();
return;
}
const atualizada = await pidAtualizarValidadoLinhaDreContabil(id, true);
linhas[idx] = atualizada;
renderDre();
}
// Mesma lógica de dcClicarValidadoConta()/dcClicarValidadoLinha(), sobre
// ContabilLinhaAnaliseVertical.
async function dcClicarValidadoAv(id) {
const linhas = apuracaoAtual.linhas_analise_vertical;
const nivelFn = (l) => Math.max(0, l.nivel);
const idx = linhas.findIndex((l) => l.id === id);
if (idx === -1) return;
const niveis = linhas.map(nivelFn);
const temFilhosItem = idx + 1 < linhas.length && niveis[idx + 1] > niveis[idx];
const linha = linhas[idx];
if (!temFilhosItem) {
const atualizada = await pidAtualizarValidadoLinhaAnaliseVerticalContabil(id, !linha.validado);
linhas[idx] = atualizada;
renderAnaliseVertical();
return;
}
const descendentes = dcDescendentes(linhas, nivelFn, id);
const descendentesFolhas = dcDescendentes(linhas, nivelFn, id, true);
const estado = dcEstadoValidacaoGrupo(linha, descendentes, descendentesFolhas);
if (estado === "completo") {
const alvos = [linha, ...descendentes].filter((item) => item.validado);
const respostas = await Promise.all(
alvos.map((item) => pidAtualizarValidadoLinhaAnaliseVerticalContabil(item.id, false))
);
respostas.forEach((atualizada) => {
const i = linhas.findIndex((l) => l.id === atualizada.id);
if (i >= 0) linhas[i] = atualizada;
});
renderAnaliseVertical();
return;
}
if (estado === "parcial") {
if (!(await pidConfirm("Deseja validar todas as contas deste grupo?"))) return;
const alvos = [linha, ...descendentes].filter((item) => !item.validado);
const respostas = await Promise.all(
alvos.map((item) => pidAtualizarValidadoLinhaAnaliseVerticalContabil(item.id, true))
);
respostas.forEach((atualizada) => {
const i = linhas.findIndex((l) => l.id === atualizada.id);
if (i >= 0) linhas[i] = atualizada;
});
renderAnaliseVertical();
return;
}
const atualizada = await pidAtualizarValidadoLinhaAnaliseVerticalContabil(id, true);
linhas[idx] = atualizada;
renderAnaliseVertical();
}
// Conteúdo do botão de observação de uma conta/linha — pedido explícito
// do usuário: a coluna "Observação" ficava poluída visualmente com
// "+ Observação"/o texto da observação em si repetido em toda linha;
// virou sempre o mesmo ícone, só a cor muda
// (`.dc-conta-observacao-btn--preenchida`, ver dashboard-contabil.css) pra
// indicar de relance quais linhas já têm observação, com um contador ao
// lado (ver dcObsBotaoHtml). Clicar não abre um popup — abre o painel de
// observações dentro da própria tabela (`
`),
// pedido explícito do usuário pra não perder o texto por causa de um
// clique fora de um modal.
const PID_DC_OBSERVACAO_ICONE =
'';
// Botão de "validado" (check) ao lado do de observação — pedido explícito
// do usuário, um segundo controle puramente informativo pro contador
// marcar que já conferiu aquela conta/linha durante a revisão (não afeta
// achados/observação/relatório, só o próprio marcador).
const PID_DC_VALIDADO_ICONE =
'';
// Tooltip customizado no visual do Portal (ver `.dc-hover-tooltip` em
// dashboard-contabil.css) — troca o balão nativo do navegador (que mostra
// o `title`, sem estilo nenhum do app) por um `` posicionado via CSS
// puro, pedido explícito do usuário depois de ver o tooltip nativo nos
// badges abaixo. `gatilhoHtml` é o ícone (já pronto, com sua própria
// classe/cor); `texto` vai só no `` do tooltip (nunca em `title`,
// que voltaria a mostrar o balão nativo do navegador em cima do custom).
function pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto, classeExtra = "") {
return `${gatilhoHtml}${pidDcEscapeHtml(texto)}`;
}
// Badge de alerta ao lado dos botões de validado/observação — marca uma
// conta/linha que mudou no último reprocessamento (`alterada_reprocessamento`,
// ver `ContabilApuracaoViewSet.reprocessar()`/`_contabil_sincroniza_*` em
// views.py). Puramente informativo (não clicável) — permanece visível
// mesmo depois do contador marcar a conta/linha como validada de novo
// (pedido explícito do usuário: precisa dar pra identificar depois quais
// itens já foram reprocessados E revalidados, não só sumir o aviso), só
// muda de cor (`--danger` enquanto pendente, verde quando já validado) —
// some de vez só num próximo reprocessamento sem essa mudança específica.
const PID_DC_ALTERADA_ICONE =
'';
// `valorAnteriorTexto` (já formatado pelo chamador — pidDcFormatMoeda() pra
// Balancete/DRE, pidDcValorAnteriorAvTexto() pra Análise Vertical) some no
// tooltip pra visualização rápida no hover, sem precisar abrir a apuração
// anterior — pedido explícito do usuário. `null` quando o backend não tem
// o valor anterior salvo (conta/linha nova, sem "antes" pra comparar).
function pidDcAlteradaBadgeHtml(alterada, validado, valorAnteriorTexto) {
if (!alterada) return "";
const classe = validado ? "dc-conta-alterada-badge dc-conta-alterada-badge--validada" : "dc-conta-alterada-badge";
const situacao = validado
? "Esta conta/linha mudou no último reprocessamento e já foi revalidada."
: "Esta conta/linha mudou no último reprocessamento e voltou a ficar não validada.";
const texto = valorAnteriorTexto ? `${situacao}\nValor antes do reprocessamento: ${valorAnteriorTexto}` : situacao;
return pidDcHoverTooltipHtml(PID_DC_ALTERADA_ICONE, classe, texto);
}
// Só usado pela Análise Vertical (Balancete/DRE têm um valor único —
// formatam com pidDcFormatMoeda() direto no chamador) — junta o valor
// antes do reprocessamento de cada mês (mesmo alinhamento posicional de
// ContabilApuracao.analise_vertical_meses/linha.valores, ver o model) numa
// linha por mês, uma por `\n` (o atributo `title` já quebra linha nisso).
function pidDcValorAnteriorAvTexto(linha) {
const anteriores = linha.valores_anterior_reprocessamento;
if (!anteriores || !anteriores.length) return null;
const meses = apuracaoAtual.analise_vertical_meses || [];
return anteriores
.map((v, i) => `${pidDcFormatMesAnaliseVertical(meses[i])}: ${pidDcFormatMoeda(v.valor)} (${pidDcFormatPercentualAnaliseVertical(v.percentual)})`)
.join("\n");
}
// Aviso de "fonte de PDF atípica" (ContabilApuracao.fonte_pdf_atipica,
// detectado em parser.py — ver _normaliza_titulo()/fonte_pdf_atipica) ao
// lado do nome da empresa, tanto na lista quanto no cabeçalho da revisão.
// Puramente informativo: avisa que esse PDF específico usa uma fonte
// diferente da do relatório de referência, o que já causou palavras
// grudadas em algumas descrições de conta em pelo menos um caso real
// (confirmado contra `1751 - Balancete 07.2026.pdf`) — não há correção
// automática segura pra isso (ver CLAUDE.md do pacote), então o jeito é
// avisar o contador pra conferir com atenção os nomes de conta dessa
// apuração específica.
const PID_DC_FONTE_ATIPICA_ICONE =
'';
function pidDcFontePdfAtipicaBadgeHtml(fontePdfAtipica) {
if (!fontePdfAtipica) return "";
const texto =
"Este relatório foi gerado a partir de um modelo de PDF diferente do padrão — pequenos erros de nomenclatura podem ocorrer em algumas contas/linhas devido à fonte usada neste documento. Vale conferir os nomes com atenção.";
return pidDcHoverTooltipHtml(PID_DC_FONTE_ATIPICA_ICONE, "dc-fonte-atipica-badge", texto);
}
// ---- Observações: histórico por empresa+conta (ContabilObservacao) ----
// A observação deixou de ser um campo da linha e virou uma thread por
// conta/linha que atravessa competências: o que o contador escreveu em
// 07/2026 continua aparecendo em 08/2026, assinado e travado pra edição,
// até ele encerrar ("ocultar das próximas execuções"). Por isso nada aqui
// é casado por id de linha — a linha é recriada a cada apuração, a
// observação não; o vínculo é a chave natural (`alvo_tipo`+`alvo_chave`),
// as mesmas usadas pelo reprocessamento em `_contabil_sincroniza_*`.
// Qual linha está com o painel de observações aberto, por tabela (no
// máximo uma por tabela, mesmo espírito dos antigos dcContaObsEditId/
// dcDreObsEditId/dcAvObsEditId que este objeto substituiu).
// `variacao` é o painel aberto dentro de um card da lista "Variações
// atípicas" (fim da aba Análise Vertical): mesma observação da linha da
// Análise Vertical, só exibida em outro lugar — por isso abrir um fecha o
// outro (ver dcTrataCliqueObservacao), senão a mesma thread existiria duas
// vezes na tela com os mesmos ids de campo.
let dcObsPainelAberto = { conta: null, dre: null, analise_vertical: null, variacao: null };
// Observação (id) em edição inline dentro do painel — só uma por vez, e só
// enquanto ela for da competência aberta (histórica nunca é editável).
let dcObsEditandoId = null;
// ---- Rascunhos de observação (texto digitado e ainda não salvo) ----
// Expandir/recolher um grupo, trocar o filtro, usar o olho, encerrar ou
// validar redesenham a tabela inteira por `innerHTML`, e o `