portal_publico/static/js/dashboard-contabil.js

3476 lines
158 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const PID_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 =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z"/><circle cx="12" cy="12" r="3"/></svg>';
const PID_DC_ICON_OLHO_FECHADO =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 7 11 7a13.16 13.16 0 0 1-1.67 2.68M6.61 6.61C3.35 8.44 1 12 1 12s4 7 11 7a9.26 9.26 0 0 0 5.39-1.61M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>';
const PID_DC_ICON_EDITAR =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>';
const PID_DC_ICON_LIXEIRA =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const PID_DC_ICON_HISTORICO =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><path d="M12 7v5l4 2"/></svg>';
// 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 `<span class="dc-hover-tooltip__texto">` (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 = `
<td>${pidDcEscapeHtml(apuracao.codigo_empresa)} - ${pidDcEscapeHtml(apuracao.nome_empresa)} ${pidDcFontePdfAtipicaBadgeHtml(apuracao.fonte_pdf_atipica)}</td>
<td>${pidDcFormatCompetencia(apuracao.competencia)}</td>
<td>${apuracao.total_achados_pendentes}</td>
<td><span class="status-pill ${apuracao.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"}">${PID_DC_STATUS_LABELS[apuracao.status] || apuracao.status}</span></td>
<td>${pidDcEscapeHtml(apuracao.criado_por_nome || "-")}</td>
<td>${pidDcFormatData(apuracao.criado_em)}</td>
<td class="pa-row-actions">
<button type="button" class="btn-outline" data-dc-abrir="${apuracao.id}">Abrir</button>
${
apuracao.status === "concluida"
? ""
: `<button type="button" class="icon-btn" data-dc-reprocessar-abrir="${apuracao.id}" aria-label="Reprocessar" title="Reprocessar (anexar outro arquivo pra esta mesma competência)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
</button>`
}
<button type="button" class="icon-btn" data-dc-excluir="${apuracao.id}" aria-label="Excluir" title="Excluir">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</td>
`;
body.appendChild(tr);
});
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 <th> 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, "&quot;");
}
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 = `
<button type="button" class="dc-col-filter__btn" aria-label="Filtrar coluna" aria-expanded="false" title="Filtrar">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" stroke-linejoin="round"/></svg>
</button>
<div class="dc-col-filter__popup" hidden>
<input type="text" class="checklist-search" placeholder="Buscar..." />
<label class="modal-checkbox checklist-select-all"><input type="checkbox" checked /> Selecionar todos</label>
<div class="checklist-box"></div>
<div class="dc-col-filter__actions">
<button type="button" class="btn-ghost dc-col-filter__limpar">Limpar</button>
<button type="button" class="btn-solid dc-col-filter__aplicar">Aplicar</button>
</div>
</div>`;
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) =>
`<label class="checklist-item"><input type="checkbox" data-valor-filtro="${dcEscapeAttr(v)}" ${marcados.has(v) ? "checked" : ""} /><span class="checklist-item__info">${pidDcEscapeHtml(dcLabelValorFiltroLista(campo, v))}</span></label>`
)
.join("")
: `<p class="checklist-empty">Nenhum valor encontrado.</p>`;
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 = `<circle class="dc-donut-svg__bg" cx="18" cy="18" r="15.9155" />`;
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 += `
<circle
class="dc-donut-svg__seg${filtroSeveridade === severidade ? " is-active" : ""}"
data-dc-donut-severidade="${severidade}"
cx="18" cy="18" r="15.9155"
stroke="${PID_DC_SEVERIDADE_CORES[severidade]}"
stroke-dasharray="${pct} ${100 - pct}"
stroke-dashoffset="${offset}"
/>
`;
});
}
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 = `<span class="dc-donut-legend__dot" style="background:${PID_DC_SEVERIDADE_CORES[severidade]}"></span>${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 = `
<div class="dc-categoria-card__header">
<span class="dc-categoria-card__dot"></span>
<span class="dc-categoria-card__titulo">${pidDcEscapeHtml(grupo.label)}</span>
<span class="dc-categoria-card__contagem">${totalGrupo}</span>
</div>
<div class="dc-categoria-card__regras"></div>
`;
const regrasEl = card.querySelector(".dc-categoria-card__regras");
grupo.regras.forEach((chave) => {
const regraInfo = regraPorChave.get(chave);
if (!regraInfo) return;
const itens = porRegra.get(chave) || [];
const clicavel = itens.length > 0;
const linha = document.createElement("div");
linha.className = [
"dc-categoria-card__regra",
clicavel ? "dc-categoria-card__regra--clicavel" : "dc-categoria-card__regra--vazia",
filtroRegra === chave ? "is-active" : "",
].filter(Boolean).join(" ");
linha.innerHTML = `
<span class="dc-categoria-card__regra-label">${pidDcEscapeHtml(regraInfo.label)}</span>
<span class="dc-categoria-card__regra-contagem">${itens.length}</span>
`;
if (clicavel) {
linha.addEventListener("click", () => {
filtroRegra = filtroRegra === chave ? null : chave;
renderAchados();
pidDcScrollParaLista();
});
}
regrasEl.appendChild(linha);
});
grid.appendChild(card);
});
}
function renderAchados() {
renderAchadosResumo();
const filtroAtivo = document.getElementById("dc-regra-filtro-ativo");
const regraInfo = filtroRegra ? PID_DC_REGRAS.find((r) => r.chave === filtroRegra) : null;
filtroAtivo.hidden = !regraInfo;
if (regraInfo) document.getElementById("dc-regra-filtro-ativo-label").textContent = regraInfo.label;
const lista = document.getElementById("dc-achados-list");
const empty = document.getElementById("dc-achados-empty");
const achados = achadosFiltrados();
lista.innerHTML = "";
empty.hidden = achados.length > 0;
achados.forEach((achado) => {
const card = document.createElement("div");
card.className = `dc-achado-card dc-achado-card--${achado.severidade}`;
const podeEditar = apuracaoAtual.status !== "concluida" && achado.status === "pendente";
const conta = achado.conta != null ? apuracaoAtual.contas.find((c) => c.id === achado.conta) : null;
const expandida = dcAchadosContaExpandida.has(achado.id);
card.innerHTML = `
<div class="dc-achado-card__main">
<div class="dc-achado-card__titulo">
<span class="dc-badge dc-badge--${achado.severidade}">${PID_DC_SEVERIDADE_LABELS[achado.severidade] || achado.severidade}</span>
<span class="dc-badge dc-badge--${achado.status}">${PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status}</span>
${pidDcEscapeHtml(achado.titulo)}
</div>
<div class="dc-achado-card__mensagem">${pidDcEscapeHtml(achado.mensagem)}</div>
${conta ? `
<button type="button" class="dc-achado-card__toggle-conta${expandida ? " is-expanded" : ""}" data-dc-achado-toggle-conta="${achado.id}">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
${expandida ? "Ocultar conta usada no apontamento" : "Ver conta usada no apontamento"}
</button>
${expandida ? `
<dl class="dc-achado-card__conta">
<div><dt>Conta</dt><dd>${pidDcEscapeHtml(conta.codigo)} — ${pidDcEscapeHtml(conta.descricao)}</dd></div>
<div><dt>Saldo Anterior</dt><dd>${pidDcFormatMoeda(conta.saldo_anterior)}</dd></div>
<div><dt>Débito</dt><dd>${pidDcFormatMoeda(conta.debito)}</dd></div>
<div><dt>Crédito</dt><dd>${pidDcFormatMoeda(conta.credito)}</dd></div>
<div><dt>Saldo Atual</dt><dd>${pidDcFormatMoeda(conta.saldo_atual)}</dd></div>
</dl>
` : ""}
` : ""}
${achado.observacao_contador ? `<div class="dc-achado-card__observacao">${pidDcEscapeHtml(achado.observacao_contador)}${achado.tratado_por_nome ? ` — ${pidDcEscapeHtml(achado.tratado_por_nome)}` : ""}</div>` : ""}
</div>
<div class="dc-achado-card__actions">
${podeEditar ? `<button type="button" class="btn-outline" data-dc-achado-editar="${achado.id}">Revisar</button>` : ""}
</div>
`;
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 (`<tr class="dc-obs-edit-row">`),
// 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 =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>';
// 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 =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
// 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 `<span>` 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 `<span>` 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 `<span class="dc-hover-tooltip">
<span class="dc-hover-tooltip__gatilho ${gatilhoClasse}" tabindex="0" aria-label="${pidDcEscapeHtml(texto)}">${gatilhoHtml}</span>
<span class="dc-hover-tooltip__texto" role="tooltip">${pidDcEscapeHtml(texto)}</span>
</span>`;
}
// 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 =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><path d="M12 9v4M12 17h.01"/></svg>';
// `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 =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>';
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 ? `<span class="dc-obs-contador">${observacoes.length}</span>` : "";
return `<button type="button" class="dc-conta-observacao-btn${classeDestaque}" data-dc-obs-abrir="${tipo}:${alvoId}" title="${pidDcEscapeHtml(titulo)}" aria-label="${pidDcEscapeHtml(titulo)}">
${PID_DC_OBSERVACAO_ICONE}${contador}
</button>`;
}
const PID_DC_ICON_ENCERRAR =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6v14h12V6"/></svg>';
const PID_DC_ICON_REATIVAR =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 2.64-6.36"/><path d="M3 3v6h6"/></svg>';
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('<span class="dc-obs-selo dc-obs-selo--historica">Histórico</span>');
if (obs.encerrada) {
selos.push(
`<span class="dc-obs-selo dc-obs-selo--encerrada">Encerrada em ${pidDcFormatCompetencia(obs.encerrada_em_competencia)}</span>`
);
}
if (obs.editada) selos.push('<span class="dc-obs-selo dc-obs-selo--editada">Editada</span>');
selos.push(
obs.mostrar_ao_cliente
? '<span class="dc-obs-selo dc-obs-selo--cliente">Aparece ao cliente</span>'
: '<span class="dc-obs-selo">Interna</span>'
);
const acoes = [
`<button type="button" class="icon-btn dc-dash-toggle-btn" data-dc-obs-visibilidade="${obs.id}" title="${obs.mostrar_ao_cliente ? "Ocultar do relatório do cliente" : "Mostrar no relatório do cliente"}" aria-label="${obs.mostrar_ao_cliente ? "Ocultar do relatório" : "Mostrar no relatório"}" ${concluida ? "disabled" : ""}>${obs.mostrar_ao_cliente ? PID_DC_ICON_OLHO_ABERTO : PID_DC_ICON_OLHO_FECHADO}</button>`,
];
if (obs.editada) {
acoes.push(
`<button type="button" class="icon-btn" data-dc-obs-historico="${obs.id}" title="Ver histórico de edições" aria-label="Ver histórico de edições">${PID_DC_ICON_HISTORICO}</button>`
);
}
if (podeEditarTexto) {
acoes.push(
`<button type="button" class="icon-btn" data-dc-obs-editar="${obs.id}" title="Editar esta observação" aria-label="Editar observação">${PID_DC_ICON_EDITAR}</button>`
);
}
if (!concluida) {
acoes.push(
obs.encerrada
? `<button type="button" class="icon-btn" data-dc-obs-reativar="${obs.id}" title="Voltar a exibir nas próximas competências" aria-label="Reativar observação">${PID_DC_ICON_REATIVAR}</button>`
: `<button type="button" class="icon-btn" data-dc-obs-encerrar="${obs.id}" title="Ocultar das próximas competências (continua visível nesta)" aria-label="Encerrar observação">${PID_DC_ICON_ENCERRAR}</button>`
);
}
const corpo =
dcObsEditandoId === obs.id && podeEditarTexto
? `<div class="modal-field">
<textarea id="dc-obs-edit-texto-${obs.id}" rows="3">${pidDcEscapeHtml(obs.texto)}</textarea>
</div>
<p class="modal-error" id="dc-obs-edit-erro-${obs.id}"></p>
<div class="modal-actions">
<button type="button" class="btn-outline" data-dc-obs-edit-cancelar="${obs.id}">Cancelar</button>
<button type="button" class="btn-solid" data-dc-obs-edit-salvar="${obs.id}">Salvar</button>
</div>`
: `<p class="dc-obs-item__texto">${pidDcEscapeHtml(obs.texto)}</p>`;
return `
<article class="dc-obs-item${obs.historica ? " dc-obs-item--historica" : ""}${obs.encerrada ? " dc-obs-item--encerrada" : ""}">
<header class="dc-obs-item__head">
<span class="dc-obs-item__assinatura">${assinatura}</span>
<span class="dc-obs-item__selos">${selos.join("")}</span>
<span class="dc-obs-item__acoes">${acoes.join("")}</span>
</header>
${corpo}
</article>
`;
}
// 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) => `
<div class="dc-obs-historico-item">
<span class="dc-obs-historico-item__meta">${pidDcEscapeHtml(edicao.editado_por_nome || "Usuário removido")} · ${pidDcFormatData(edicao.editado_em)}</span>
<p class="dc-obs-historico-item__texto dc-obs-historico-item__texto--anterior">${pidDcEscapeHtml(edicao.texto_anterior)}</p>
<p class="dc-obs-historico-item__texto">${pidDcEscapeHtml(edicao.texto_novo)}</p>
</div>
`
)
.join("");
document.getElementById("dc-obs-historico-lista").innerHTML =
lista || '<p class="dc-obs-thread__vazio">Nenhuma edição registrada.</p>';
document.getElementById("dc-obs-historico-modal").hidden = false;
}
// `<tr>` 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("")
: '<p class="dc-obs-thread__vazio">Nenhuma observação registrada até agora.</p>';
const nova = concluida
? ""
: `<div class="dc-obs-nova">
<div class="modal-field">
<textarea id="dc-obs-nova-texto-${tipo}-${alvoId}" rows="3" placeholder="Escreva uma observação nova..."></textarea>
</div>
<label class="modal-checkbox">
<input type="checkbox" id="dc-obs-nova-mostrar-${tipo}-${alvoId}" />
Mostrar esta observação ao cliente no relatório
</label>
<p class="modal-error" id="dc-obs-nova-erro-${tipo}-${alvoId}"></p>
<div class="modal-actions">
<button type="button" class="btn-outline" data-dc-obs-fechar="${tipo}">Fechar</button>
<button type="button" class="btn-solid" data-dc-obs-criar="${tipo}:${alvoId}">Salvar observação</button>
</div>
</div>`;
const rodape = concluida
? '<p class="dc-obs-thread__vazio">Análise concluída, histórico somente leitura.</p>'
: "";
return `
<td colspan="${colspan}">
<div class="dc-obs-edit">
<div class="dc-obs-thread">${thread}</div>
${nova}${rodape}
</div>
</td>
`;
}
// 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]
? `<button type="button" class="dc-conta-toggle${expandida ? " is-expanded" : ""}" data-dc-conta-toggle="${conta.id}" aria-label="${expandida ? "Recolher" : "Expandir"} ${pidDcEscapeHtml(conta.descricao)}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>`
: `<span class="dc-conta-toggle-spacer"></span>`;
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 = `
<td>${pidDcEscapeHtml(conta.conta_numero)}</td>
<td>${pidDcEscapeHtml(conta.codigo)}</td>
<td style="padding-left: calc(var(--space-4) + ${nivel * 18}px)">
<span class="dc-conta-desc-cell">${toggle}<span>${pidDcEscapeHtml(conta.descricao)}</span></span>
</td>
<td>${pidDcFormatMoeda(conta.saldo_anterior)}</td>
<td>${pidDcFormatMoeda(conta.debito)}</td>
<td>${pidDcFormatMoeda(conta.credito)}</td>
<td>${pidDcFormatMoeda(conta.saldo_atual)}</td>
<td>
<div class="dc-obs-cell-actions">
<button type="button" class="dc-conta-validado-btn${validadoInfo.classe}" data-dc-conta-validado="${conta.id}" title="${validadoInfo.title}" aria-label="${validadoInfo.label}" aria-pressed="${validadoInfo.pressed}" ${concluida ? "disabled" : ""}>
${PID_DC_VALIDADO_ICONE}
</button>
${dcObsBotaoHtml("conta", conta.id, observacoesDaConta, temObsDescendente)}
${pidDcAlteradaBadgeHtml(
conta.alterada_reprocessamento,
conta.validado,
conta.valor_anterior_reprocessamento != null ? pidDcFormatMoeda(conta.valor_anterior_reprocessamento) : null
)}
</div>
</td>
`;
body.appendChild(tr);
if (dcObsPainelAberto.conta === conta.id) {
const editRow = document.createElement("tr");
editRow.className = "dc-obs-edit-row";
editRow.innerHTML = dcObsPainelHtml("conta", conta.id, observacoesDaConta, 8, concluida);
body.appendChild(editRow);
}
});
renderContasObsResumo();
}
document.getElementById("dc-contas-body").addEventListener("click", async (event) => {
const toggleBtn = event.target.closest("[data-dc-conta-toggle]");
if (toggleBtn) {
const id = Number(toggleBtn.getAttribute("data-dc-conta-toggle"));
const estavaColapsada = dcContasColapsadas.has(id);
if (estavaColapsada) dcContasColapsadas.delete(id);
else dcContasColapsadas.add(id);
// Expandir empilha o destaque atual e troca pros filhos recém-revelados;
// recolher desempilha e restaura o destaque de antes — ver dcContasDestaque acima.
if (estavaColapsada) {
dcContasDestaqueHistorico.push(dcContasDestaque);
dcContasDestaque = dcFilhosDiretos(apuracaoAtual.contas, dcContaNivel, id);
} else {
dcContasDestaque = dcContasDestaqueHistorico.pop() || new Set();
}
renderContas();
return;
}
const validadoBtn = event.target.closest("[data-dc-conta-validado]");
if (validadoBtn) {
if (validadoBtn.disabled) return;
const id = Number(validadoBtn.getAttribute("data-dc-conta-validado"));
validadoBtn.disabled = true;
try {
await dcClicarValidadoConta(id);
} catch (e) {
await pidAlert(e.message);
} finally {
validadoBtn.disabled = false;
}
return;
}
await dcTrataCliqueObservacao(event, "conta");
});
// Mesma árvore recolhível do Balancete (ver renderContas), agora sobre
// ContabilLinhaDre.nivel — que já chega pronto do backend na ordem de
// leitura do PDF, então "ter filhos" é só olhar se a próxima linha tem
// nível maior que o da linha atual.
function renderDre() {
const body = document.getElementById("dc-dre-body");
body.innerHTML = "";
const concluida = apuracaoAtual.status === "concluida";
const linhas = apuracaoAtual.linhas_dre;
const niveis = linhas.map((linha) => Math.max(0, linha.nivel));
const temFilhos = linhas.map((_, i) => i + 1 < linhas.length && niveis[i + 1] > niveis[i]);
const pilhaColapso = [];
linhas.forEach((linha, i) => {
const nivel = niveis[i];
while (pilhaColapso.length && pilhaColapso[pilhaColapso.length - 1] >= nivel) {
pilhaColapso.pop();
}
const oculta = pilhaColapso.length > 0;
if (temFilhos[i] && dcDreColapsadas.has(linha.id)) {
pilhaColapso.push(nivel);
}
if (oculta) return;
const expandida = !dcDreColapsadas.has(linha.id);
const toggle = temFilhos[i]
? `<button type="button" class="dc-conta-toggle${expandida ? " is-expanded" : ""}" data-dc-dre-toggle="${linha.id}" aria-label="${expandida ? "Recolher" : "Expandir"} ${pidDcEscapeHtml(linha.descricao)}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>`
: `<span class="dc-conta-toggle-spacer"></span>`;
const validadoInfo = dcValidadoInfo(linha, linhas, (l) => Math.max(0, l.nivel), temFilhos[i]);
const observacoesDaLinha = dcObservacoesDe("dre", dcChaveObsLinha(linha));
const temObsDescendente =
temFilhos[i] &&
dcTemObservacaoDescendente("dre", linhas, (l) => Math.max(0, l.nivel), dcChaveObsLinha, linha.id);
const tr = document.createElement("tr");
tr.className = linha.totalizador ? "dc-dre-row dc-dre-row--totalizador" : "dc-dre-row";
if (dcDreDestaque.has(linha.id)) tr.classList.add("dc-conta-row--destaque");
const indentacao = nivel * 16;
tr.innerHTML = `
<td style="padding-left: calc(var(--space-4) + ${indentacao}px)">
<span class="dc-conta-desc-cell">${toggle}<span>${pidDcEscapeHtml(linha.descricao)}</span></span>
</td>
<td>${pidDcFormatMoeda(linha.valor)}</td>
<td>
<div class="dc-obs-cell-actions">
<button type="button" class="dc-conta-validado-btn${validadoInfo.classe}" data-dc-dre-validado="${linha.id}" title="${validadoInfo.title}" aria-label="${validadoInfo.label}" aria-pressed="${validadoInfo.pressed}" ${concluida ? "disabled" : ""}>
${PID_DC_VALIDADO_ICONE}
</button>
${dcObsBotaoHtml("dre", linha.id, observacoesDaLinha, temObsDescendente)}
${pidDcAlteradaBadgeHtml(
linha.alterada_reprocessamento,
linha.validado,
linha.valor_anterior_reprocessamento != null ? pidDcFormatMoeda(linha.valor_anterior_reprocessamento) : null
)}
</div>
</td>
`;
body.appendChild(tr);
if (dcObsPainelAberto.dre === linha.id) {
const editRow = document.createElement("tr");
editRow.className = "dc-obs-edit-row";
editRow.innerHTML = dcObsPainelHtml("dre", linha.id, observacoesDaLinha, 3, concluida);
body.appendChild(editRow);
}
});
renderDreObsResumo();
}
document.getElementById("dc-dre-body").addEventListener("click", async (event) => {
const toggleBtn = event.target.closest("[data-dc-dre-toggle]");
if (toggleBtn) {
const id = Number(toggleBtn.getAttribute("data-dc-dre-toggle"));
const estavaColapsada = dcDreColapsadas.has(id);
if (estavaColapsada) dcDreColapsadas.delete(id);
else dcDreColapsadas.add(id);
const nivelFn = (linha) => Math.max(0, linha.nivel);
if (estavaColapsada) {
dcDreDestaqueHistorico.push(dcDreDestaque);
dcDreDestaque = dcFilhosDiretos(apuracaoAtual.linhas_dre, nivelFn, id);
} else {
dcDreDestaque = dcDreDestaqueHistorico.pop() || new Set();
}
renderDre();
return;
}
const validadoBtn = event.target.closest("[data-dc-dre-validado]");
if (validadoBtn) {
if (validadoBtn.disabled) return;
const id = Number(validadoBtn.getAttribute("data-dc-dre-validado"));
validadoBtn.disabled = true;
try {
await dcClicarValidadoLinha(id);
} catch (e) {
await pidAlert(e.message);
} finally {
validadoBtn.disabled = false;
}
return;
}
await dcTrataCliqueObservacao(event, "dre");
});
// Cabeçalho de coluna da Análise Vertical é montado em JS (não hardcoded
// no HTML) porque o número de meses varia conforme o período do PDF
// (normalmente 3, ver ContabilApuracao.analise_vertical_meses) — cada mês
// vira duas colunas (Valor/Variação).
function renderAnaliseVerticalHead() {
const meses = apuracaoAtual.analise_vertical_meses || [];
const row = document.getElementById("dc-av-head-row");
row.innerHTML = `
<th>Descrição</th>
${meses
.map((mes) => {
const label = pidDcEscapeHtml(pidDcFormatMesAnaliseVertical(mes));
return `<th>${label} — Valor</th><th>${label} — Variação</th>`;
})
.join("")}
<th>
<span class="dc-th-linha">Observação
<button type="button" class="icon-btn dc-reset-formatacao-btn" data-dc-reset-formatacao="analise-vertical" aria-label="Restaurar formatação padrão da Análise Vertical" title="Restaurar formatação padrão (recolher grupos como no início)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 2.64-6.36"/><path d="M3 3v6h6"/></svg>
</button>
</span>
</th>
`;
}
// Mesma árvore recolhível do Balancete/DRE (ver renderContas/renderDre),
// agora sobre ContabilLinhaAnaliseVertical — cada linha mostra um
// valor+percentual por mês (colunas dinâmicas) em vez de um valor único.
function renderAnaliseVertical() {
renderAnaliseVerticalHead();
const body = document.getElementById("dc-av-body");
body.innerHTML = "";
const concluida = apuracaoAtual.status === "concluida";
const linhas = apuracaoAtual.linhas_analise_vertical;
const colspan = 2 + (apuracaoAtual.analise_vertical_meses || []).length * 2;
const nivelFn = (l) => Math.max(0, l.nivel);
const niveis = linhas.map(nivelFn);
const temFilhos = linhas.map((_, i) => i + 1 < linhas.length && niveis[i + 1] > niveis[i]);
const pilhaColapso = [];
linhas.forEach((linha, i) => {
const nivel = niveis[i];
while (pilhaColapso.length && pilhaColapso[pilhaColapso.length - 1] >= nivel) {
pilhaColapso.pop();
}
const oculta = pilhaColapso.length > 0;
if (temFilhos[i] && dcAvColapsadas.has(linha.id)) {
pilhaColapso.push(nivel);
}
if (oculta) return;
const expandida = !dcAvColapsadas.has(linha.id);
const toggle = temFilhos[i]
? `<button type="button" class="dc-conta-toggle${expandida ? " is-expanded" : ""}" data-dc-av-toggle="${linha.id}" aria-label="${expandida ? "Recolher" : "Expandir"} ${pidDcEscapeHtml(linha.descricao)}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>`
: `<span class="dc-conta-toggle-spacer"></span>`;
const validadoInfo = dcValidadoInfo(linha, linhas, nivelFn, temFilhos[i]);
const observacoesDaLinha = dcObservacoesDe("analise_vertical", dcChaveObsLinha(linha));
const temObsDescendente =
temFilhos[i] && dcTemObservacaoDescendente("analise_vertical", linhas, nivelFn, dcChaveObsLinha, linha.id);
const colunasMensais = linha.valores
.map(
(v) =>
`<td>${pidDcFormatMoeda(v.valor)}</td><td>${pidDcFormatPercentualAnaliseVertical(v.percentual)}</td>`
)
.join("");
const tr = document.createElement("tr");
tr.className = linha.totalizador ? "dc-dre-row dc-dre-row--totalizador" : "dc-dre-row";
if (dcAvDestaque.has(linha.id)) tr.classList.add("dc-conta-row--destaque");
const indentacao = nivel * 16;
tr.innerHTML = `
<td style="padding-left: calc(var(--space-4) + ${indentacao}px)">
<span class="dc-conta-desc-cell">${toggle}<span>${pidDcEscapeHtml(linha.descricao)}</span></span>
</td>
${colunasMensais}
<td>
<div class="dc-obs-cell-actions">
<button type="button" class="dc-conta-validado-btn${validadoInfo.classe}" data-dc-av-validado="${linha.id}" title="${validadoInfo.title}" aria-label="${validadoInfo.label}" aria-pressed="${validadoInfo.pressed}" ${concluida ? "disabled" : ""}>
${PID_DC_VALIDADO_ICONE}
</button>
${dcObsBotaoHtml("analise_vertical", linha.id, observacoesDaLinha, temObsDescendente)}
${pidDcAlteradaBadgeHtml(linha.alterada_reprocessamento, linha.validado, pidDcValorAnteriorAvTexto(linha))}
</div>
</td>
`;
body.appendChild(tr);
if (dcObsPainelAberto.analise_vertical === linha.id) {
const editRow = document.createElement("tr");
editRow.className = "dc-obs-edit-row";
editRow.innerHTML = dcObsPainelHtml("analise_vertical", linha.id, observacoesDaLinha, colspan, concluida);
body.appendChild(editRow);
}
});
renderAnaliseVerticalObsResumo();
}
document.getElementById("dc-av-body").addEventListener("click", async (event) => {
const toggleBtn = event.target.closest("[data-dc-av-toggle]");
if (toggleBtn) {
const id = Number(toggleBtn.getAttribute("data-dc-av-toggle"));
const estavaColapsada = dcAvColapsadas.has(id);
if (estavaColapsada) dcAvColapsadas.delete(id);
else dcAvColapsadas.add(id);
const nivelFn = (linha) => Math.max(0, linha.nivel);
if (estavaColapsada) {
dcAvDestaqueHistorico.push(dcAvDestaque);
dcAvDestaque = dcFilhosDiretos(apuracaoAtual.linhas_analise_vertical, nivelFn, id);
} else {
dcAvDestaque = dcAvDestaqueHistorico.pop() || new Set();
}
renderAnaliseVertical();
return;
}
const validadoBtn = event.target.closest("[data-dc-av-validado]");
if (validadoBtn) {
if (validadoBtn.disabled) return;
const id = Number(validadoBtn.getAttribute("data-dc-av-validado"));
validadoBtn.disabled = true;
try {
await dcClicarValidadoAv(id);
} catch (e) {
await pidAlert(e.message);
} finally {
validadoBtn.disabled = false;
}
return;
}
await dcTrataCliqueObservacao(event, "analise_vertical");
});
// ---- Tabs ----
document.getElementById("dc-tabs").addEventListener("click", async (event) => {
const tabBtn = event.target.closest("[data-dc-tab]");
if (!tabBtn) return;
const tab = tabBtn.getAttribute("data-dc-tab");
document.querySelectorAll("#dc-tabs .pa-tab").forEach((t) => t.classList.toggle("is-active", t === tabBtn));
document.querySelectorAll("[data-dc-panel]").forEach((panel) => {
panel.hidden = panel.getAttribute("data-dc-panel") !== tab;
});
if (tab === "dashboard") await renderDashboardTab();
});
// ---- Restaurar formatação padrão (Balancete/DRE/Análise Vertical) ----
// Botão no canto direito da linha de cabeçalho de cada tabela — descarta
// todo expandir/recolher manual feito pelo contador e recalcula o mesmo
// estado inicial de dcColapsoPadrao()/dcUltimaLevaVisivel() usado quando a
// apuração é aberta (ver renderRevisao). Cada árvore (Balancete/DRE/Análise
// Vertical) tem seu próprio botão porque cada uma tem seu próprio estado de
// colapso/destaque, independente das demais.
document.getElementById("dc-contas-thead").addEventListener("click", (event) => {
if (!event.target.closest("[data-dc-reset-formatacao]")) return;
dcContasColapsadas = dcColapsoPadrao(apuracaoAtual.contas, dcContaNivel);
dcContasDestaque = dcUltimaLevaVisivel(apuracaoAtual.contas, dcContaNivel, dcContasColapsadas);
dcContasDestaqueHistorico = [];
renderContas();
});
document.getElementById("dc-dre-thead").addEventListener("click", (event) => {
if (!event.target.closest("[data-dc-reset-formatacao]")) return;
const nivelFn = (linha) => Math.max(0, linha.nivel);
dcDreColapsadas = dcColapsoPadrao(apuracaoAtual.linhas_dre, nivelFn);
dcDreDestaque = dcUltimaLevaVisivel(apuracaoAtual.linhas_dre, nivelFn, dcDreColapsadas);
dcDreDestaqueHistorico = [];
renderDre();
});
document.getElementById("dc-av-thead").addEventListener("click", (event) => {
if (!event.target.closest("[data-dc-reset-formatacao]")) return;
const nivelFn = (linha) => Math.max(0, linha.nivel);
dcAvColapsadas = dcColapsoPadrao(apuracaoAtual.linhas_analise_vertical, nivelFn);
dcAvDestaque = dcUltimaLevaVisivel(apuracaoAtual.linhas_analise_vertical, nivelFn, dcAvColapsadas);
dcAvDestaqueHistorico = [];
renderAnaliseVertical();
});
// ---- Resumo do Fechamento (texto rico, aba "Dashboard") ----
// Editor contenteditable + colar/arrastar imagem embutida — mesmo padrão
// de `.ag-richtext` (acessos-gerais.js) e `.ajuda-modal__editor`
// (ajuda-aplicacao.js), duplicado aqui de propósito (não é um componente
// compartilhado no projeto). Populado só em `renderRevisao()` (uma vez
// por apuração aberta), nunca em `renderDashboardTab()` — que roda toda
// vez que o contador troca de aba, e resetar o `innerHTML` a cada troca
// descartaria texto ainda não salvo.
const PID_DC_RESUMO_FECHAMENTO_IMAGE_MAX_BYTES = 2 * 1024 * 1024;
const resumoFechamentoEditor = document.getElementById("dc-dash-resumo-fechamento-editor");
const resumoFechamentoSalvarBtn = document.getElementById("dc-dash-resumo-fechamento-salvar-btn");
const resumoFechamentoStatusEl = document.getElementById("dc-dash-resumo-fechamento-status");
function renderResumoFechamento() {
const concluida = apuracaoAtual.status === "concluida";
resumoFechamentoEditor.innerHTML = apuracaoAtual.resumo_fechamento || "";
resumoFechamentoEditor.setAttribute("contenteditable", concluida ? "false" : "true");
resumoFechamentoSalvarBtn.hidden = concluida;
resumoFechamentoStatusEl.hidden = true;
}
function pidDcResumoFechamentoPlaceCaretAtPoint(x, y) {
const selection = window.getSelection();
if (document.caretRangeFromPoint) {
const range = document.caretRangeFromPoint(x, y);
if (range) {
selection.removeAllRanges();
selection.addRange(range);
}
} else if (document.caretPositionFromPoint) {
const pos = document.caretPositionFromPoint(x, y);
if (pos) {
const range = document.createRange();
range.setStart(pos.offsetNode, pos.offset);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
}
}
function pidDcResumoFechamentoMostraStatus(texto) {
resumoFechamentoStatusEl.hidden = false;
resumoFechamentoStatusEl.textContent = texto;
}
function pidDcResumoFechamentoInserirImagem(file, atPoint) {
if (!file || !file.type.startsWith("image/")) return;
if (file.size > PID_DC_RESUMO_FECHAMENTO_IMAGE_MAX_BYTES) {
pidDcResumoFechamentoMostraStatus("A imagem deve ter no máximo 2MB.");
return;
}
const reader = new FileReader();
reader.onload = () => {
resumoFechamentoEditor.focus();
if (atPoint) pidDcResumoFechamentoPlaceCaretAtPoint(atPoint.x, atPoint.y);
document.execCommand("insertImage", false, reader.result);
};
reader.readAsDataURL(file);
}
resumoFechamentoEditor.addEventListener("paste", (event) => {
const items = Array.from((event.clipboardData && event.clipboardData.items) || []);
const imageItem = items.find((item) => item.type.startsWith("image/"));
if (!imageItem) return;
event.preventDefault();
pidDcResumoFechamentoInserirImagem(imageItem.getAsFile());
});
resumoFechamentoEditor.addEventListener("dragover", (event) => {
event.preventDefault();
});
resumoFechamentoEditor.addEventListener("drop", (event) => {
const file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
if (!file || !file.type.startsWith("image/")) return;
event.preventDefault();
pidDcResumoFechamentoInserirImagem(file, { x: event.clientX, y: event.clientY });
});
resumoFechamentoSalvarBtn.addEventListener("click", async () => {
const hasImage = resumoFechamentoEditor.querySelector("img") !== null;
const hasText = resumoFechamentoEditor.textContent.trim() !== "";
const valor = hasImage || hasText ? resumoFechamentoEditor.innerHTML : "";
resumoFechamentoSalvarBtn.disabled = true;
try {
const resultado = await pidAtualizarResumoFechamentoContabil(apuracaoAtual.id, valor);
apuracaoAtual.resumo_fechamento = resultado.resumo_fechamento;
pidDcResumoFechamentoMostraStatus("Salvo.");
} catch (e) {
pidDcResumoFechamentoMostraStatus(e.message);
} finally {
resumoFechamentoSalvarBtn.disabled = false;
}
});
// ---- Aba "Dashboard" (pré-visualização do relatório "Gerar Dashboard") ----
// Indicadores só são buscados na primeira vez que a aba é aberta (mesmo
// espírito de "canvas com tamanho zero não desenha certo" do relatório em
// si — aqui o motivo é só evitar um cálculo/consulta extra ao banco em
// toda apuração aberta, já que boa parte das revisões nem chega a olhar
// esta aba). Observações (contas/DRE/achados) já estão todas carregadas
// em apuracaoAtual, não precisam de fetch próprio.
async function renderDashboardTab() {
if (!dcIndicadoresAtual) {
dcIndicadoresAtual = await pidBuscarIndicadoresContabil(apuracaoAtual.id);
}
renderDashboardIndicadores();
renderDashboardObservacoes();
}
// Agrupa as chaves de `metadados` pelo campo `grupo` de cada uma (vem do
// servidor — os 11 de sistema já têm um grupo fixo, personalizados
// sempre caem em "Indicadores Personalizados", ver
// `ContabilApuracaoViewSet.indicadores()`); só a ORDEM de exibição dos
// grupos é decidida aqui (`PID_DC_DASH_ORDEM_GRUPOS`).
function renderDashboardIndicadores() {
const container = document.getElementById("dc-dash-indicadores");
container.innerHTML = "";
if (!dcIndicadoresAtual) return;
const { indicadores, indicadores_ocultos: indicadoresOcultos, metadados } = dcIndicadoresAtual;
const concluida = apuracaoAtual.status === "concluida";
const grupos = new Map();
Object.keys(metadados).forEach((chave) => {
const nomeGrupo = metadados[chave].grupo;
if (!grupos.has(nomeGrupo)) grupos.set(nomeGrupo, []);
grupos.get(nomeGrupo).push(chave);
});
const nomesGrupos = [...grupos.keys()].sort((a, b) => {
const posicaoA = PID_DC_DASH_ORDEM_GRUPOS.indexOf(a);
const posicaoB = PID_DC_DASH_ORDEM_GRUPOS.indexOf(b);
return (posicaoA === -1 ? 99 : posicaoA) - (posicaoB === -1 ? 99 : posicaoB);
});
nomesGrupos.forEach((nomeGrupo) => {
const secao = document.createElement("div");
secao.className = "dc-dash-grupo";
secao.innerHTML = `
<h4 class="dc-dash-grupo__titulo">${pidDcEscapeHtml(nomeGrupo)}</h4>
<div class="dc-dash-cards-grid"></div>
`;
const grid = secao.querySelector(".dc-dash-cards-grid");
grupos.get(nomeGrupo).forEach((chave) => {
const meta = metadados[chave];
const oculto = indicadoresOcultos.includes(chave);
const card = document.createElement("div");
card.className = `dc-dash-card${oculto ? " is-oculto" : ""}`;
card.innerHTML = `
<div class="dc-dash-card__info">
<span class="dc-dash-card__label">${pidDcEscapeHtml(meta.nome)}</span>
<span class="dc-dash-card__valor">${pidDcFormatIndicadorGenerico(meta.formato, indicadores[chave])}</span>
</div>
<div class="dc-dash-card__actions">
<button
type="button"
class="icon-btn dc-dash-toggle-btn"
data-dc-dash-indicador-editar="${meta.definicao_id}"
aria-label="Editar indicador"
title="Editar indicador"
${concluida ? "disabled" : ""}
>${PID_DC_ICON_EDITAR}</button>
<button
type="button"
class="icon-btn dc-dash-toggle-btn"
data-dc-dash-indicador-toggle="${chave}"
aria-label="${oculto ? "Mostrar" : "Ocultar"} no relatório"
title="${oculto ? "Mostrar no relatório" : "Ocultar do relatório"}"
${concluida ? "disabled" : ""}
>${oculto ? PID_DC_ICON_OLHO_FECHADO : PID_DC_ICON_OLHO_ABERTO}</button>
</div>
`;
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: '<path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/>' },
{
chave: "tendencia-alta",
rotulo: "Tendência de alta",
svg: '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
},
{
chave: "tendencia-baixa",
rotulo: "Tendência de baixa",
svg: '<polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/><polyline points="17 18 23 18 23 12"/>',
},
{
chave: "percentual",
rotulo: "Percentual",
svg: '<line x1="19" y1="5" x2="5" y2="19"/><circle cx="6.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="17.5" r="2.5"/>',
},
{
chave: "pizza",
rotulo: "Gráfico de pizza",
svg: '<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>',
},
{ chave: "atividade", rotulo: "Atividade", svg: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>' },
{
chave: "moeda",
rotulo: "Cifrão",
svg: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
},
{
chave: "alvo",
rotulo: "Alvo/meta",
svg: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
},
{
chave: "camadas",
rotulo: "Camadas",
svg:
'<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/>' +
'<polyline points="2 12 12 17 22 12"/>',
},
{
chave: "cartao",
rotulo: "Cartão",
svg: '<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/>',
},
{
chave: "selo",
rotulo: "Selo/prêmio",
svg: '<circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/>',
},
];
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 = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icone.svg}</svg>`;
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 = `
<input type="text" class="checklist-search" placeholder="Buscar conta por código ou descrição..." data-dc-comp-busca />
<div class="checklist-box" data-dc-comp-lista>
${orfaos.map((codigo) => `
<label class="checklist-item" data-dc-comp-item="${pidDcEscapeHtml(codigo.toLowerCase())}">
<input type="checkbox" value="${pidDcEscapeHtml(codigo)}" checked />
<span class="checklist-item__info"><span>${pidDcEscapeHtml(codigo)} — <em>conta não encontrada nesta apuração, mantida</em></span></span>
</label>
`).join("")}
${apuracaoAtual.contas.map((conta) => `
<label class="checklist-item" data-dc-comp-item="${pidDcEscapeHtml(`${conta.codigo} ${conta.descricao}`.toLowerCase())}">
<input type="checkbox" value="${pidDcEscapeHtml(conta.codigo)}" ${selecionadosSet.has(conta.codigo) ? "checked" : ""} />
<span class="checklist-item__info"><span>${pidDcEscapeHtml(conta.codigo)} — ${pidDcEscapeHtml(conta.descricao)}</span></span>
</label>
`).join("")}
</div>
`;
} else if (tipo === "linha_dre") {
const selecionados = valoresIniciais.linhas_dre_descricoes || [];
const descricoesNestaApuracao = new Set(apuracaoAtual.linhas_dre.map((linha) => linha.descricao));
const orfaos = selecionados.filter((descricao) => !descricoesNestaApuracao.has(descricao));
const selecionadosSet = new Set(selecionados);
picker.innerHTML = `
<input type="text" class="checklist-search" placeholder="Buscar linha da DRE..." data-dc-comp-busca />
<div class="checklist-box" data-dc-comp-lista>
${orfaos.map((descricao) => `
<label class="checklist-item" data-dc-comp-item="${pidDcEscapeHtml(descricao.toLowerCase())}">
<input type="checkbox" value="${pidDcEscapeHtml(descricao)}" checked />
<span class="checklist-item__info"><span>${pidDcEscapeHtml(descricao)} — <em>linha não encontrada nesta apuração, mantida</em></span></span>
</label>
`).join("")}
${apuracaoAtual.linhas_dre.map((linha) => `
<label class="checklist-item" data-dc-comp-item="${pidDcEscapeHtml(linha.descricao.toLowerCase())}">
<input type="checkbox" value="${pidDcEscapeHtml(linha.descricao)}" ${selecionadosSet.has(linha.descricao) ? "checked" : ""} />
<span class="checklist-item__info"><span>${pidDcEscapeHtml(linha.descricao)}</span></span>
</label>
`).join("")}
</div>
`;
} 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 = `
<select class="dc-ind-comp-indicador">
<option value="">Selecione um indicador...</option>
${opcoes.map((chave) => `<option value="${pidDcEscapeHtml(chave)}" ${chave === atual ? "selected" : ""}>${pidDcEscapeHtml(metadados[chave].nome)}</option>`).join("")}
</select>
`;
} 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 = `<p class="dc-ind-formula-hint">Usa automaticamente o resultado líquido do período (última linha da DRE desta apuração), sem precisar escolher nada aqui.</p>`;
} else {
picker.innerHTML = "";
}
}
function pidDcCriaLinhaComponente(valoresIniciais) {
dcIndicadorComponenteSeq += 1;
const linha = document.createElement("div");
linha.className = "dc-ind-componente";
linha.dataset.dcCompId = String(dcIndicadorComponenteSeq);
const tipoInicial = valoresIniciais.tipo || "contas";
linha.innerHTML = `
<div class="dc-ind-componente__linha">
<div class="modal-field">
<label>Chave (usada na fórmula)</label>
<input type="text" class="dc-ind-comp-chave" placeholder="ex.: receita" value="${pidDcEscapeHtml(valoresIniciais.chave || "")}" />
</div>
<div class="modal-field">
<label>Tipo</label>
<select class="dc-ind-comp-tipo">
${Object.keys(PID_DC_COMP_TIPO_LABELS).map((tipo) => `<option value="${tipo}" ${tipo === tipoInicial ? "selected" : ""}>${PID_DC_COMP_TIPO_LABELS[tipo]}</option>`).join("")}
</select>
</div>
<button type="button" class="icon-btn dc-ind-comp-remover" aria-label="Remover componente" title="Remover componente">${PID_DC_ICON_LIXEIRA}</button>
</div>
<div class="dc-ind-comp-picker"></div>
`;
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 = `<p class="dc-ind-preview__erro">Preencha a chave de todos os componentes e a fórmula antes de calcular.</p>`;
return;
}
const payload = {
nome: document.getElementById("dc-indicador-modal-nome").value.trim() || "Pré-visualização",
descricao: "",
formato: document.getElementById("dc-indicador-modal-formato").value,
formula,
componentes,
};
const calcularBtn = document.getElementById("dc-indicador-calcular-btn");
calcularBtn.disabled = true;
previewEl.innerHTML = `<p class="dc-ind-preview__carregando">Calculando...</p>`;
try {
const resultado = await pidPreVisualizarIndicadorContabil(apuracaoAtual.id, payload);
if (resultado.erro) {
previewEl.innerHTML = `<p class="dc-ind-preview__erro">${pidDcEscapeHtml(resultado.erro)}</p>`;
} else {
previewEl.innerHTML = `
<ul class="dc-ind-preview__lista">
${resultado.componentes.map((c) => `
<li><span>${pidDcEscapeHtml(c.chave)}</span><span>${c.valor_formatado === null ? "—" : pidDcEscapeHtml(c.valor_formatado)}</span></li>
`).join("")}
</ul>
<div class="dc-ind-preview__resultado">Resultado: <strong>${resultado.resultado_formatado === null ? "—" : pidDcEscapeHtml(resultado.resultado_formatado)}</strong></div>
`;
}
} catch (e) {
previewEl.innerHTML = `<p class="dc-ind-preview__erro">${pidDcEscapeHtml(e.message)}</p>`;
} 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
? `<span class="dc-ind-hub-item__spacer"></span>`
: `<input
type="checkbox"
class="dc-ind-hub-item__ativo"
data-dc-hub-toggle="${definicao.chave}"
${ativo ? "checked" : ""}
${concluida ? "disabled" : ""}
aria-label="Ativo nesta apuração"
title="Ativo nesta apuração"
/>`
}
<button type="button" class="dc-ind-hub-item__nome" data-dc-hub-ver="${definicao.id}">${pidDcEscapeHtml(definicao.nome)}</button>
<button
type="button"
class="icon-btn"
data-dc-hub-editar="${definicao.id}"
aria-label="Editar"
title="Editar"
${concluida ? "disabled" : ""}
>${PID_DC_ICON_EDITAR}</button>
`;
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) => `
<div class="dc-dash-obs${achado.oculto_no_relatorio ? " is-oculto" : ""}">
<div class="dc-dash-obs__info">
<span class="dc-dash-obs__origem">Auditoria</span>
<span class="dc-dash-obs__referencia">${pidDcEscapeHtml(achado.titulo)}</span>
<p class="dc-dash-obs__texto">${pidDcEscapeHtml(achado.observacao_contador)}</p>
</div>
<button
type="button"
class="icon-btn dc-dash-toggle-btn"
data-dc-dash-achado-toggle="${achado.id}"
aria-label="${achado.oculto_no_relatorio ? "Mostrar" : "Ocultar"} no relatório"
title="${achado.oculto_no_relatorio ? "Mostrar no relatório" : "Ocultar do relatório"}"
${concluida ? "disabled" : ""}
>
${achado.oculto_no_relatorio ? PID_DC_ICON_OLHO_FECHADO : PID_DC_ICON_OLHO_ABERTO}
</button>
</div>
`
),
];
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('<span class="dc-obs-selo dc-obs-selo--historica">Histórico</span>');
if (obs.encerrada) {
selos.push(
`<span class="dc-obs-selo dc-obs-selo--encerrada">Encerrada em ${pidDcFormatCompetencia(obs.encerrada_em_competencia)}</span>`
);
}
const acaoEncerrar = concluida
? ""
: obs.encerrada
? `<button type="button" class="icon-btn" data-dc-obs-reativar="${obs.id}" title="Voltar a exibir nas próximas competências" aria-label="Reativar observação">${PID_DC_ICON_REATIVAR}</button>`
: `<button type="button" class="icon-btn" data-dc-obs-encerrar="${obs.id}" title="Ocultar das próximas competências (continua visível nesta)" aria-label="Encerrar observação">${PID_DC_ICON_ENCERRAR}</button>`;
return `
<div class="dc-dash-obs${obs.mostrar_ao_cliente ? "" : " is-oculto"}">
<div class="dc-dash-obs__info">
${origem ? `<span class="dc-dash-obs__origem">${pidDcEscapeHtml(origem)}</span>` : ""}
<span class="dc-dash-obs__referencia">${pidDcEscapeHtml(obs.alvo_rotulo || obs.alvo_chave)}</span>
<p class="dc-dash-obs__texto">${pidDcEscapeHtml(obs.texto)}</p>
<span class="dc-obs-item__assinatura">${pidDcEscapeHtml(obs.criado_por_nome || "Usuário removido")} · ${pidDcFormatData(obs.criado_em)} · competência ${pidDcFormatCompetencia(obs.competencia_origem)}</span>
${selos.length ? `<span class="dc-obs-item__selos">${selos.join("")}</span>` : ""}
</div>
<div class="dc-obs-item__acoes">
<button
type="button"
class="icon-btn dc-dash-toggle-btn"
data-dc-obs-visibilidade="${obs.id}"
aria-label="${obs.mostrar_ao_cliente ? "Ocultar" : "Mostrar"} no relatório"
title="${obs.mostrar_ao_cliente ? "Ocultar do relatório do cliente" : "Mostrar no relatório do cliente"}"
${concluida ? "disabled" : ""}
>
${obs.mostrar_ao_cliente ? PID_DC_ICON_OLHO_ABERTO : PID_DC_ICON_OLHO_FECHADO}
</button>
${acaoEncerrar}
</div>
</div>
`;
}
function dcRenderObsResumo(tipo, idLista, idVazio, idContador) {
const lista = document.getElementById(idLista);
const empty = document.getElementById(idVazio);
const count = document.getElementById(idContador);
const concluida = apuracaoAtual.status === "concluida";
const itens = dcObsAplicaFiltro(dcObservacoesDoTipo(tipo));
count.textContent = String(itens.length);
empty.hidden = itens.length > 0;
lista.innerHTML = itens.map((obs) => dcObsResumoItemHtml(obs, null, concluida)).join("");
}
function renderContasObsResumo() {
dcRenderObsResumo("conta", "dc-contas-obs-list", "dc-contas-obs-empty", "dc-contas-obs-count");
}
function renderDreObsResumo() {
dcRenderObsResumo("dre", "dc-dre-obs-list", "dc-dre-obs-empty", "dc-dre-obs-count");
}
function renderAnaliseVerticalObsResumo() {
dcRenderObsResumo("analise_vertical", "dc-av-obs-list", "dc-av-obs-empty", "dc-av-obs-count");
}
["dc-contas-obs-list", "dc-dre-obs-list", "dc-av-obs-list"].forEach((id) => {
document.getElementById(id).addEventListener("click", async (event) => {
await dcTrataCliqueObservacao(event, "conta");
});
});
// ---- Modal de achado (tratar/ignorar) ----
const achadoModal = document.getElementById("dc-achado-modal");
function abrirModalAchado(achadoId) {
const achado = apuracaoAtual.achados.find((a) => a.id === achadoId);
if (!achado) return;
achadoEmEdicao = achadoId;
document.getElementById("dc-achado-modal-titulo").textContent = achado.titulo;
document.getElementById("dc-achado-modal-mensagem").textContent = achado.mensagem;
document.getElementById("dc-achado-modal-texto").value = achado.observacao_contador || "";
document.getElementById("dc-achado-modal-error").textContent = "";
achadoModal.hidden = false;
}
function fecharModalAchado() {
achadoModal.hidden = true;
achadoEmEdicao = null;
}
document.getElementById("dc-achado-modal-cancel-btn").addEventListener("click", fecharModalAchado);
achadoModal.addEventListener("click", (event) => {
if (event.target === achadoModal) fecharModalAchado();
});
async function salvarStatusAchado(novoStatus) {
const errorEl = document.getElementById("dc-achado-modal-error");
errorEl.textContent = "";
const texto = document.getElementById("dc-achado-modal-texto").value.trim();
if (!texto) {
errorEl.textContent = "Informe uma justificativa para o tratamento desta observação.";
return;
}
try {
const achado = await pidAtualizarAchadoContabil(achadoEmEdicao, novoStatus, texto);
const indice = apuracaoAtual.achados.findIndex((a) => a.id === achadoEmEdicao);
if (indice >= 0) apuracaoAtual.achados[indice] = achado;
renderAchados();
fecharModalAchado();
await carregarLista();
} catch (e) {
errorEl.textContent = e.message;
}
}
document.getElementById("dc-achado-modal-tratar-btn").addEventListener("click", () => salvarStatusAchado("tratado"));
document.getElementById("dc-achado-modal-ignorar-btn").addEventListener("click", () => salvarStatusAchado("ignorado"));
// ---- Concluir análise ----
document.getElementById("dc-concluir-btn").addEventListener("click", async () => {
if (!(await pidConfirm("Concluir esta análise? Depois de concluída, as observações não podem mais ser editadas.")))
return;
apuracaoAtual = await pidConcluirApuracaoContabil(apuracaoAtual.id);
renderRevisao();
await carregarLista();
});
document.getElementById("dc-review-back-btn").addEventListener("click", () => showView("list"));
// ---- Gerar Dashboard ----
document.getElementById("dc-gerar-dashboard-btn").addEventListener("click", () => {
pidGerarDashboardContabil(apuracaoAtual.id);
});
await carregarLista();
showView("list");
});