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).
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" },
];
// 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"],
},
{
label: "Variações e Indicadores",
regras: ["variacao_atipica_dre"],
},
];
// 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 =
'';
// 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 pidDcEscapeHtml(texto) {
const div = document.createElement("div");
div.textContent = texto == null ? "" : String(texto);
return div.innerHTML;
}
// 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;
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);
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 } });
}
// `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 },
});
}
// ---- 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 repaginar nem refazer fetch a cada mudança.
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 };
const dcListSortHeaders = Array.from(document.querySelectorAll("#dc-list-table th[data-sort]"));
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 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);
body.innerHTML = "";
ordenadas.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);
});
}
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;
}
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 (value === null || value === undefined ? "" : String(value)).replace(/"/g, """);
}
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();
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();
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());
renderList();
});
}
document.addEventListener("click", (event) => {
if (!event.target.closest(".dc-col-filter")) dcFecharTodosOsFiltrosColuna();
});
document.getElementById("dc-list-body").addEventListener("click", async (event) => {
const abrirBtn = event.target.closest("[data-dc-abrir]");
if (abrirBtn) {
await abrirRevisao(Number(abrirBtn.getAttribute("data-dc-abrir")));
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;
await pidExcluirApuracaoContabil(Number(excluirBtn.getAttribute("data-dc-excluir")));
await carregarLista();
}
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;
function abrirReprocessarModal(id) {
dcReprocessarApuracaoId = id;
document.getElementById("dc-reprocessar-arquivo").value = "";
resetReprocessarArquivoField();
document.getElementById("dc-reprocessar-error").textContent = "";
document.getElementById("dc-reprocessar-modal").hidden = false;
}
function fecharReprocessarModal() {
dcReprocessarApuracaoId = null;
document.getElementById("dc-reprocessar-modal").hidden = true;
}
document.getElementById("dc-reprocessar-cancel-btn").addEventListener("click", fecharReprocessarModal);
document.getElementById("dc-reprocessar-modal").addEventListener("click", (event) => {
if (event.target.id === "dc-reprocessar-modal") fecharReprocessarModal();
});
document.getElementById("dc-reprocessar-confirmar-btn").addEventListener("click", async () => {
const errorEl = document.getElementById("dc-reprocessar-error");
errorEl.textContent = "";
const arquivo = document.getElementById("dc-reprocessar-arquivo").files[0];
if (!arquivo) {
errorEl.textContent = "Anexe o PDF de Balancete + DRE.";
return;
}
const formData = new FormData();
formData.append("arquivo", arquivo);
const confirmarBtn = document.getElementById("dc-reprocessar-confirmar-btn");
confirmarBtn.disabled = true;
try {
const atualizada = await pidReprocessarApuracaoContabil(dcReprocessarApuracaoId, formData);
fecharReprocessarModal();
await carregarLista();
// Se a apuração reprocessada é a que já está aberta na tela de
// revisão, atualiza ela também — senão o contador continuaria vendo
// dados desatualizados até sair e reabrir.
if (apuracaoAtual && apuracaoAtual.id === atualizada.id) {
apuracaoAtual = atualizada;
// As observações não são tocadas pelo reprocessamento (moram em
// `ContabilObservacao`, casadas por chave natural), mas recarrega
// junto mesmo assim: se uma conta mudou de código no PDF novo, a
// thread dela passa a pendurar em outra linha.
await dcCarregarObservacoes();
renderRevisao();
}
await pidAlert("Análise reprocessada com sucesso.");
} catch (e) {
errorEl.textContent = e.message;
} finally {
confirmarBtn.disabled = false;
}
});
// ---- Nova Análise ----
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 resetArquivoField = wireArquivoField("dc-form-arquivo");
document.getElementById("dc-new-btn").addEventListener("click", () => {
document.getElementById("dc-form-arquivo").value = "";
resetArquivoField();
document.getElementById("dc-form-error").textContent = "";
showView("form");
});
document.getElementById("dc-form-back-btn").addEventListener("click", () => showView("list"));
document.getElementById("dc-form-cancel-btn").addEventListener("click", () => showView("list"));
document.getElementById("dc-form-submit-btn").addEventListener("click", async () => {
const errorEl = document.getElementById("dc-form-error");
errorEl.textContent = "";
const arquivo = document.getElementById("dc-form-arquivo").files[0];
if (!arquivo) {
errorEl.textContent = "Anexe o PDF de Balancete + DRE.";
return;
}
const formData = new FormData();
formData.append("arquivo", arquivo);
const submitBtn = document.getElementById("dc-form-submit-btn");
submitBtn.disabled = true;
try {
const apuracao = await pidCriarApuracaoContabil(formData);
apuracaoAtual = apuracao;
await dcCarregarObservacoes();
filtroSeveridade = "todas";
filtroStatusAchado = "pendente";
filtroRegra = null;
renderRevisao();
showView("review");
await carregarLista();
} catch (e) {
errorEl.textContent = e.message;
} finally {
submitBtn.disabled = false;
}
});
// ---- Revisão ----
async function abrirRevisao(id) {
apuracaoAtual = await pidFetchApuracaoContabil(id);
await dcCarregarObservacoes();
filtroSeveridade = "todas";
filtroStatusAchado = "pendente";
filtroRegra = null;
renderRevisao();
showView("review");
}
function renderRevisao() {
const apuracao = apuracaoAtual;
const concluida = apuracao.status === "concluida";
document.getElementById("dc-review-empresa").innerHTML =
`— ${pidDcEscapeHtml(apuracao.codigo_empresa)} - ${pidDcEscapeHtml(apuracao.nome_empresa)} ${pidDcFontePdfAtipicaBadgeHtml(apuracao.fonte_pdf_atipica)}`;
document.getElementById("dc-review-subtitle").textContent =
`CNPJ ${apuracao.cnpj || "não informado"} · Competência ${pidDcFormatCompetencia(apuracao.competencia)} · ${apuracao.contas.length} conta(s)`;
document.getElementById("dc-concluida-aviso").hidden = !concluida;
document.getElementById("dc-concluir-btn").hidden = concluida;
dcContasColapsadas = dcColapsoPadrao(apuracao.contas, dcContaNivel);
dcDreColapsadas = dcColapsoPadrao(apuracao.linhas_dre, (linha) => Math.max(0, linha.nivel));
// Destaque inicial = a última leva já visível de cada ramo (grupo
// colapsado por padrão OU folha genuína sem filho nenhum — ver
// dcUltimaLevaVisivel()) — nasce destacada sem precisar de nenhum
// clique, mesmo ajuste já feito no relatório "Gerar Dashboard" (ver
// pidDcrArvore() em dashboard-contabil-relatorio.html), pedido explícito
// do usuário pra não abrir a tela inteira na cor de grupo/total.
dcContasDestaque = dcUltimaLevaVisivel(apuracao.contas, dcContaNivel, dcContasColapsadas);
dcDreDestaque = dcUltimaLevaVisivel(apuracao.linhas_dre, (linha) => Math.max(0, linha.nivel), dcDreColapsadas);
dcContasDestaqueHistorico = [];
dcDreDestaqueHistorico = [];
dcObsPainelAberto = { conta: null, dre: null, analise_vertical: null };
dcObsEditandoId = null;
dcAchadosContaExpandida = new Set();
dcIndicadoresAtual = null;
// Aba "Análise Vertical" só existe quando o PDF tinha essa seção (nem
// todo relatório traz — ver ContabilApuracao.analise_vertical_meses) —
// some completamente quando vazia, em vez de mostrar uma tabela sem
// nenhuma coluna de mês.
const temAnaliseVertical = (apuracao.analise_vertical_meses || []).length > 0;
const avTabBtn = document.getElementById("dc-av-tab-btn");
const avEstavaAtiva = avTabBtn.classList.contains("is-active");
avTabBtn.hidden = !temAnaliseVertical;
if (!temAnaliseVertical && avEstavaAtiva) {
// A apuração anterior tinha Análise Vertical e estava nessa aba — como
// esta não tem, volta pra "Observações" pra não deixar o botão da aba
// ativa escondido com o painel dela ainda visível.
document.querySelectorAll("#dc-tabs .pa-tab").forEach((t) => t.classList.toggle("is-active", t.dataset.dcTab === "achados"));
document.querySelectorAll("[data-dc-panel]").forEach((panel) => {
panel.hidden = panel.getAttribute("data-dc-panel") !== "achados";
});
}
dcAvColapsadas = dcColapsoPadrao(apuracao.linhas_analise_vertical, (linha) => Math.max(0, linha.nivel));
dcAvDestaque = dcUltimaLevaVisivel(
apuracao.linhas_analise_vertical,
(linha) => Math.max(0, linha.nivel),
dcAvColapsadas
);
dcAvDestaqueHistorico = [];
renderAchados();
renderContas();
renderDre();
renderAnaliseVertical();
renderResumoFechamento();
}
const PID_DC_SEVERIDADE_ORDEM = { alta: 0, media: 1, baixa: 2 };
function achadosFiltrados() {
return apuracaoAtual.achados
.filter((achado) => {
if (filtroSeveridade !== "todas" && achado.severidade !== filtroSeveridade) return false;
if (filtroStatusAchado === "pendente" && achado.status !== "pendente") return false;
if (filtroRegra && achado.regra !== filtroRegra) return false;
return true;
})
.slice()
.sort((a, b) => (PID_DC_SEVERIDADE_ORDEM[a.severidade] ?? 3) - (PID_DC_SEVERIDADE_ORDEM[b.severidade] ?? 3));
}
function pidDcScrollParaLista() {
document.getElementById("dc-achados-list").scrollIntoView({ behavior: "smooth", block: "nearest" });
}
// Resumo do topo da aba Observações: donut por severidade (clicável, filtra
// igual aos chips Alta/Média/Baixa) + grade de cards por categoria/regra
// (clicáveis, isolam achado.regra na lista abaixo). Contagens sempre sobre
// TODOS os achados da apuração, independente dos filtros já aplicados —
// visão geral estável, mesmo espírito do "Distribuição das Análises" do
// sistema externo que inspirou esta seção.
function renderAchadosResumo() {
const achados = apuracaoAtual.achados;
const total = achados.length;
// ---- Donut por severidade ----
const porSeveridade = { alta: 0, media: 0, baixa: 0 };
achados.forEach((achado) => {
if (achado.severidade in porSeveridade) porSeveridade[achado.severidade] += 1;
});
document.getElementById("dc-achados-donut-total").textContent = String(total);
const svg = document.getElementById("dc-achados-donut-svg");
const legend = document.getElementById("dc-achados-donut-legend");
legend.innerHTML = "";
let anelSvg = ``;
if (total > 0) {
let acumulado = 0;
PID_DC_SEVERIDADE_LISTA.forEach((severidade) => {
const qtd = porSeveridade[severidade];
if (qtd === 0) return;
const pct = (qtd / total) * 100;
const offset = 25 - acumulado;
acumulado += pct;
anelSvg += `
`;
});
}
svg.innerHTML = anelSvg;
svg.querySelectorAll("[data-dc-donut-severidade]").forEach((seg) => {
seg.addEventListener("click", () => {
pidDcSelecionarSeveridade(seg.getAttribute("data-dc-donut-severidade"));
pidDcScrollParaLista();
});
});
PID_DC_SEVERIDADE_LISTA.forEach((severidade) => {
const li = document.createElement("li");
li.className = `dc-donut-legend__item${filtroSeveridade === severidade ? " is-active" : ""}`;
li.innerHTML = `${PID_DC_SEVERIDADE_LABELS[severidade]} (${porSeveridade[severidade]})`;
li.addEventListener("click", () => {
pidDcSelecionarSeveridade(severidade);
pidDcScrollParaLista();
});
legend.appendChild(li);
});
// ---- Cards por grupo temático (ver PID_DC_GRUPOS) ----
const porRegra = new Map();
PID_DC_REGRAS.forEach((r) => porRegra.set(r.chave, []));
achados.forEach((achado) => {
if (!porRegra.has(achado.regra)) porRegra.set(achado.regra, []);
porRegra.get(achado.regra).push(achado);
});
const regraPorChave = new Map(PID_DC_REGRAS.map((r) => [r.chave, r]));
const grid = document.getElementById("dc-achados-categorias");
grid.innerHTML = "";
PID_DC_GRUPOS.forEach((grupo, grupoIndex) => {
const cor = PID_DC_CATEGORIA_CORES[grupoIndex % PID_DC_CATEGORIA_CORES.length];
const totalGrupo = grupo.regras.reduce((soma, chave) => soma + (porRegra.get(chave) || []).length, 0);
const card = document.createElement("div");
card.className = "dc-categoria-card";
card.style.setProperty("--dc-categoria-cor", cor);
card.innerHTML = `
`;
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 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;
}
// A partir deste nível (equivalente ao 3º segmento do código de
// classificação, ex. "1.01.01") o Balancete/DRE nascem recolhidos —
// pedido explícito do usuário pra reduzir a poluição visual da árvore
// completa; o contador expande sob demanda pra ver o nível analítico
// abaixo. Mesmo limiar usado no relatório "Gerar Dashboard"
// (_CONTABIL_NIVEL_ABERTO_PADRAO em views.py).
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 da última "leva" de linhas recém-reveladas por um clique de
// expandir — pedido explícito do usuário: destacar as contas que
// aparecem ao expandir um grupo, e mover o destaque (não acumular) pra a
// leva mais recente conforme o contador vai expandindo mais fundo. Cada
// clique de expandir SUBSTITUI esse conjunto, empilhando o que estava
// destacado antes em dc*DestaqueHistorico; um clique de recolher desempilha
// e restaura o destaque anterior (pedido explícito do usuário — fechar uma
// leva revela de novo o destaque de onde se estava antes de abri-la, em vez
// de simplesmente apagar o destaque). Um par conjunto+pilha por árvore, já
// que Balancete e DRE são independentes.
let dcContasDestaque = new Set();
let dcDreDestaque = new Set();
let dcAvDestaque = new Set();
let dcContasDestaqueHistorico = [];
let dcDreDestaqueHistorico = [];
let dcAvDestaqueHistorico = [];
// 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) {
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).
let dcObsPainelAberto = { conta: null, dre: null, analise_vertical: 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;
// "todas" | "cliente" | "internas" — filtro das listas de resumo (chips em
// cada aba e na aba "Dashboard"); não afeta a thread dentro do painel, que
// é sempre o histórico completo daquela conta.
let dcObsFiltro = "todas";
// Rótulo da aba de origem de cada tipo de alvo, usado na lista consolidada
// da aba "Dashboard" (os resumos por aba não precisam, já são de um tipo).
const PID_DC_OBS_ORIGENS = { conta: "Balancete", dre: "D.R.E.", analise_vertical: "Análise Vertical" };
// Mesma chave de ContabilObservacao.chave_conta() no backend — o `codigo`
// de classificação sozinho não é único (o Questor reaproveita o mesmo
// código pra várias contas analíticas de mesma natureza, ex. bancos
// diferentes sob "Depósitos Bancários à Vista"), então a descrição
// desambigua. Usar só `codigo` aqui fazia uma observação escrita numa
// conta aparecer em todas as outras que compartilhavam a classificação.
function dcChaveObsConta(conta) {
return `${conta.codigo}|${conta.descricao}`;
}
function dcChaveObsLinha(linha) {
return `${linha.descricao}|${linha.nivel}`;
}
function dcReindexaObservacoes() {
dcObsIndice = { conta: new Map(), dre: new Map(), analise_vertical: new Map() };
dcObservacoes.forEach((obs) => {
const mapa = dcObsIndice[obs.alvo_tipo];
if (!mapa) return;
if (!mapa.has(obs.alvo_chave)) mapa.set(obs.alvo_chave, []);
mapa.get(obs.alvo_chave).push(obs);
});
}
async function dcCarregarObservacoes() {
dcObservacoes = await pidBuscarObservacoesContabil(apuracaoAtual.id);
dcReindexaObservacoes();
}
function dcObservacoesDe(tipo, chave) {
return dcObsIndice[tipo].get(chave) || [];
}
function dcObservacoesDoTipo(tipo) {
return dcObservacoes.filter((obs) => obs.alvo_tipo === tipo);
}
function dcObsAplicaFiltro(lista) {
if (dcObsFiltro === "cliente") return lista.filter((obs) => obs.mostrar_ao_cliente);
if (dcObsFiltro === "internas") return lista.filter((obs) => !obs.mostrar_ao_cliente);
return lista;
}
function dcRenderTabelaDoTipo(tipo) {
if (tipo === "conta") renderContas();
else if (tipo === "dre") renderDre();
else renderAnaliseVertical();
}
// Qualquer mutação de observação afeta as 3 tabelas (a mesma conta pode
// aparecer em mais de uma), os resumos por aba e a lista consolidada da
// aba "Dashboard" — mais simples (e barato o bastante) re-renderizar tudo
// do que tentar acertar só o pedaço que mudou.
function dcRenderObservacoesTudo() {
renderContas();
renderDre();
renderAnaliseVertical();
renderDashboardObservacoes();
}
// Botão da coluna "Observação": ícone com a contagem de observações
// vigentes daquela conta/linha (as desta competência mais as herdadas),
// colorido quando há alguma — o texto em si nunca aparece na tabela, só no
// painel (pedido antigo do usuário pra não poluir a coluna).
//
// `temObsDescendente` (pedido explícito do usuário: "quando é feito o
// comentário em uma das contas filhas deve aparecer ressaltado o ícone na
// conta mãe também, pois caso esteja recolhida, o usuário consegue
// visualizar se há ou não observações realizadas") só importa quando a
// própria conta/linha não tem observação própria — nesse caso o ícone
// ganha um destaque mais discreto (`--descendente`, cor `--gold`, mesmo
// tom do estado "parcial" do botão "validado") em vez do `--preenchida`
// (`--accent`) usado quando a observação é da própria linha.
function dcObsBotaoHtml(tipo, alvoId, observacoes, temObsDescendente) {
const doMes = observacoes.filter((obs) => !obs.historica).length;
const herdadas = observacoes.length - doMes;
let titulo = "Adicionar observação";
if (observacoes.length) {
const partes = [];
if (doMes) partes.push(`${doMes} desta competência`);
if (herdadas) partes.push(`${herdadas} do histórico`);
titulo = `${observacoes.length} observação(ões): ${partes.join(", ")}`;
} else if (temObsDescendente) {
titulo = "Nenhuma observação nesta conta, mas há observações em contas dentro deste grupo";
}
const classeDestaque = observacoes.length
? " dc-conta-observacao-btn--preenchida"
: temObsDescendente
? " dc-conta-observacao-btn--descendente"
: "";
const contador = observacoes.length ? `${observacoes.length}` : "";
return ``;
}
const PID_DC_ICON_ENCERRAR =
'';
const PID_DC_ICON_REATIVAR =
'';
function dcObsItemHtml(obs, concluida) {
// "Histórica" = veio de uma competência anterior: texto/autor/data
// congelados (o backend também recusa a edição, ver
// `ContabilObservacaoViewSet._garante_texto_editavel`).
const podeEditarTexto = !concluida && !obs.historica;
const assinatura = `${pidDcEscapeHtml(obs.criado_por_nome || "Usuário removido")} · ${pidDcFormatData(obs.criado_em)} · competência ${pidDcFormatCompetencia(obs.competencia_origem)}`;
const selos = [];
if (obs.historica) selos.push('Histórico');
if (obs.encerrada) {
selos.push(
`Encerrada em ${pidDcFormatCompetencia(obs.encerrada_em_competencia)}`
);
}
if (obs.editada) selos.push('Editada');
selos.push(
obs.mostrar_ao_cliente
? 'Aparece ao cliente'
: 'Interna'
);
const acoes = [
``,
];
if (obs.editada) {
acoes.push(
``
);
}
if (podeEditarTexto) {
acoes.push(
``
);
}
if (!concluida) {
acoes.push(
obs.encerrada
? ``
: ``
);
}
const corpo =
dcObsEditandoId === obs.id && podeEditarTexto
? `
`
: `
${pidDcEscapeHtml(obs.texto)}
`;
return `
${assinatura}${selos.join("")}${acoes.join("")}
${corpo}
`;
}
// Modal "Histórico de edições" — só existe pra observações com `editada`
// (ver `ContabilObservacaoEdicao` no backend). `obs.edicoes` já vem
// carregado junto da observação (`ContabilObservacaoSerializer`), então
// não faz nenhuma chamada de API própria.
function pidDcAbrirHistoricoObservacao(obs) {
const edicoes = [...(obs.edicoes || [])].sort((a, b) => new Date(b.editado_em) - new Date(a.editado_em));
const lista = edicoes
.map(
(edicao) => `
` extra logo abaixo da conta/linha (mesma `.dc-obs-edit-row` de
// antes): a thread do histórico em cima, o campo de observação nova
// embaixo. Uma observação nunca é sobrescrita — escrever de novo cria mais
// um item na thread.
function dcObsPainelHtml(tipo, alvoId, observacoes, colspan, concluida) {
const thread = observacoes.length
? observacoes.map((obs) => dcObsItemHtml(obs, concluida)).join("")
: '
Nenhuma observação registrada até agora.
';
const nova = concluida
? ""
: `
`;
const rodape = concluida
? '
Análise concluída, histórico somente leitura.
'
: "";
return `
${thread}
${nova}${rodape}
`;
}
// Handler único das ações de observação, compartilhado pelos três `tbody`
// (Balancete/DRE/Análise Vertical) — devolve `true` quando tratou o clique,
// pra cada listener de tabela continuar cuidando só do que é dele
// (expandir/recolher, validado).
async function dcTrataCliqueObservacao(event, tipo) {
const abrirBtn = event.target.closest("[data-dc-obs-abrir]");
if (abrirBtn) {
const [tipoBtn, idTexto] = abrirBtn.getAttribute("data-dc-obs-abrir").split(":");
const id = Number(idTexto);
dcObsPainelAberto[tipoBtn] = dcObsPainelAberto[tipoBtn] === id ? null : id;
dcObsEditandoId = null;
dcRenderTabelaDoTipo(tipo);
return true;
}
const fecharBtn = event.target.closest("[data-dc-obs-fechar]");
if (fecharBtn) {
dcObsPainelAberto[fecharBtn.getAttribute("data-dc-obs-fechar")] = null;
dcObsEditandoId = null;
dcRenderTabelaDoTipo(tipo);
return true;
}
const criarBtn = event.target.closest("[data-dc-obs-criar]");
if (criarBtn) {
const [alvoTipo, idTexto] = criarBtn.getAttribute("data-dc-obs-criar").split(":");
const alvoId = Number(idTexto);
const erroEl = document.getElementById(`dc-obs-nova-erro-${alvoTipo}-${alvoId}`);
const texto = document.getElementById(`dc-obs-nova-texto-${alvoTipo}-${alvoId}`).value.trim();
const mostrar = document.getElementById(`dc-obs-nova-mostrar-${alvoTipo}-${alvoId}`).checked;
erroEl.textContent = "";
if (!texto) {
erroEl.textContent = "Escreva a observação antes de salvar.";
return true;
}
criarBtn.disabled = true;
try {
await pidCriarObservacaoContabil(apuracaoAtual.id, alvoTipo, alvoId, texto, mostrar);
await dcCarregarObservacoes();
dcRenderObservacoesTudo();
} catch (e) {
erroEl.textContent = e.message;
criarBtn.disabled = false;
}
return true;
}
const historicoBtn = event.target.closest("[data-dc-obs-historico]");
if (historicoBtn) {
const id = Number(historicoBtn.getAttribute("data-dc-obs-historico"));
const obs = dcObservacoes.find((o) => o.id === id);
if (obs) pidDcAbrirHistoricoObservacao(obs);
return true;
}
const editarBtn = event.target.closest("[data-dc-obs-editar]");
if (editarBtn) {
dcObsEditandoId = Number(editarBtn.getAttribute("data-dc-obs-editar"));
dcRenderTabelaDoTipo(tipo);
return true;
}
const editCancelarBtn = event.target.closest("[data-dc-obs-edit-cancelar]");
if (editCancelarBtn) {
dcObsEditandoId = null;
dcRenderTabelaDoTipo(tipo);
return true;
}
const editSalvarBtn = event.target.closest("[data-dc-obs-edit-salvar]");
if (editSalvarBtn) {
const id = Number(editSalvarBtn.getAttribute("data-dc-obs-edit-salvar"));
const erroEl = document.getElementById(`dc-obs-edit-erro-${id}`);
const texto = document.getElementById(`dc-obs-edit-texto-${id}`).value.trim();
erroEl.textContent = "";
if (!texto) {
erroEl.textContent = "A observação não pode ficar vazia. Oculte-a se não quiser mais exibi-la nas próximas competências.";
return true;
}
editSalvarBtn.disabled = true;
try {
await pidAtualizarObservacaoContabil(id, { texto });
dcObsEditandoId = null;
await dcCarregarObservacoes();
dcRenderObservacoesTudo();
} catch (e) {
erroEl.textContent = e.message;
editSalvarBtn.disabled = false;
}
return true;
}
const encerrarBtn = event.target.closest("[data-dc-obs-encerrar]");
if (encerrarBtn) {
const id = Number(encerrarBtn.getAttribute("data-dc-obs-encerrar"));
const ok = await pidConfirm(
"Ocultar esta observação das próximas competências? Ela continua registrada aqui e no histórico desta análise."
);
if (!ok) return true;
try {
await pidEncerrarObservacaoContabil(id, apuracaoAtual.id);
await dcCarregarObservacoes();
dcRenderObservacoesTudo();
} catch (e) {
await pidAlert(e.message);
}
return true;
}
const reativarBtn = event.target.closest("[data-dc-obs-reativar]");
if (reativarBtn) {
const id = Number(reativarBtn.getAttribute("data-dc-obs-reativar"));
try {
await pidReativarObservacaoContabil(id, apuracaoAtual.id);
await dcCarregarObservacoes();
dcRenderObservacoesTudo();
} catch (e) {
await pidAlert(e.message);
}
return true;
}
const visibilidadeBtn = event.target.closest("[data-dc-obs-visibilidade]");
if (visibilidadeBtn) {
if (visibilidadeBtn.disabled) return true;
const id = Number(visibilidadeBtn.getAttribute("data-dc-obs-visibilidade"));
const obs = dcObservacoes.find((o) => o.id === id);
visibilidadeBtn.disabled = true;
try {
await pidAtualizarObservacaoContabil(id, { mostrar_ao_cliente: !obs.mostrar_ao_cliente });
await dcCarregarObservacoes();
dcRenderObservacoesTudo();
} catch (e) {
visibilidadeBtn.disabled = false;
await pidAlert(e.message);
}
return true;
}
return false;
}
// Os chips "Todas / Visíveis ao cliente / Internas" existem em quatro
// lugares (resumo de cada aba + lista consolidada da aba "Dashboard") e
// compartilham a mesma variável — filtrar numa aba filtra em todas, que é o
// comportamento esperado de um filtro de visibilidade global.
document.querySelectorAll("[data-dc-obs-filtros]").forEach((grupo) => {
grupo.addEventListener("click", (event) => {
const btn = event.target.closest("[data-dc-obs-filtro]");
if (!btn) return;
dcObsFiltro = btn.getAttribute("data-dc-obs-filtro");
document.querySelectorAll("[data-dc-obs-filtro]").forEach((chip) => {
chip.classList.toggle("is-active", chip.getAttribute("data-dc-obs-filtro") === dcObsFiltro);
});
if (apuracaoAtual) dcRenderObservacoesTudo();
});
});
function renderContas() {
const body = document.getElementById("dc-contas-body");
body.innerHTML = "";
const concluida = apuracaoAtual.status === "concluida";
const contas = apuracaoAtual.contas;
const niveis = contas.map(dcContaNivel);
const temFilhos = contas.map((_, i) => i + 1 < contas.length && niveis[i + 1] > niveis[i]);
// Pilha dos níveis de ancestrais recolhidos — enquanto uma linha tiver
// algum ancestral na pilha, ela fica oculta (é descendente de um grupo
// fechado), mesmo padrão de renderização de árvore a partir de uma
// lista plana com profundidade já conhecida.
const pilhaColapso = [];
contas.forEach((conta, i) => {
const nivel = niveis[i];
while (pilhaColapso.length && pilhaColapso[pilhaColapso.length - 1] >= nivel) {
pilhaColapso.pop();
}
const oculta = pilhaColapso.length > 0;
if (temFilhos[i] && dcContasColapsadas.has(conta.id)) {
pilhaColapso.push(nivel);
}
if (oculta) return;
const expandida = !dcContasColapsadas.has(conta.id);
const toggle = temFilhos[i]
? ``
: ``;
const validadoInfo = dcValidadoInfo(conta, contas, dcContaNivel, temFilhos[i]);
const observacoesDaConta = dcObservacoesDe("conta", dcChaveObsConta(conta));
const temObsDescendente =
temFilhos[i] && dcTemObservacaoDescendente("conta", contas, dcContaNivel, dcChaveObsConta, conta.id);
const tr = document.createElement("tr");
if (conta.tipo === "S") tr.classList.add("dc-conta-row--sintetica");
if (dcContasDestaque.has(conta.id)) tr.classList.add("dc-conta-row--destaque");
tr.innerHTML = `
`;
card.addEventListener("click", (event) => {
if (event.target.closest("button")) return;
pidDcAbrirFormulaModal(meta);
});
grid.appendChild(card);
});
container.appendChild(secao);
});
}
document.getElementById("dc-dash-indicadores").addEventListener("click", async (event) => {
const editarBtn = event.target.closest("[data-dc-dash-indicador-editar]");
if (editarBtn) {
if (editarBtn.disabled) return;
const definicaoId = Number(editarBtn.getAttribute("data-dc-dash-indicador-editar"));
try {
const definicao = await pidBuscarIndicadorContabilDefinicao(definicaoId);
pidDcAbrirIndicadorModal(definicao);
} catch (e) {
await pidAlert(e.message);
}
return;
}
const btn = event.target.closest("[data-dc-dash-indicador-toggle]");
if (!btn || btn.disabled) return;
const chave = btn.getAttribute("data-dc-dash-indicador-toggle");
const atual = dcIndicadoresAtual.indicadores_ocultos;
const novaLista = atual.includes(chave) ? atual.filter((c) => c !== chave) : [...atual, chave];
btn.disabled = true;
try {
const resposta = await pidAtualizarIndicadoresOcultosContabil(apuracaoAtual.id, novaLista);
dcIndicadoresAtual.indicadores_ocultos = resposta.indicadores_ocultos;
renderDashboardIndicadores();
} catch (e) {
btn.disabled = false;
await pidAlert(e.message);
}
});
// ---- Modal "Ver fórmula" ----
// Aceita um objeto {nome, descricao, formula_texto} direto — não só uma
// chave de `dcIndicadoresAtual.metadados` (que só lista indicadores
// ATIVOS nesta apuração). Um indicador não padrão ainda não selecionado
// pra esta apuração não aparece em `metadados`, mas o hub de
// "Gerenciar Indicadores" (ver pidDcCriaLinhaHub) ainda precisa poder
// mostrar a fórmula dele — por isso o chamador monta o objeto certo em
// cada caso, em vez desta função sempre resolver por chave.
function pidDcAbrirFormulaModal(meta) {
if (!meta) return;
document.getElementById("dc-indicador-formula-modal-titulo").textContent = meta.nome;
document.getElementById("dc-indicador-formula-modal-descricao").textContent =
meta.descricao || "Sem descrição cadastrada.";
document.getElementById("dc-indicador-formula-modal-formula").textContent = meta.formula_texto || "—";
document.getElementById("dc-indicador-formula-modal").hidden = false;
}
document.getElementById("dc-indicador-formula-modal-fechar-btn").addEventListener("click", () => {
document.getElementById("dc-indicador-formula-modal").hidden = true;
});
document.getElementById("dc-indicador-formula-modal").addEventListener("click", (event) => {
if (event.target.id === "dc-indicador-formula-modal") {
document.getElementById("dc-indicador-formula-modal").hidden = true;
}
});
document.getElementById("dc-obs-historico-modal-fechar-btn").addEventListener("click", () => {
document.getElementById("dc-obs-historico-modal").hidden = true;
});
document.getElementById("dc-obs-historico-modal").addEventListener("click", (event) => {
if (event.target.id === "dc-obs-historico-modal") {
document.getElementById("dc-obs-historico-modal").hidden = true;
}
});
// ---- Modal "Novo/Editar Indicador" ----
// Opções de ícone do card no relatório "Gerar Dashboard" — cópia irmã de
// `_CONTABIL_ICONES_SVG` em portal_api/views.py (mesmas chaves/desenhos),
// mantida em sincronia manualmente (ver comentário em
// IndicadorContabilDefinicao.icone, models.py, sobre por que não dá pra
// ter uma fonte única entre o backend e este JS).
const PID_DC_INDICADOR_ICONES = [
{ chave: "barras", rotulo: "Barras", svg: '' },
{
chave: "tendencia-alta",
rotulo: "Tendência de alta",
svg: '',
},
{
chave: "tendencia-baixa",
rotulo: "Tendência de baixa",
svg: '',
},
{
chave: "percentual",
rotulo: "Percentual",
svg: '',
},
{
chave: "pizza",
rotulo: "Gráfico de pizza",
svg: '',
},
{ chave: "atividade", rotulo: "Atividade", svg: '' },
{
chave: "moeda",
rotulo: "Cifrão",
svg: '',
},
{
chave: "alvo",
rotulo: "Alvo/meta",
svg: '',
},
{
chave: "camadas",
rotulo: "Camadas",
svg:
'' +
'',
},
{
chave: "cartao",
rotulo: "Cartão",
svg: '',
},
{
chave: "selo",
rotulo: "Selo/prêmio",
svg: '',
},
];
const PID_DC_INDICADOR_ICONE_PADRAO = "barras";
const indicadorModal = document.getElementById("dc-indicador-modal");
const indicadorComponentesEl = document.getElementById("dc-indicador-componentes");
const indicadorIconesEl = document.getElementById("dc-indicador-icones");
let dcIndicadorEmEdicao = null; // definição completa (id/chave/.../componentes) ou null = criando
let dcIndicadorIconeSelecionado = PID_DC_INDICADOR_ICONE_PADRAO;
let dcIndicadorComponenteSeq = 0;
// Grade de ícones é montada uma única vez (não depende da definição em
// edição, só a seleção ativa muda) — cada botão já é o próprio preview do
// ícone, sem precisar de nenhum outro elemento de preview separado.
PID_DC_INDICADOR_ICONES.forEach((icone) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "dc-ind-icone-btn";
btn.setAttribute("data-dc-icone", icone.chave);
btn.setAttribute("aria-label", icone.rotulo);
btn.title = icone.rotulo;
btn.innerHTML = ``;
indicadorIconesEl.appendChild(btn);
});
function pidDcSelecionaIcone(chave) {
dcIndicadorIconeSelecionado = chave;
indicadorIconesEl.querySelectorAll(".dc-ind-icone-btn").forEach((btn) => {
btn.classList.toggle("is-selecionado", btn.getAttribute("data-dc-icone") === chave);
});
}
indicadorIconesEl.addEventListener("click", (event) => {
const btn = event.target.closest(".dc-ind-icone-btn");
if (btn) pidDcSelecionaIcone(btn.getAttribute("data-dc-icone"));
});
const PID_DC_COMP_TIPO_LABELS = {
contas: "Soma de contas do Balancete",
linha_dre: "Soma de linhas da DRE",
variacao_conta: "Variação de contas (atual − anterior)",
indicador: "Referência a outro indicador",
resultado_liquido: "Resultado Líquido da DRE (última linha)",
};
function pidDcRenderComponentePicker(linhaEl, tipo, valoresIniciais) {
const picker = linhaEl.querySelector(".dc-ind-comp-picker");
if (tipo === "contas" || tipo === "variacao_conta") {
const selecionados = valoresIniciais.contas_codigos || [];
const codigosNestaApuracao = new Set(apuracaoAtual.contas.map((conta) => conta.codigo));
// Um indicador pode ter sido criado a partir de outra apuração
// (empresa/competência diferente) — código selecionado que não
// existe aqui ainda precisa continuar marcado e ser salvo de volta
// do jeito que estava, senão editar um indicador desses nesta tela
// apagaria silenciosamente a conta que só existe na apuração
// original. Aparece como item extra, sinalizado, no topo da lista.
const orfaos = selecionados.filter((codigo) => !codigosNestaApuracao.has(codigo));
const selecionadosSet = new Set(selecionados);
picker.innerHTML = `
`;
} else if (tipo === "indicador") {
const atual = valoresIniciais.indicador_referenciado || "";
const chaveEditando = dcIndicadorEmEdicao ? dcIndicadorEmEdicao.chave : null;
const metadados = (dcIndicadoresAtual && dcIndicadoresAtual.metadados) || {};
const opcoes = Object.keys(metadados)
.filter((chave) => chave !== chaveEditando)
.sort((a, b) => metadados[a].nome.localeCompare(metadados[b].nome));
picker.innerHTML = `
`;
} else if (tipo === "resultado_liquido") {
// Sem seleção nenhuma — sempre resolve pra última linha da DRE desta
// apuração, seja qual for o rótulo (evita quebrar quando o resultado
// muda de sinal e a linha passa de "LUCRO" pra "PREJUÍZO", ou
// vice-versa, ver dashboard_contabil/models.py).
picker.innerHTML = `
Usa automaticamente o resultado líquido do período (última linha da DRE desta apuração), sem precisar escolher nada aqui.
`;
pidDcRenderComponentePicker(linha, tipoInicial, valoresIniciais);
return linha;
}
indicadorComponentesEl.addEventListener("change", (event) => {
if (event.target.classList.contains("dc-ind-comp-tipo")) {
const linha = event.target.closest(".dc-ind-componente");
pidDcRenderComponentePicker(linha, event.target.value, {});
}
});
indicadorComponentesEl.addEventListener("input", (event) => {
if (!event.target.hasAttribute("data-dc-comp-busca")) return;
const linha = event.target.closest(".dc-ind-componente");
const termo = event.target.value.trim().toLowerCase();
linha.querySelectorAll("[data-dc-comp-item]").forEach((item) => {
item.hidden = termo !== "" && !item.getAttribute("data-dc-comp-item").includes(termo);
});
});
indicadorComponentesEl.addEventListener("click", (event) => {
const removerBtn = event.target.closest(".dc-ind-comp-remover");
if (removerBtn) removerBtn.closest(".dc-ind-componente").remove();
});
document.getElementById("dc-indicador-add-componente-btn").addEventListener("click", () => {
indicadorComponentesEl.appendChild(pidDcCriaLinhaComponente({}));
});
function pidDcAbrirIndicadorModal(definicao) {
dcIndicadorEmEdicao = definicao || null;
document.getElementById("dc-indicador-modal-titulo").textContent = definicao ? "Editar Indicador" : "Novo Indicador";
document.getElementById("dc-indicador-modal-nome").value = definicao ? definicao.nome : "";
document.getElementById("dc-indicador-modal-descricao").value = definicao ? definicao.descricao : "";
document.getElementById("dc-indicador-modal-formato").value = definicao ? definicao.formato : "indice";
document.getElementById("dc-indicador-modal-formula").value = definicao ? definicao.formula : "";
document.getElementById("dc-indicador-modal-formula-exibicao").value = definicao ? definicao.formula_exibicao : "";
document.getElementById("dc-indicador-modal-padrao").checked = definicao ? definicao.padrao : true;
pidDcSelecionaIcone(definicao ? definicao.icone : PID_DC_INDICADOR_ICONE_PADRAO);
document.getElementById("dc-indicador-modal-error").textContent = "";
document.getElementById("dc-indicador-modal-excluir-btn").hidden = !definicao;
document.getElementById("dc-indicador-preview").hidden = true;
indicadorComponentesEl.innerHTML = "";
const componentesIniciais = definicao ? definicao.componentes : [];
if (componentesIniciais.length) {
componentesIniciais.forEach((componente) => indicadorComponentesEl.appendChild(pidDcCriaLinhaComponente(componente)));
} else {
indicadorComponentesEl.appendChild(pidDcCriaLinhaComponente({}));
}
indicadorModal.hidden = false;
}
function pidDcFecharIndicadorModal() {
indicadorModal.hidden = true;
dcIndicadorEmEdicao = null;
}
// Fechar por "Cancelar" ou clicando fora (overlay) sempre pede confirmação
// — pedido explícito do usuário, mesmo padrão já usado no modal de "Mais
// informações" (ajuda-aplicacao.js) e no antigo "Fechar sem salvar" do
// Cadastro de Regras de Plano de Saúde (ver "Modal de confirmação
// genérico" no CLAUDE.md da raiz). Só esses dois caminhos passam por
// aqui — depois de salvar/excluir com sucesso, o próprio fluxo chama
// `pidDcFecharIndicadorModal()` direto, sem perguntar (não há o que
// descartar).
async function pidDcFecharIndicadorModalComConfirmacao() {
if (!(await pidConfirm("Sair sem salvar as alterações?", { perigoso: true }))) return;
pidDcFecharIndicadorModal();
}
document.getElementById("dc-indicador-modal-cancel-btn").addEventListener("click", pidDcFecharIndicadorModalComConfirmacao);
indicadorModal.addEventListener("click", (event) => {
if (event.target === indicadorModal) pidDcFecharIndicadorModalComConfirmacao();
});
// Depois de criar/editar/excluir, além de recarregar os cards da aba
// "Dashboard", atualiza o hub também se ele estiver aberto por trás
// (o form de indicador é sempre stacked por cima dele, ver
// `.modal-overlay--top`/pidDcAbrirHubIndicadores) — sem isso, o hub
// ficaria com a lista desatualizada até ser reaberto.
async function pidDcAposSalvarOuExcluirIndicador() {
dcIndicadoresAtual = null;
await renderDashboardTab();
if (!indicadoresHubModal.hidden) await pidDcRenderHubIndicadores();
}
// Reaproveitada pelo "Salvar" e pelo "Calcular com esta apuração" — os
// dois precisam exatamente da mesma leitura do formulário (chave/tipo/
// seleção de cada linha de componente).
function pidDcColetaComponentesForm() {
return [...indicadorComponentesEl.querySelectorAll(".dc-ind-componente")].map((linha) => {
const tipo = linha.querySelector(".dc-ind-comp-tipo").value;
const chave = linha.querySelector(".dc-ind-comp-chave").value.trim();
const componente = { chave, tipo, contas_codigos: [], linhas_dre_descricoes: [], indicador_referenciado: "" };
if (tipo === "contas" || tipo === "variacao_conta") {
componente.contas_codigos = [...linha.querySelectorAll('[data-dc-comp-lista] input[type="checkbox"]:checked')].map(
(cb) => cb.value
);
} else if (tipo === "linha_dre") {
componente.linhas_dre_descricoes = [
...linha.querySelectorAll('[data-dc-comp-lista] input[type="checkbox"]:checked'),
].map((cb) => cb.value);
} else if (tipo === "indicador") {
const select = linha.querySelector(".dc-ind-comp-indicador");
componente.indicador_referenciado = select ? select.value : "";
}
return componente;
});
}
document.getElementById("dc-indicador-modal-save-btn").addEventListener("click", async () => {
const errorEl = document.getElementById("dc-indicador-modal-error");
errorEl.textContent = "";
const componentes = pidDcColetaComponentesForm();
const payload = {
nome: document.getElementById("dc-indicador-modal-nome").value.trim(),
descricao: document.getElementById("dc-indicador-modal-descricao").value.trim(),
formato: document.getElementById("dc-indicador-modal-formato").value,
formula: document.getElementById("dc-indicador-modal-formula").value.trim(),
formula_exibicao: document.getElementById("dc-indicador-modal-formula-exibicao").value.trim(),
padrao: document.getElementById("dc-indicador-modal-padrao").checked,
icone: dcIndicadorIconeSelecionado,
componentes,
};
if (!payload.nome) {
errorEl.textContent = "Informe o nome do indicador.";
return;
}
if (!componentes.length) {
errorEl.textContent = "Adicione pelo menos um componente.";
return;
}
if (componentes.some((c) => !c.chave)) {
errorEl.textContent = "Preencha a chave de todos os componentes.";
return;
}
if (!payload.formula) {
errorEl.textContent = "Informe a fórmula do indicador.";
return;
}
const saveBtn = document.getElementById("dc-indicador-modal-save-btn");
saveBtn.disabled = true;
try {
if (dcIndicadorEmEdicao) {
await pidAtualizarIndicadorContabil(dcIndicadorEmEdicao.id, payload);
} else {
await pidCriarIndicadorContabil(payload);
}
pidDcFecharIndicadorModal();
await pidDcAposSalvarOuExcluirIndicador();
} catch (e) {
errorEl.textContent = e.message;
} finally {
saveBtn.disabled = false;
}
});
// Calcula, contra a apuração aberta agora, o valor de cada componente e
// o resultado final — sem salvar nada — pro contador conferir se a
// fórmula pegou a conta/linha certa antes de confirmar. Reaproveita a
// mesma coleta do formulário do botão "Salvar" (`pidDcColetaComponentesForm`).
document.getElementById("dc-indicador-calcular-btn").addEventListener("click", async () => {
const previewEl = document.getElementById("dc-indicador-preview");
const componentes = pidDcColetaComponentesForm();
const formula = document.getElementById("dc-indicador-modal-formula").value.trim();
previewEl.hidden = false;
if (!componentes.length || componentes.some((c) => !c.chave) || !formula) {
previewEl.innerHTML = `
Preencha a chave de todos os componentes e a fórmula antes de calcular.
`;
} finally {
calcularBtn.disabled = false;
}
});
document.getElementById("dc-indicador-modal-excluir-btn").addEventListener("click", async () => {
if (!dcIndicadorEmEdicao) return;
if (
!(await pidConfirm(`Excluir o indicador "${dcIndicadorEmEdicao.nome}"? Essa ação não pode ser desfeita.`, {
perigoso: true,
}))
) {
return;
}
const errorEl = document.getElementById("dc-indicador-modal-error");
errorEl.textContent = "";
try {
await pidExcluirIndicadorContabil(dcIndicadorEmEdicao.id);
pidDcFecharIndicadorModal();
await pidDcAposSalvarOuExcluirIndicador();
} catch (e) {
errorEl.textContent = e.message;
}
});
// ---- Modal "Gerenciar Indicadores" (hub: lista todos os indicadores —
// padrão e não padrão — pra visualizar/editar/ativar, mesmo os que não
// estão aparecendo nesta apuração agora) ----
const indicadoresHubModal = document.getElementById("dc-indicadores-hub-modal");
function pidDcCriaLinhaHub(definicao, ativo, concluida) {
const linha = document.createElement("div");
linha.className = "dc-ind-hub-item";
linha.innerHTML = `
${
ativo === null
? ``
: ``
}
`;
return linha;
}
async function pidDcRenderHubIndicadores() {
const listaPadrao = document.getElementById("dc-ind-hub-lista-padrao");
const listaNaoPadrao = document.getElementById("dc-ind-hub-lista-nao-padrao");
const vazioPadrao = document.getElementById("dc-ind-hub-vazio-padrao");
const vazioNaoPadrao = document.getElementById("dc-ind-hub-vazio-nao-padrao");
listaPadrao.innerHTML = "";
listaNaoPadrao.innerHTML = "";
let definicoes;
try {
definicoes = await pidBuscarDefinicoesIndicadoresContabil();
} catch (e) {
await pidAlert(e.message);
return;
}
const selecionados = new Set((dcIndicadoresAtual && dcIndicadoresAtual.indicadores_selecionados) || []);
const concluida = apuracaoAtual.status === "concluida";
const padrao = definicoes.filter((d) => d.padrao);
const naoPadrao = definicoes.filter((d) => !d.padrao);
vazioPadrao.hidden = padrao.length > 0;
vazioNaoPadrao.hidden = naoPadrao.length > 0;
padrao.forEach((definicao) => listaPadrao.appendChild(pidDcCriaLinhaHub(definicao, null, concluida)));
naoPadrao.forEach((definicao) =>
listaNaoPadrao.appendChild(pidDcCriaLinhaHub(definicao, selecionados.has(definicao.chave), concluida))
);
}
async function pidDcAbrirHubIndicadores() {
indicadoresHubModal.hidden = false;
await pidDcRenderHubIndicadores();
}
document.getElementById("dc-indicadores-gerenciar-btn").addEventListener("click", pidDcAbrirHubIndicadores);
document.getElementById("dc-indicadores-hub-fechar-btn").addEventListener("click", () => {
indicadoresHubModal.hidden = true;
});
indicadoresHubModal.addEventListener("click", (event) => {
if (event.target === indicadoresHubModal) indicadoresHubModal.hidden = true;
});
document.getElementById("dc-indicadores-hub-novo-btn").addEventListener("click", () => pidDcAbrirIndicadorModal(null));
indicadoresHubModal.addEventListener("click", async (event) => {
const verBtn = event.target.closest("[data-dc-hub-ver]");
if (verBtn) {
const definicaoId = Number(verBtn.getAttribute("data-dc-hub-ver"));
try {
const definicao = await pidBuscarIndicadorContabilDefinicao(definicaoId);
pidDcAbrirFormulaModal({
nome: definicao.nome,
descricao: definicao.descricao,
formula_texto: definicao.formula,
});
} catch (e) {
await pidAlert(e.message);
}
return;
}
const editarBtn = event.target.closest("[data-dc-hub-editar]");
if (editarBtn) {
if (editarBtn.disabled) return;
const definicaoId = Number(editarBtn.getAttribute("data-dc-hub-editar"));
try {
const definicao = await pidBuscarIndicadorContabilDefinicao(definicaoId);
pidDcAbrirIndicadorModal(definicao);
} catch (e) {
await pidAlert(e.message);
}
}
});
indicadoresHubModal.addEventListener("change", async (event) => {
const checkbox = event.target.closest("[data-dc-hub-toggle]");
if (!checkbox || checkbox.disabled) return;
const chave = checkbox.getAttribute("data-dc-hub-toggle");
const atual = new Set(dcIndicadoresAtual.indicadores_selecionados || []);
if (checkbox.checked) atual.add(chave);
else atual.delete(chave);
checkbox.disabled = true;
try {
await pidAtualizarIndicadoresSelecionadosContabil(apuracaoAtual.id, [...atual]);
dcIndicadoresAtual = null;
await renderDashboardTab();
await pidDcRenderHubIndicadores();
} catch (e) {
checkbox.checked = !checkbox.checked;
checkbox.disabled = false;
await pidAlert(e.message);
}
});
// Lista consolidada da aba "Dashboard": as observações vigentes das três
// tabelas (Balancete/DRE/Análise Vertical) mais as justificativas de
// auditoria — pré-visualização do que vai (ou não) pro relatório do
// cliente. Observação segue o histórico (`ContabilObservacao`), auditoria
// continua presa à apuração (`ContabilAchado.oculto_no_relatorio`), então
// os dois tipos de item têm ações diferentes.
function renderDashboardObservacoes() {
const lista = document.getElementById("dc-dash-observacoes-list");
const empty = document.getElementById("dc-dash-observacoes-empty");
const concluida = apuracaoAtual.status === "concluida";
const observacoes = dcObsAplicaFiltro(dcObservacoes);
const achados = apuracaoAtual.achados.filter((achado) => {
if (!achado.observacao_contador) return false;
if (dcObsFiltro === "cliente") return !achado.oculto_no_relatorio;
if (dcObsFiltro === "internas") return achado.oculto_no_relatorio;
return true;
});
const html = [
...observacoes.map((obs) => dcObsResumoItemHtml(obs, PID_DC_OBS_ORIGENS[obs.alvo_tipo], concluida)),
...achados.map(
(achado) => `
Auditoria${pidDcEscapeHtml(achado.titulo)}
${pidDcEscapeHtml(achado.observacao_contador)}
`
),
];
empty.hidden = html.length > 0;
lista.innerHTML = html.join("");
}
document.getElementById("dc-dash-observacoes-list").addEventListener("click", async (event) => {
const achadoBtn = event.target.closest("[data-dc-dash-achado-toggle]");
if (achadoBtn) {
if (achadoBtn.disabled) return;
const id = Number(achadoBtn.getAttribute("data-dc-dash-achado-toggle"));
const achado = apuracaoAtual.achados.find((a) => a.id === id);
achadoBtn.disabled = true;
try {
const atualizado = await pidAlternarOcultoAchadoContabil(id, !achado.oculto_no_relatorio);
const indice = apuracaoAtual.achados.findIndex((a) => a.id === id);
apuracaoAtual.achados[indice] = atualizado;
renderDashboardObservacoes();
} catch (e) {
achadoBtn.disabled = false;
await pidAlert(e.message);
}
return;
}
await dcTrataCliqueObservacao(event, "conta");
});
// ---- Resumo de observações no final de cada aba ----
// Cada aba (Balancete/DRE/Análise Vertical) lista as observações vigentes
// daquela seção — as escritas nesta competência e as herdadas do histórico
// —, com a assinatura de quem escreveu, o botão de olho (mostrar ao
// cliente) e o de encerrar/reativar, sem precisar abrir conta por conta.
// Os chips "Todas / Visíveis ao cliente / Internas" filtram as quatro
// listas ao mesmo tempo (ver dcObsFiltro).
function dcObsResumoItemHtml(obs, origem, concluida) {
const selos = [];
if (obs.historica) selos.push('Histórico');
if (obs.encerrada) {
selos.push(
`Encerrada em ${pidDcFormatCompetencia(obs.encerrada_em_competencia)}`
);
}
const acaoEncerrar = concluida
? ""
: obs.encerrada
? ``
: ``;
return `