const PID_DC_STATUS_LABELS = { revisao: "Em revisão", concluida: "Concluída" }; const PID_DC_SEVERIDADE_LABELS = { alta: "Alta", media: "Média", baixa: "Baixa" }; const PID_DC_ACHADO_STATUS_LABELS = { pendente: "Pendente", tratado: "Tratado", ignorado: "Ignorado" }; // Ordem de exibição do donut/legenda de severidade no resumo — mesmas cores // já usadas pelos badges (.dc-badge--alta/--media/--baixa, dashboard-contabil.css). const PID_DC_SEVERIDADE_LISTA = ["alta", "media", "baixa"]; const PID_DC_SEVERIDADE_CORES = { alta: "var(--danger)", media: "var(--gold)", baixa: "rgb(var(--slate-rgb))" }; // Mesmas 9 regras/chaves de regras.py (REGRAS) — enumeradas aqui pra sempre // mostrar as 9 categorias no resumo, mesmo as que não geraram achado nesta // apuração (mesmo espírito do "Nenhum registro encontrado" do sistema externo // que inspirou esta visão). A 10ª (`item_removido_reprocessamento`) não é uma // regra sobre o PDF: nasce na sincronização do reprocessamento, quando uma // conta/linha some do arquivo novo (ver regras.achados_itens_removidos()). const PID_DC_REGRAS = [ { chave: "balanceamento_ativo_passivo", label: "Balanceamento Ativo x Passivo" }, { chave: "debito_credito_divergente", label: "Débito x Crédito" }, { chave: "saldo_negativo_caixa", label: "Caixa Negativo" }, { chave: "saldo_sinal_invertido", label: "Sinal de Saldo Invertido" }, { chave: "lucro_balancete_diverge_dre", label: "Lucro Balancete x DRE" }, { chave: "conta_transitoria_com_saldo", label: "Contas Transitórias" }, { chave: "conta_deveria_zerar", label: "Contas que Deveriam Zerar" }, { chave: "descricao_generica", label: "Descrição Genérica" }, { chave: "variacao_atipica_dre", label: "Variação Atípica na DRE" }, { chave: "item_removido_reprocessamento", label: "Contas e Linhas Removidas" }, ]; // Agrupamento temático das 9 regras acima pro resumo da aba Observações — // evita 9 cards soltos no grid (poluído visualmente com uma tabela por // conta em cada um); cada grupo vira 1 card com as regras dele listadas por // linha (label + contagem), clique numa linha com contagem > 0 continua // isolando achado.regra na lista abaixo, igual antes. const PID_DC_GRUPOS = [ { label: "Divergências de Saldo", regras: [ "balanceamento_ativo_passivo", "debito_credito_divergente", "saldo_negativo_caixa", "saldo_sinal_invertido", "lucro_balancete_diverge_dre", ], }, { label: "Contas Atípicas", regras: ["conta_transitoria_com_saldo", "conta_deveria_zerar", "descricao_generica"], }, { label: "Variações e Indicadores", regras: ["variacao_atipica_dre"], }, // `somenteComAchados`: só aparece na apuração em que algo foi de fato // removido — um card fixo em "0" em toda análise nunca reprocessada seria // ruído, diferente das regras acima, cujo "0" é a informação de que aquela // checagem rodou e passou. { label: "Mudanças no Reprocessamento", regras: ["item_removido_reprocessamento"], somenteComAchados: true, }, ]; // Paleta de 10 cores distintas reaproveitando os tokens *-rgb já existentes // (theme-aware — acompanham o tema claro/escuro e a cor de tema escolhida // pelo usuário), com color-mix() pra completar até 10 sem hardcoded hex novo. const PID_DC_CATEGORIA_CORES = [ "rgb(var(--accent-rgb))", "rgb(var(--danger-rgb))", "rgb(var(--gold-rgb))", "rgb(var(--teal-rgb))", "rgb(var(--slate-rgb))", "rgb(var(--coral-rgb))", "color-mix(in srgb, rgb(var(--accent-rgb)) 55%, white)", "color-mix(in srgb, rgb(var(--danger-rgb)) 55%, white)", "color-mix(in srgb, rgb(var(--gold-rgb)) 55%, black)", "color-mix(in srgb, rgb(var(--teal-rgb)) 55%, black)", ]; function pidDcFormatMoeda(valor) { const numero = Number(valor || 0); return numero.toLocaleString("pt-BR", { style: "currency", currency: "BRL" }); } // Os 3 formatos abaixo espelham os filtros moeda/percentual/indice de // portal_api/templatetags/contabil_extras.py (mesmo relatório "Gerar // Dashboard") — diferente de pidDcFormatMoeda acima, `null`/`undefined` // sempre vira "—", nunca "R$ 0,00": em IndicadoresFinanceiros, `None` // significa "indisponível" (ex. EBITDA sem histórico), não zero. function pidDcFormatIndicadorMoeda(valor) { if (valor === null || valor === undefined) return "—"; return Number(valor).toLocaleString("pt-BR", { style: "currency", currency: "BRL" }); } function pidDcFormatIndicadorPercentual(valor) { if (valor === null || valor === undefined) return "—"; return `${(Number(valor) * 100).toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`; } function pidDcFormatIndicadorIndice(valor) { if (valor === null || valor === undefined) return "—"; return Number(valor).toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } // Label/grupo/formato de cada indicador (de sistema ou personalizado) vêm // sempre do servidor agora (`GET /contabil-apuracoes/{id}/indicadores/`, // campo `metadados`) — única fonte de verdade, já que um indicador // personalizado não tem como ter seu formato hardcoded aqui. Só a ORDEM de // exibição dos grupos continua fixa no frontend (grupo desconhecido, se // algum dia existir, cai no fim). const PID_DC_DASH_ORDEM_GRUPOS = [ "Indicadores de Resultado", "Indicadores de Liquidez e Endividamento", "Indicadores Personalizados", ]; function pidDcFormatIndicadorGenerico(formato, valor) { if (formato === "moeda") return pidDcFormatIndicadorMoeda(valor); if (formato === "percentual") return pidDcFormatIndicadorPercentual(valor); return pidDcFormatIndicadorIndice(valor); } const PID_DC_ICON_OLHO_ABERTO = ''; const PID_DC_ICON_OLHO_FECHADO = ''; const PID_DC_ICON_EDITAR = ''; const PID_DC_ICON_LIXEIRA = ''; const PID_DC_ICON_HISTORICO = ''; // As duas faces do botão de árvore no cabeçalho da primeira coluna das 3 // tabelas da revisão (ver dcAtualizaBotaoArvore) — é um toggle: "−" enquanto // a árvore está toda aberta (o padrão), "+" assim que existe qualquer grupo // recolhido. const PID_DC_ICON_RECOLHER_ARVORE = ''; const PID_DC_ICON_EXPANDIR_ARVORE = ''; // Usado pelo botão "Ver na tabela" dos cards de achado (ver renderAchados) — // mesma semântica de "localizar um ponto", reaproveitado como pin de mapa. const PID_DC_ICON_LOCALIZAR = ''; // Percentual da Análise Vertical já vem "pronto" do PDF (ex. "-7.33" quer // dizer -7,33%, não -733%) — diferente de pidDcFormatIndicadorPercentual // (usado pelos cards de indicador), que recebe uma fração e multiplica por // 100 antes de exibir. Reusar aquele aqui exibiria "-733,00%" errado. function pidDcFormatPercentualAnaliseVertical(valor) { const numero = Number(valor || 0); return `${numero.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`; } // "mai/2026" (formato salvo pelo parser) -> "Mai/2026" (rótulo de coluna). function pidDcFormatMesAnaliseVertical(mes) { if (!mes) return ""; return mes.charAt(0).toUpperCase() + mes.slice(1); } function pidDcFormatCompetencia(iso) { if (!iso) return ""; const [ano, mes] = iso.split("-"); return `${mes}/${ano}`; } function pidDcFormatData(iso) { if (!iso) return ""; return new Date(iso).toLocaleDateString("pt-BR"); } function pidDcFormatDataHora(iso) { if (!iso) return ""; return new Date(iso).toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }); } function pidDcEscapeHtml(texto) { const div = document.createElement("div"); div.textContent = texto == null ? "" : String(texto); return div.innerHTML; } // Barra de progresso de "Nova Análise"/"Reprocessar" — o processamento roda // de forma síncrona numa única requisição (sem fila de tarefas), então não // existe um percentual real pra reportar. Simula a subida: cresce rápido no // início e desacelera perto de 90%, sem nunca chegar lá sozinha — só // `completar()` (chamado quando a resposta volta) fecha os últimos 10% e // segura em 100% por um instante, pra sempre refletir o fim de verdade do // processamento, nunca um término fake antes da hora. function pidDcIniciarProgressoSimulado(fillEl) { let pct = 0; fillEl.style.width = "0%"; const intervalId = setInterval(() => { pct += (90 - pct) * 0.1; fillEl.style.width = `${pct}%`; }, 200); return { parar() { clearInterval(intervalId); }, async completar() { clearInterval(intervalId); fillEl.style.width = "100%"; await new Promise((resolve) => setTimeout(resolve, 300)); }, }; } // Posiciona o `` (fixed, ver // dashboard-contabil.css) relativo à viewport, calculado no momento do // hover/foco — nasce acima do gatilho, centralizado; sem espaço acima // (badge perto do topo da tela) desce pra baixo do gatilho; clampado nas // laterais pra nunca vazar pra fora da tela (badge perto da borda da // tabela). Necessário porque os badges deste pacote vivem dentro de // `.pa-table-wrap`, que corta (`overflow:hidden`) qualquer coisa // posicionada com `position:absolute` que escape da própria tabela — // `position:fixed` já resolve o corte sozinho, isto aqui só calcula onde. // Delegado no `document` com `capture:true` porque `mouseenter`/`focus` não // borbulham — um único listener cobre todo badge atual e futuro, mesmo os // recriados a cada re-render da tabela. function pidDcPosicionaTooltip(event) { const gatilho = event.target.closest && event.target.closest(".dc-hover-tooltip__gatilho"); if (!gatilho) return; const texto = gatilho.nextElementSibling; if (!texto || !texto.classList.contains("dc-hover-tooltip__texto")) return; 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 } }); } // Excluir de verdade — botão voltou a existir na tela (rodada 145), restrito // a quem criou a observação e só enquanto a apuração de origem estiver em // revisão (mesmas duas regras checadas de novo no servidor, ver // ContabilObservacaoViewSet.perform_destroy()). async function pidExcluirObservacaoContabil(id) { return pidApiRequest(`/contabil-observacoes/${id}/`, { method: "DELETE" }); } // `alterada_reprocessamento` nunca é tocado por aqui (pedido explícito do // usuário) — o badge de alerta continua visível mesmo depois de validado, // só muda de cor (ver pidDcAlteradaBadgeHtml), pra o contador conseguir // identificar depois quais itens já foram reprocessados E revalidados. Só // um reprocessamento novo (sem mudança daquela conta/linha desta vez) limpa // o campo de verdade, em `_contabil_sincroniza_*` (views.py). async function pidAtualizarValidadoContaContabil(id, validado) { return pidApiRequest(`/contabil-contas/${id}/`, { method: "PATCH", body: { validado } }); } async function pidAtualizarValidadoLinhaDreContabil(id, validado) { return pidApiRequest(`/contabil-linhas-dre/${id}/`, { method: "PATCH", body: { validado } }); } async function pidAtualizarValidadoLinhaAnaliseVerticalContabil(id, validado) { return pidApiRequest(`/contabil-linhas-analise-vertical/${id}/`, { method: "PATCH", body: { validado } }); } async function pidAtualizarAchadoContabil(id, status, observacaoContador) { return pidApiRequest(`/contabil-achados/${id}/`, { method: "PATCH", body: { status, observacao_contador: observacaoContador }, }); } // ---- Aba "Dashboard" (pré-visualização do relatório "Gerar Dashboard") ---- async function pidBuscarIndicadoresContabil(id) { return pidApiRequest(`/contabil-apuracoes/${id}/indicadores/`); } async function pidAtualizarIndicadoresOcultosContabil(id, indicadoresOcultos) { return pidApiRequest(`/contabil-apuracoes/${id}/indicadores-ocultos/`, { method: "POST", body: { indicadores_ocultos: indicadoresOcultos }, }); } async function pidAlternarOcultoAchadoContabil(id, oculto) { return pidApiRequest(`/contabil-achados/${id}/alternar-oculto/`, { method: "POST", body: { oculto_no_relatorio: oculto }, }); } async function pidBuscarIndicadorContabilDefinicao(id) { return pidApiRequest(`/contabil-indicadores-definicoes/${id}/`); } async function pidCriarIndicadorContabil(payload) { return pidApiRequest("/contabil-indicadores-definicoes/", { method: "POST", body: payload }); } async function pidAtualizarIndicadorContabil(id, payload) { return pidApiRequest(`/contabil-indicadores-definicoes/${id}/`, { method: "PATCH", body: payload }); } async function pidExcluirIndicadorContabil(id) { return pidApiRequest(`/contabil-indicadores-definicoes/${id}/`, { method: "DELETE" }); } async function pidBuscarDefinicoesIndicadoresContabil() { return pidApiRequest("/contabil-indicadores-definicoes/"); } async function pidPreVisualizarIndicadorContabil(apuracaoId, payload) { return pidApiRequest(`/contabil-apuracoes/${apuracaoId}/pre-visualizar-indicador/`, { method: "POST", body: payload, }); } async function pidAtualizarIndicadoresSelecionadosContabil(id, indicadoresSelecionados) { return pidApiRequest(`/contabil-apuracoes/${id}/indicadores-selecionados/`, { method: "POST", body: { indicadores_selecionados: indicadoresSelecionados }, }); } async function pidAtualizarResumoFechamentoContabil(id, resumoFechamento) { return pidApiRequest(`/contabil-apuracoes/${id}/resumo-fechamento/`, { method: "POST", body: { resumo_fechamento: resumoFechamento }, }); } document.addEventListener("DOMContentLoaded", async () => { const listView = document.getElementById("dc-list-view"); if (!listView) return; const me = await pidGetMe(); if (!me) return; const relatoriosPerms = (me.permissoes_efetivas && me.permissoes_efetivas["relatorios"]) || {}; const canUse = !!(relatoriosPerms.enabled && relatoriosPerms.apps && relatoriosPerms.apps["dashboard-contabil"]); const noAccessEl = document.getElementById("dc-no-access"); const contentEl = document.getElementById("dc-content"); if (noAccessEl) noAccessEl.hidden = canUse; if (contentEl) contentEl.hidden = !canUse; if (!canUse) return; // ---- Botão "voltar ao topo" ---- // Página tem tabelas longas (Balancete/DRE/Análise Vertical, achados) — // botão flutuante só aparece depois que o início da página já saiu da // viewport (scroll da própria janela, `.page-content` não tem overflow // próprio), pra não poluir a tela quando não faz falta. const PID_DC_BACK_TO_TOP_LIMIAR_PX = 400; const backToTopBtn = document.getElementById("dc-back-to-top-btn"); if (backToTopBtn) { window.addEventListener("scroll", () => { backToTopBtn.hidden = window.scrollY < PID_DC_BACK_TO_TOP_LIMIAR_PX; }); backToTopBtn.addEventListener("click", () => { window.scrollTo({ top: 0, behavior: "smooth" }); }); } const formView = document.getElementById("dc-form-view"); const reviewView = document.getElementById("dc-review-view"); let apuracaoAtual = null; let filtroSeveridade = "todas"; let filtroStatusAchado = "pendente"; // Categoria (achado.regra) selecionada clicando num card do resumo — null // = nenhuma categoria isolada. Filtro independente de severidade/status, // os três se combinam (E lógico) na lista detalhada abaixo do resumo. let filtroRegra = null; let achadoEmEdicao = null; // Ids de ContabilConta com filhos recolhidos na árvore do Balancete — // reiniciado toda vez que uma apuração é aberta/criada (ver renderRevisao). let dcContasColapsadas = new Set(); // Mesma ideia, mas para a árvore da DRE (ContabilLinhaDre.id) — nível de // indentação já vem pronto do backend (linha.nivel), sem precisar derivar // de um código de classificação como no Balancete. let dcDreColapsadas = new Set(); // Observações vigentes nesta apuração (as desta competência + as herdadas // de competências anteriores ainda não encerradas) — carregadas à parte da // apuração, porque não pertencem a ela: `GET /contabil-apuracoes/{id}/ // observacoes/`, ver `ContabilObservacao` no backend. `dcObsIndice` é o // mesmo conteúdo indexado pela chave natural do alvo, pra cada linha da // tabela achar as suas sem varrer a lista inteira. let dcObservacoes = []; let dcObsIndice = { conta: new Map(), dre: new Map(), analise_vertical: new Map() }; // Mesma ideia de dcDreColapsadas, agora pra árvore da Análise Vertical // (ContabilLinhaAnaliseVertical) — mesmo nível de indentação da DRE // (linha.nivel), só que cada linha tem um valor+percentual por mês em vez // de um valor único. let dcAvColapsadas = new Set(); // Ids de ContabilAchado com o detalhe da conta usada no apontamento // expandido (ver renderAchados) — só achados com `conta` preenchido (as // regras gerais, ex. balanceamento Ativo x Passivo, não têm uma conta // única por trás, então não ganham o toggle). let dcAchadosContaExpandida = new Set(); // { indicadores, indicadores_ocultos } da aba "Dashboard" — carregado sob // demanda (só quando essa aba é aberta pela primeira vez após abrir/criar // a apuração, ver renderDashboardTab), null até lá. let dcIndicadoresAtual = null; // Ordenação + filtro "estilo Excel" do histórico (#dc-list-table) — mesmo // mecanismo de listSortKey/listSortDir/listFiltros em // importacao-plano-saude.js: client-side, sobre o array já carregado // (dcListaApuracoes), sem refazer fetch a cada mudança. A paginação // (dcListPage/DC_LIST_PAGE_SIZE) também segue o mesmo padrão de // importacao-plano-saude.js (IPS_LIST_PAGE_SIZE/renderListPagination). let dcListaApuracoes = []; let dcListSortKey = "criado_em"; let dcListSortDir = -1; // null = sem filtro nessa coluna; senão, um Set com os valores marcados no // popup daquela coluna (combinam entre si por E lógico). let dcListFiltros = { empresa: null, competencia: null, status: null, criado_por: null }; let dcListPage = 1; const DC_LIST_PAGE_SIZE = 10; const dcListSortHeaders = Array.from(document.querySelectorAll("#dc-list-table th[data-sort]")); const dcListPagination = document.getElementById("dc-list-pagination"); const dcListCount = document.getElementById("dc-list-count"); function showView(view) { listView.hidden = view !== "list"; formView.hidden = view !== "form"; reviewView.hidden = view !== "review"; } // ---- Histórico ---- // Compara numericamente quando os dois valores parecem número (evita // "218" ficando antes de "9" por comparação de string pura), senão cai // pra comparação de texto (acentuada/case-insensitive) — mesma função de // importacao-plano-saude.js (comparaValoresLista). function dcComparaValoresLista(a, b) { const an = Number(a); const bn = Number(b); const ambosNumeros = a !== "" && b !== "" && !Number.isNaN(an) && !Number.isNaN(bn); if (ambosNumeros) return an - bn; return String(a).localeCompare(String(b), "pt-BR", { sensitivity: "base" }); } const PID_DC_LISTA_SORT_VALOR = { empresa: (a) => a.codigo_empresa || "", competencia: (a) => a.competencia || "", observacoes: (a) => a.total_achados_pendentes ?? 0, status: (a) => a.status || "", criado_por: (a) => a.criado_por_nome || "", criado_em: (a) => a.criado_em || "", }; // Colunas com filtro "estilo Excel" no cabeçalho do histórico — mesma // chave de PID_DC_LISTA_SORT_VALOR, reaproveitado como extrator do valor // bruto de cada linha pro popup também (ver dcCriarFiltroColuna). // "observacoes"/"criado_em" só ordenam, não filtram — a primeira é uma // contagem, a segunda uma data/hora de criação, granularidade fina demais // pra um checklist de valores distintos. function dcLabelValorFiltroLista(campo, valor) { if (campo === "status") return PID_DC_STATUS_LABELS[valor] || valor; if (campo === "competencia") return pidDcFormatCompetencia(valor) || "(Vazio)"; if (campo === "empresa") { const apuracao = dcListaApuracoes.find((a) => (a.codigo_empresa || "") === valor); return apuracao ? `${valor || "(Vazio)"} - ${apuracao.nome_empresa}` : valor || "(Vazio)"; } return valor || "(Vazio)"; } function dcApuracaoPassaNosFiltros(apuracao) { return Object.entries(dcListFiltros).every(([campo, valoresPermitidos]) => { if (!valoresPermitidos) return true; // null = sem filtro nessa coluna return valoresPermitidos.has(PID_DC_LISTA_SORT_VALOR[campo](apuracao)); }); } async function carregarLista() { dcListaApuracoes = await pidFetchApuracoesContabil(); renderList(); } function dcRenderListPagination(totalPaginas) { if (!dcListPagination) return; if (totalPaginas <= 1) { dcListPagination.innerHTML = ""; return; } dcListPagination.innerHTML = ` Página ${dcListPage} de ${totalPaginas} `; } function renderList() { const body = document.getElementById("dc-list-body"); const empty = document.getElementById("dc-list-empty"); const filtradas = dcListaApuracoes.filter(dcApuracaoPassaNosFiltros); empty.hidden = filtradas.length !== 0; empty.textContent = dcListaApuracoes.length === 0 ? "Nenhuma análise realizada ainda." : "Nenhuma análise encontrada com esse filtro."; const extrator = PID_DC_LISTA_SORT_VALOR[dcListSortKey]; const ordenadas = [...filtradas].sort((a, b) => dcComparaValoresLista(extrator(a), extrator(b)) * dcListSortDir); const totalPaginas = Math.max(1, Math.ceil(ordenadas.length / DC_LIST_PAGE_SIZE)); dcListPage = Math.min(dcListPage, totalPaginas); const inicio = (dcListPage - 1) * DC_LIST_PAGE_SIZE; const pagina = ordenadas.slice(inicio, inicio + DC_LIST_PAGE_SIZE); body.innerHTML = ""; pagina.forEach((apuracao) => { const tr = document.createElement("tr"); tr.innerHTML = ` ${pidDcEscapeHtml(apuracao.codigo_empresa)} - ${pidDcEscapeHtml(apuracao.nome_empresa)} ${pidDcFontePdfAtipicaBadgeHtml(apuracao.fonte_pdf_atipica)} ${pidDcFormatCompetencia(apuracao.competencia)} ${apuracao.total_achados_pendentes} ${PID_DC_STATUS_LABELS[apuracao.status] || apuracao.status} ${pidDcEscapeHtml(apuracao.criado_por_nome || "-")} ${pidDcFormatData(apuracao.criado_em)} ${ apuracao.status === "concluida" ? "" : `` } ${ apuracao.status === "concluida" ? "" : `` } `; body.appendChild(tr); }); dcListSortHeaders.forEach((th) => { const key = th.getAttribute("data-sort"); th.classList.toggle("is-sort-asc", key === dcListSortKey && dcListSortDir === 1); th.classList.toggle("is-sort-desc", key === dcListSortKey && dcListSortDir === -1); }); dcRenderListPagination(totalPaginas); if (dcListCount) { if (ordenadas.length === 0) { dcListCount.textContent = ""; } else { const fim = Math.min(inicio + DC_LIST_PAGE_SIZE, ordenadas.length); dcListCount.textContent = `Mostrando ${inicio + 1}–${fim} de ${ordenadas.length} registro${ordenadas.length === 1 ? "" : "s"}`; } } } dcListSortHeaders.forEach((th) => { th.addEventListener("click", (event) => { // Ignora cliques vindos de dentro do popup de filtro — sem isso, abrir // o filtro ou marcar um checkbox dele também reordenava a coluna, já // que o filtro mora dentro do mesmo clicável. if (event.target.closest(".dc-col-filter")) return; const key = th.getAttribute("data-sort"); if (dcListSortKey === key) { dcListSortDir *= -1; } else { dcListSortKey = key; dcListSortDir = 1; } dcListPage = 1; renderList(); }); }); // Filtro "estilo Excel" por coluna do histórico — mesmo mecanismo de // criarFiltroColuna em importacao-plano-saude.js. const dcFiltrosColuna = []; function dcFecharTodosOsFiltrosColuna() { dcFiltrosColuna.forEach((f) => f.fechar()); } function dcEscapeAttr(value) { return (value === null || value === undefined ? "" : String(value)).replace(/"/g, """); } function dcCriarFiltroColuna(campo) { const th = dcListSortHeaders.find((el) => el.getAttribute("data-sort") === campo); if (!th) return; const wrapper = document.createElement("span"); wrapper.className = "dc-col-filter"; wrapper.innerHTML = ` `; th.appendChild(wrapper); const btn = wrapper.querySelector(".dc-col-filter__btn"); const popup = wrapper.querySelector(".dc-col-filter__popup"); const searchInput = wrapper.querySelector(".checklist-search"); const selectAllInput = wrapper.querySelector(".checklist-select-all input"); const box = wrapper.querySelector(".checklist-box"); const limparBtn = wrapper.querySelector(".dc-col-filter__limpar"); const aplicarBtn = wrapper.querySelector(".dc-col-filter__aplicar"); const extrator = PID_DC_LISTA_SORT_VALOR[campo]; let marcados = null; // Set em edição — só existe enquanto o popup está aberto // Ordena pelo valor bruto (dcComparaValoresLista), não pelo rótulo // formatado — importa pra "competencia" (ISO "AAAA-MM-DD", ordena certo // cronologicamente por string; o rótulo "MM/AAAA" ordenaria por mês // primeiro, misturando anos). function valoresDistintos() { return Array.from(new Set(dcListaApuracoes.map(extrator))).sort(dcComparaValoresLista); } function valoresVisiveis() { const termo = searchInput.value.trim().toLowerCase(); return valoresDistintos().filter((v) => dcLabelValorFiltroLista(campo, v).toLowerCase().includes(termo)); } function renderItens() { const visiveis = valoresVisiveis(); box.innerHTML = visiveis.length ? visiveis .map( (v) => `` ) .join("") : `

Nenhum valor encontrado.

`; selectAllInput.checked = visiveis.length > 0 && visiveis.every((v) => marcados.has(v)); } function abrir() { // `marcados` sempre recomeça a partir do filtro já aplicado (ou de // todos os valores existentes, se a coluna ainda não tem filtro) — // nunca do que ficou de uma edição anterior cancelada sem "Aplicar". marcados = dcListFiltros[campo] ? new Set(dcListFiltros[campo]) : new Set(valoresDistintos()); searchInput.value = ""; renderItens(); popup.hidden = false; btn.setAttribute("aria-expanded", "true"); } function fechar() { popup.hidden = true; marcados = null; btn.setAttribute("aria-expanded", "false"); } function atualizarBotaoAtivo() { btn.classList.toggle("is-active", !!dcListFiltros[campo]); } btn.addEventListener("click", (event) => { event.stopPropagation(); const estavaAberto = !popup.hidden; dcFecharTodosOsFiltrosColuna(); if (!estavaAberto) abrir(); }); popup.addEventListener("click", (event) => event.stopPropagation()); searchInput.addEventListener("input", renderItens); searchInput.addEventListener("keydown", (event) => { if (event.key !== "Enter") return; event.preventDefault(); const visiveis = valoresVisiveis(); if (visiveis.length) marcados = new Set(visiveis); aplicarBtn.click(); }); selectAllInput.addEventListener("change", () => { const visiveis = valoresVisiveis(); if (selectAllInput.checked) visiveis.forEach((v) => marcados.add(v)); else visiveis.forEach((v) => marcados.delete(v)); renderItens(); }); box.addEventListener("change", (event) => { const input = event.target.closest("[data-valor-filtro]"); if (!input) return; const valor = input.getAttribute("data-valor-filtro"); if (input.checked) marcados.add(valor); else marcados.delete(valor); renderItens(); }); limparBtn.addEventListener("click", () => { dcListFiltros[campo] = null; fechar(); atualizarBotaoAtivo(); dcListPage = 1; renderList(); }); aplicarBtn.addEventListener("click", () => { const todos = valoresDistintos(); // Marcar tudo equivale a "sem filtro" — mantém null nesse caso, senão // um valor novo que apareça depois ficaria de fora até o usuário // marcá-lo manualmente de novo. const marcouTudo = todos.length === marcados.size && todos.every((v) => marcados.has(v)); dcListFiltros[campo] = marcouTudo ? null : new Set(marcados); fechar(); atualizarBotaoAtivo(); dcListPage = 1; renderList(); }); dcFiltrosColuna.push({ fechar, atualizarBotaoAtivo }); } ["empresa", "competencia", "status", "criado_por"].forEach(dcCriarFiltroColuna); const dcListResetBtn = document.getElementById("dc-list-reset-btn"); if (dcListResetBtn) { dcListResetBtn.addEventListener("click", () => { dcFecharTodosOsFiltrosColuna(); dcListSortKey = "criado_em"; dcListSortDir = -1; dcListFiltros = { empresa: null, competencia: null, status: null, criado_por: null }; dcFiltrosColuna.forEach((f) => f.atualizarBotaoAtivo()); dcListPage = 1; renderList(); }); } document.addEventListener("click", (event) => { if (!event.target.closest(".dc-col-filter")) dcFecharTodosOsFiltrosColuna(); }); if (dcListPagination) { dcListPagination.addEventListener("click", (event) => { if (event.target.closest("#dc-list-prev-btn")) { dcListPage -= 1; renderList(); } else if (event.target.closest("#dc-list-next-btn")) { dcListPage += 1; renderList(); } }); } document.getElementById("dc-list-body").addEventListener("click", async (event) => { const abrirBtn = event.target.closest("[data-dc-abrir]"); if (abrirBtn) { 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; // O botão nem aparece numa análise concluída, mas o servidor recusa de // novo (ver ContabilApuracaoViewSet.perform_destroy) — pode ter sido // concluída por outra pessoa depois desta lista ter sido carregada, e aí // o motivo precisa chegar na tela em vez de o clique não fazer nada. try { await pidExcluirApuracaoContabil(Number(excluirBtn.getAttribute("data-dc-excluir"))); } catch (e) { await pidAlert(e.message); } await carregarLista(); return; } const reprocessarBtn = event.target.closest("[data-dc-reprocessar-abrir]"); if (reprocessarBtn) { abrirReprocessarModal(Number(reprocessarBtn.getAttribute("data-dc-reprocessar-abrir"))); } }); // ---- Reprocessar (anexar outro PDF pra mesma empresa/competência) ---- // Diferente de "Nova Análise" (cria uma apuração do zero), reprocessar // resincroniza a apuração já existente — o backend (`_contabil_sincroniza_*` // em views.py) preserva observação/validação de conta/linha sem mudança e // só reseta validado + marca `alterada_reprocessamento` no que realmente // mudou; achados nunca são apagados nem têm status/justificativa // sobrescritos, mesmo os que não disparam mais. Aberto direto da lista // (não precisa abrir a apuração antes), por isso tem seu próprio `id` em // vez de depender de `apuracaoAtual`. const resetReprocessarArquivoField = wireArquivoField("dc-reprocessar-arquivo"); let dcReprocessarApuracaoId = null; // Log de reprocessamentos anteriores (quem + quando) — pedido explícito do // usuário, "identificar quem reprocessou e quantos reprocessamentos já // ocorreu". `dcListaApuracoes` já carrega `reprocessamentos` (ordenado do // mais recente pro mais antigo, ver Meta.ordering do model) junto do resto // da lista, então não precisa de uma chamada de API própria — só procurar // a apuração pelo id. function renderReprocessarLog(id) { const secao = document.getElementById("dc-reprocessar-log"); const apuracao = dcListaApuracoes.find((a) => a.id === id); const reprocessamentos = apuracao ? apuracao.reprocessamentos || [] : []; secao.hidden = reprocessamentos.length === 0; if (reprocessamentos.length === 0) return; document.getElementById("dc-reprocessar-log-count").textContent = reprocessamentos.length; document.getElementById("dc-reprocessar-log-lista").innerHTML = reprocessamentos .map( (r) => `
  • ${pidDcEscapeHtml(r.reprocessado_por_nome || "Usuário removido")} · ${pidDcFormatDataHora(r.reprocessado_em)}
  • ` ) .join(""); } function abrirReprocessarModal(id) { dcReprocessarApuracaoId = id; document.getElementById("dc-reprocessar-arquivo").value = ""; resetReprocessarArquivoField(); document.getElementById("dc-reprocessar-error").textContent = ""; renderReprocessarLog(id); 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"); const cancelarBtn = document.getElementById("dc-reprocessar-cancel-btn"); const progressEl = document.getElementById("dc-reprocessar-progress"); confirmarBtn.disabled = true; cancelarBtn.disabled = true; progressEl.hidden = false; const progresso = pidDcIniciarProgressoSimulado(progressEl.querySelector(".dc-progress__fill")); try { const atualizada = await pidReprocessarApuracaoContabil(dcReprocessarApuracaoId, formData); await progresso.completar(); 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) { progresso.parar(); errorEl.textContent = e.message; } finally { confirmarBtn.disabled = false; cancelarBtn.disabled = false; progressEl.hidden = true; } }); // ---- 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"); const cancelBtn = document.getElementById("dc-form-cancel-btn"); const backBtn = document.getElementById("dc-form-back-btn"); const progressEl = document.getElementById("dc-form-progress"); submitBtn.disabled = true; cancelBtn.disabled = true; backBtn.disabled = true; progressEl.hidden = false; const progresso = pidDcIniciarProgressoSimulado(progressEl.querySelector(".dc-progress__fill")); try { const apuracao = await pidCriarApuracaoContabil(formData); await progresso.completar(); apuracaoAtual = apuracao; await dcCarregarObservacoes(); filtroSeveridade = "todas"; filtroStatusAchado = "pendente"; filtroRegra = null; renderRevisao(); showView("review"); await carregarLista(); } catch (e) { progresso.parar(); errorEl.textContent = e.message; } finally { submitBtn.disabled = false; cancelBtn.disabled = false; backBtn.disabled = false; progressEl.hidden = true; } }); // ---- 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; // Padrão de abertura: árvore **inteira expandida**, até a conta analítica // mais funda (pedido explícito do usuário — antes nascia com os grupos // recolhidos por dcColapsoPadrao(), que agora é o que o botão "−" do // cabeçalho aplica sob demanda; ver a seção dos botões mais abaixo). dcContasColapsadas = new Set(); dcDreColapsadas = new Set(); // Destaque inicial = a última leva já visível de cada ramo (com nada // recolhido, são exatamente as folhas — 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); dcContasExpandidos = new Set(); dcDreExpandidos = new Set(); 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 = new Set(); // mesmo padrão de abertura do Balancete/DRE acima (tudo expandido) dcAvDestaque = dcUltimaLevaVisivel( apuracao.linhas_analise_vertical, (linha) => Math.max(0, linha.nivel), dcAvColapsadas ); dcAvExpandidos = new Set(); renderAchados(); renderContas(); renderDre(); renderAnaliseVertical(); renderResumoFechamento(); } const PID_DC_SEVERIDADE_ORDEM = { alta: 0, media: 1, baixa: 2 }; function achadosFiltrados() { return apuracaoAtual.achados .filter((achado) => { if (filtroSeveridade !== "todas" && achado.severidade !== filtroSeveridade) return false; if (filtroStatusAchado === "pendente" && achado.status !== "pendente") return false; if (filtroRegra && achado.regra !== filtroRegra) return false; return true; }) .slice() .sort((a, b) => (PID_DC_SEVERIDADE_ORDEM[a.severidade] ?? 3) - (PID_DC_SEVERIDADE_ORDEM[b.severidade] ?? 3)); } function pidDcScrollParaLista() { document.getElementById("dc-achados-list").scrollIntoView({ behavior: "smooth", block: "nearest" }); } // Resumo do topo da aba Observações: donut por severidade (clicável, filtra // igual aos chips Alta/Média/Baixa) + grade de cards por categoria/regra // (clicáveis, isolam achado.regra na lista abaixo). Contagens sempre sobre // TODOS os achados da apuração, independente dos filtros já aplicados — // visão geral estável, mesmo espírito do "Distribuição das Análises" do // sistema externo que inspirou esta seção. function renderAchadosResumo() { const achados = apuracaoAtual.achados; const total = achados.length; // ---- Donut por severidade ---- const porSeveridade = { alta: 0, media: 0, baixa: 0 }; achados.forEach((achado) => { if (achado.severidade in porSeveridade) porSeveridade[achado.severidade] += 1; }); document.getElementById("dc-achados-donut-total").textContent = String(total); const svg = document.getElementById("dc-achados-donut-svg"); const legend = document.getElementById("dc-achados-donut-legend"); legend.innerHTML = ""; let anelSvg = ``; if (total > 0) { let acumulado = 0; PID_DC_SEVERIDADE_LISTA.forEach((severidade) => { const qtd = porSeveridade[severidade]; if (qtd === 0) return; const pct = (qtd / total) * 100; const offset = 25 - acumulado; acumulado += pct; anelSvg += ` `; }); } svg.innerHTML = anelSvg; svg.querySelectorAll("[data-dc-donut-severidade]").forEach((seg) => { seg.addEventListener("click", () => { pidDcSelecionarSeveridade(seg.getAttribute("data-dc-donut-severidade")); pidDcScrollParaLista(); }); }); PID_DC_SEVERIDADE_LISTA.forEach((severidade) => { const li = document.createElement("li"); li.className = `dc-donut-legend__item${filtroSeveridade === severidade ? " is-active" : ""}`; li.innerHTML = `${PID_DC_SEVERIDADE_LABELS[severidade]} (${porSeveridade[severidade]})`; li.addEventListener("click", () => { pidDcSelecionarSeveridade(severidade); pidDcScrollParaLista(); }); legend.appendChild(li); }); // ---- Cards por grupo temático (ver PID_DC_GRUPOS) ---- const porRegra = new Map(); PID_DC_REGRAS.forEach((r) => porRegra.set(r.chave, [])); achados.forEach((achado) => { if (!porRegra.has(achado.regra)) porRegra.set(achado.regra, []); porRegra.get(achado.regra).push(achado); }); const regraPorChave = new Map(PID_DC_REGRAS.map((r) => [r.chave, r])); const grid = document.getElementById("dc-achados-categorias"); grid.innerHTML = ""; PID_DC_GRUPOS.forEach((grupo, grupoIndex) => { const cor = PID_DC_CATEGORIA_CORES[grupoIndex % PID_DC_CATEGORIA_CORES.length]; const totalGrupo = grupo.regras.reduce((soma, chave) => soma + (porRegra.get(chave) || []).length, 0); if (grupo.somenteComAchados && totalGrupo === 0) return; const card = document.createElement("div"); card.className = "dc-categoria-card"; card.style.setProperty("--dc-categoria-cor", cor); card.innerHTML = `
    ${pidDcEscapeHtml(grupo.label)} ${totalGrupo}
    `; 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 = ` ${pidDcEscapeHtml(regraInfo.label)} ${itens.length} `; 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 temAlvoNaTabela = achado.conta != null || achado.linha_analise_vertical != null; const expandida = dcAchadosContaExpandida.has(achado.id); card.innerHTML = `
    ${PID_DC_SEVERIDADE_LABELS[achado.severidade] || achado.severidade} ${PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status} ${pidDcEscapeHtml(achado.titulo)}
    ${pidDcEscapeHtml(achado.mensagem)}
    ${conta ? ` ${expandida ? `
    Conta
    ${pidDcEscapeHtml(conta.codigo)} — ${pidDcEscapeHtml(conta.descricao)}
    Saldo Anterior
    ${pidDcFormatMoeda(conta.saldo_anterior)}
    Débito
    ${pidDcFormatMoeda(conta.debito)}
    Crédito
    ${pidDcFormatMoeda(conta.credito)}
    Saldo Atual
    ${pidDcFormatMoeda(conta.saldo_atual)}
    ` : ""} ` : ""} ${achado.observacao_contador ? `
    ${pidDcEscapeHtml(achado.observacao_contador)}${achado.tratado_por_nome ? ` — ${pidDcEscapeHtml(achado.tratado_por_nome)}` : ""}
    ` : ""}
    ${temAlvoNaTabela ? `` : ""} ${podeEditar ? `` : ""}
    `; lista.appendChild(card); }); } document.getElementById("dc-achados-list").addEventListener("click", (event) => { const toggleBtn = event.target.closest("[data-dc-achado-toggle-conta]"); if (toggleBtn) { const id = Number(toggleBtn.getAttribute("data-dc-achado-toggle-conta")); if (dcAchadosContaExpandida.has(id)) dcAchadosContaExpandida.delete(id); else dcAchadosContaExpandida.add(id); renderAchados(); return; } const irParaBtn = event.target.closest("[data-dc-achado-ir-para]"); if (irParaBtn) { const id = Number(irParaBtn.getAttribute("data-dc-achado-ir-para")); const achado = apuracaoAtual.achados.find((a) => a.id === id); if (!achado) return; if (achado.conta != null) dcIrParaLinha("conta", achado.conta); else if (achado.linha_analise_vertical != null) dcIrParaLinha("av", achado.linha_analise_vertical); return; } const btn = event.target.closest("[data-dc-achado-editar]"); if (!btn) return; abrirModalAchado(Number(btn.getAttribute("data-dc-achado-editar"))); }); // Reaproveitado tanto pelos chips de severidade quanto pelo clique no // donut/legenda do resumo (ver renderAchadosResumo) — mesmo efeito de // clicar o chip correspondente, sem duplicar a lógica de estado+re-render. function pidDcSelecionarSeveridade(severidade) { filtroSeveridade = severidade; document .querySelectorAll("#dc-achados-severidade-chips .dc-chip") .forEach((c) => c.classList.toggle("is-active", c.getAttribute("data-dc-severidade") === severidade)); renderAchados(); } document.querySelectorAll("#dc-achados-severidade-chips [data-dc-severidade]").forEach((chip) => { chip.addEventListener("click", () => pidDcSelecionarSeveridade(chip.getAttribute("data-dc-severidade"))); }); document.querySelectorAll("#dc-achados-status-chips [data-dc-status]").forEach((chip) => { chip.addEventListener("click", () => { filtroStatusAchado = chip.getAttribute("data-dc-status"); document.querySelectorAll("#dc-achados-status-chips .dc-chip").forEach((c) => c.classList.toggle("is-active", c === chip)); renderAchados(); }); }); document.getElementById("dc-regra-filtro-limpar-btn").addEventListener("click", () => { filtroRegra = null; renderAchados(); }); // O código da conta (ex. "1.01.01.002.001") já chega do backend na mesma // ordem hierárquica/sequencial do PDF (uma sintética seguida das suas // analíticas, ver portal_api/dashboard_contabil/CLAUDE.md) — o nível de // indentação vem direto da contagem de segmentos separados por ".", sem // precisar reconstruir uma árvore por id/parent (o mesmo código pode se // repetir em várias linhas irmãs, ex. três bancos diferentes sob o mesmo // "1.01.01.002.001", então não dá pra usar o código como chave única). function dcContaNivel(conta) { return conta.codigo.split(".").length - 1; } // Nível (equivalente ao 3º segmento do código de classificação, ex. // "1.01.01") a partir do qual o botão "−" do cabeçalho recolhe os grupos — // a visão compacta, só com os totais de cada nível. Até uma rodada anterior // este era o estado em que as tabelas **nasciam**; hoje elas abrem com tudo // expandido (pedido explícito do usuário, ver renderRevisao) e esta // constante só vale sob demanda. O relatório "Gerar Dashboard" segue com // seu próprio padrão, server-side e inalterado // (_CONTABIL_NIVEL_ABERTO_PADRAO em views.py, mesmo valor). const PID_DC_NIVEL_ABERTO_PADRAO = 2; function dcColapsoPadrao(itens, nivelFn) { const niveis = itens.map(nivelFn); const colapsadas = new Set(); itens.forEach((item, i) => { const temFilhos = i + 1 < itens.length && niveis[i + 1] > niveis[i]; if (temFilhos && niveis[i] >= PID_DC_NIVEL_ABERTO_PADRAO) colapsadas.add(item.id); }); return colapsadas; } // Destaque inicial (antes de qualquer clique) — pedido explícito do // usuário depois de notar que folhas genuínas (conta/linha sem filho // nenhum, ex. "(-) SIMPLES NACIONAL") ficavam de fora do destaque mesmo // sendo visualmente tão "de baixo" quanto um grupo colapsado por padrão // no mesmo nível. Usar só `colapsadas` (que só marca linhas que TÊM // filhos escondidos) deixa de fora exatamente essas folhas, já que elas // nunca entram nesse conjunto (não têm filho pra esconder). // // Critério correto, que cobre os dois casos com a mesma regra: entre as // linhas realmente visíveis com este `colapsadas`, uma linha entra no // destaque se a próxima linha visível **não** for mais profunda que ela // — ou seja, nada foi revelado logo abaixo dela agora, seja porque ela // está colapsada (filhos escondidos) ou porque é uma folha sem filho. function dcUltimaLevaVisivel(itens, nivelFn, colapsadas) { const niveis = itens.map(nivelFn); const temFilhos = itens.map((_, i) => i + 1 < itens.length && niveis[i + 1] > niveis[i]); const visiveis = []; const pilha = []; itens.forEach((item, i) => { const nivel = niveis[i]; while (pilha.length && pilha[pilha.length - 1] >= nivel) pilha.pop(); const oculta = pilha.length > 0; const temFilhoOculto = temFilhos[i] && colapsadas.has(item.id); if (temFilhoOculto) pilha.push(nivel); if (!oculta) visiveis.push({ id: item.id, nivel, temFilhoOculto }); }); const destaque = new Set(); visiveis.forEach((visivel, i) => { const proximo = visiveis[i + 1]; const eraFimDoRamo = !proximo || proximo.nivel <= visivel.nivel; if (!eraFimDoRamo) return; // Nível 0 só entra no destaque quando realmente esconde algo (grupo // colapsado manualmente) — pedido explícito do usuário: uma sequência // de linhas de resultado irmãs no nível raiz da DRE (ex. "RESULTADO // ANTES DA CS E IR", "(=) PREJUÍZO LÍQUIDO DO EXERCÍCIO") não é o fim // de um ramo aninhado, são linhas soltas lado a lado — não deviam // herdar o destaque pensado pra folha/grupo aninhado. if (visivel.nivel === 0 && !visivel.temFilhoOculto) return; destaque.add(visivel.id); }); return destaque; } // Ids das linhas de fato destacadas agora (`.dc-conta-row--destaque`) — // derivado, nunca editado à mão: sai sempre de dcDestaqueGrupos() abaixo. let dcContasDestaque = new Set(); let dcDreDestaque = new Set(); let dcAvDestaque = new Set(); // Ids dos grupos que o contador expandiu manualmente e que continuam // expandidos — entram no conjunto ao expandir, saem ao recolher (junto com // qualquer descendente do grupo recolhido, que volta a ficar invisível). // Um conjunto por árvore, já que Balancete/DRE/Análise Vertical são // independentes. let dcContasExpandidos = new Set(); let dcDreExpandidos = new Set(); let dcAvExpandidos = new Set(); // Destaque atual de uma árvore. Enquanto o contador não tiver expandido // nada, vale a leva padrão (dcUltimaLevaVisivel — toda linha que esconde // algo ou é folha genuína). A partir do primeiro clique de expandir, o // destaque passa a ser **só** a união dos filhos diretos dos grupos // expandidos manualmente, e mais nada. // // Esse "e mais nada" é o ponto: a leva padrão já marca praticamente toda // conta de nível ≥ 2 (nascem recolhidas, então toda uma é "fim de ramo"), // então somar os filhos revelados a ela deixava a tabela inteira destacada // — com tudo destacado, nada se destaca, que foi exatamente o efeito // reportado pelo usuário ("os filhos não ficaram em destaque"). Limpando o // resto, sobra o contraste de sempre; a diferença pro comportamento antigo // é que agora vários grupos abertos convivem, em vez de o mais recente // substituir o anterior. function dcDestaqueGrupos(itens, nivelFn, colapsadas, expandidos) { if (!expandidos.size) return dcUltimaLevaVisivel(itens, nivelFn, colapsadas); const destaque = new Set(); expandidos.forEach((id) => { dcFilhosDiretos(itens, nivelFn, id).forEach((filhoId) => destaque.add(filhoId)); }); return destaque; } // Filhos DIRETOS de um item (mesmo próximo nível, não os netos) — usado // pra saber quais linhas acabaram de ficar visíveis ao expandir `id`. function dcFilhosDiretos(itens, nivelFn, id) { const niveis = itens.map(nivelFn); const idx = itens.findIndex((item) => item.id === id); const filhos = new Set(); if (idx === -1) return filhos; const nivelPai = niveis[idx]; for (let j = idx + 1; j < itens.length; j++) { if (niveis[j] <= nivelPai) break; if (niveis[j] === nivelPai + 1) filhos.add(itens[j].id); } return filhos; } // TODOS os descendentes de um item (não só os diretos — filhos, netos, // bisnetos...) — usado pelo estado tri-state do botão "validado" de uma // conta/linha sintética (com filhos), ver dcEstadoValidacaoGrupo() logo // abaixo e o clique em [data-dc-conta-validado]/[data-dc-dre-validado]. // `somenteFolhas` restringe aos descendentes SEM filhos (contas/linhas // reais, nunca sintéticas) — usado pelo cálculo de "completo" de // dcEstadoValidacaoGrupo (ver comentário lá) pra ignorar o campo // `validado` de um descendente sintético intermediário, que nunca é // marcado sozinho quando só os netos (folhas) são validados um a um. function dcDescendentes(itens, nivelFn, id, somenteFolhas = false) { const niveis = itens.map(nivelFn); const idx = itens.findIndex((item) => item.id === id); const descendentes = []; if (idx === -1) return descendentes; const nivelPai = niveis[idx]; for (let j = idx + 1; j < itens.length; j++) { if (niveis[j] <= nivelPai) break; const temFilho = j + 1 < itens.length && niveis[j + 1] > niveis[j]; if (!somenteFolhas || !temFilho) descendentes.push(itens[j]); } return descendentes; } // Verdadeiro se algum descendente (filho, neto...) de uma conta/linha // sintética tem observação vigente — usado pra destacar o ícone de // observação de um grupo recolhido mesmo quando a própria sintética não // tem observação própria (pedido explícito do usuário: "caso esteja // recolhida, o usuário consegue visualizar se há ou não observações // realizadas" nas contas/linhas dentro dela). function dcTemObservacaoDescendente(tipo, itens, nivelFn, chaveFn, id) { return dcDescendentes(itens, nivelFn, id).some((item) => dcObservacoesDe(tipo, chaveFn(item)).length > 0); } // Estado do botão "validado" de uma conta/linha COM filhos (sintética) — // pedido explícito do usuário: "nenhum" (cor padrão) quando nem ela nem // nenhum descendente está validado; "completo" (verde, mesma cor de uma // folha validada) quando TODO descendente está validado; "parcial" // (amarelo) em qualquer combinação intermediária — inclui o caso de só a // própria sintética ter sido marcada (primeiro clique), sinalizando "em // andamento" até os descendentes acompanharem. // // "completo" usa só os descendentes FOLHA (`descendentesFolhas`, sem // filhos), não todo `descendentes` (que inclui sintéticas intermediárias // — ex.: uma "mãe" com "filhas" que por sua vez têm "filhos"/netos): o // campo `validado` de uma sintética intermediária nunca é marcado sozinho // quando o usuário valida os netos um a um direto (sem clicar na própria // filha) — usar todo `descendentes` nesse caso fazia a mãe nunca virar // "completo" mesmo com todos os netos validados, já que a filha // intermediária ficava com `validado=false` na checagem. `descendentes` // (todo a árvore) continua valendo pro "parcial" — uma sintética marcada // sozinha (sem cascatear) ainda deve sinalizar "em andamento" no ancestral. function dcEstadoValidacaoGrupo(item, descendentes, descendentesFolhas) { if (descendentesFolhas.length && descendentesFolhas.every((d) => d.validado)) return "completo"; if (item.validado || descendentes.some((d) => d.validado)) return "parcial"; return "nenhum"; } // Classe/título/aria do botão "validado" de uma linha — folha (`!temFilhosItem`) // continua o toggle simples de sempre; sintética (com filhos) usa o // tri-state de dcEstadoValidacaoGrupo (nenhum/parcial/completo). function dcValidadoInfo(item, itens, nivelFn, temFilhosItem) { if (!temFilhosItem) { return { classe: item.validado ? " dc-conta-validado-btn--validado" : "", title: item.validado ? "Validada — clique para desmarcar" : "Marcar como validada", label: item.validado ? "Desmarcar validação" : "Marcar como validada", pressed: item.validado, }; } const estado = dcEstadoValidacaoGrupo( item, dcDescendentes(itens, nivelFn, item.id), dcDescendentes(itens, nivelFn, item.id, true) ); if (estado === "completo") { return { classe: " dc-conta-validado-btn--validado", title: "Grupo totalmente validado — clique para desmarcar tudo", label: "Desmarcar validação de todo o grupo", pressed: true, }; } if (estado === "parcial") { return { classe: " dc-conta-validado-btn--parcial", title: "Algumas contas do grupo já validadas — clique para validar todas", label: "Validar todas as contas do grupo", pressed: false, }; } return { classe: "", title: "Marcar como validada", label: "Marcar como validada", pressed: false }; } // Clique no botão "validado" de uma conta do Balancete — pedido explícito // do usuário: numa folha (sem filhos) é só um toggle simples; numa // sintética (com filhos) o clique se comporta em 3 etapas conforme o // estado atual do grupo (ver dcEstadoValidacaoGrupo): // "nenhum" → marca só a própria sintética (passa a "parcial"). // "parcial" → pergunta antes de validar TODAS as contas do grupo de // uma vez (passa a "completo"). // "completo" → desmarca a própria sintética + todas as contas do grupo, // sem perguntar (mesmo pedido explícito: "apertar // novamente desmarca todo o grupo"). // Sempre termina com renderContas() — inclusive no caso simples de folha — // pra recalcular a cor de qualquer sintética ancestral que dependa deste // item (o estado dela é derivado dos descendentes a cada render, nunca // armazenado à parte). async function dcClicarValidadoConta(id) { const contas = apuracaoAtual.contas; const idx = contas.findIndex((c) => c.id === id); if (idx === -1) return; const niveis = contas.map(dcContaNivel); const temFilhosItem = idx + 1 < contas.length && niveis[idx + 1] > niveis[idx]; const conta = contas[idx]; if (!temFilhosItem) { const atualizada = await pidAtualizarValidadoContaContabil(id, !conta.validado); contas[idx] = atualizada; renderContas(); return; } const descendentes = dcDescendentes(contas, dcContaNivel, id); const descendentesFolhas = dcDescendentes(contas, dcContaNivel, id, true); const estado = dcEstadoValidacaoGrupo(conta, descendentes, descendentesFolhas); if (estado === "completo") { const alvos = [conta, ...descendentes].filter((item) => item.validado); const respostas = await Promise.all(alvos.map((item) => pidAtualizarValidadoContaContabil(item.id, false))); respostas.forEach((atualizada) => { const i = contas.findIndex((c) => c.id === atualizada.id); if (i >= 0) contas[i] = atualizada; }); renderContas(); return; } if (estado === "parcial") { if (!(await pidConfirm("Deseja validar todas as contas deste grupo?"))) return; const alvos = [conta, ...descendentes].filter((item) => !item.validado); const respostas = await Promise.all(alvos.map((item) => pidAtualizarValidadoContaContabil(item.id, true))); respostas.forEach((atualizada) => { const i = contas.findIndex((c) => c.id === atualizada.id); if (i >= 0) contas[i] = atualizada; }); renderContas(); return; } const atualizada = await pidAtualizarValidadoContaContabil(id, true); contas[idx] = atualizada; renderContas(); } // Mesma lógica de dcClicarValidadoConta(), sobre ContabilLinhaDre. async function dcClicarValidadoLinha(id) { const linhas = apuracaoAtual.linhas_dre; const nivelFn = (l) => Math.max(0, l.nivel); const idx = linhas.findIndex((l) => l.id === id); if (idx === -1) return; const niveis = linhas.map(nivelFn); const temFilhosItem = idx + 1 < linhas.length && niveis[idx + 1] > niveis[idx]; const linha = linhas[idx]; if (!temFilhosItem) { const atualizada = await pidAtualizarValidadoLinhaDreContabil(id, !linha.validado); linhas[idx] = atualizada; renderDre(); return; } const descendentes = dcDescendentes(linhas, nivelFn, id); const descendentesFolhas = dcDescendentes(linhas, nivelFn, id, true); const estado = dcEstadoValidacaoGrupo(linha, descendentes, descendentesFolhas); if (estado === "completo") { const alvos = [linha, ...descendentes].filter((item) => item.validado); const respostas = await Promise.all(alvos.map((item) => pidAtualizarValidadoLinhaDreContabil(item.id, false))); respostas.forEach((atualizada) => { const i = linhas.findIndex((l) => l.id === atualizada.id); if (i >= 0) linhas[i] = atualizada; }); renderDre(); return; } if (estado === "parcial") { if (!(await pidConfirm("Deseja validar todas as contas deste grupo?"))) return; const alvos = [linha, ...descendentes].filter((item) => !item.validado); const respostas = await Promise.all(alvos.map((item) => pidAtualizarValidadoLinhaDreContabil(item.id, true))); respostas.forEach((atualizada) => { const i = linhas.findIndex((l) => l.id === atualizada.id); if (i >= 0) linhas[i] = atualizada; }); renderDre(); return; } const atualizada = await pidAtualizarValidadoLinhaDreContabil(id, true); linhas[idx] = atualizada; renderDre(); } // Mesma lógica de dcClicarValidadoConta()/dcClicarValidadoLinha(), sobre // ContabilLinhaAnaliseVertical. async function dcClicarValidadoAv(id) { const linhas = apuracaoAtual.linhas_analise_vertical; const nivelFn = (l) => Math.max(0, l.nivel); const idx = linhas.findIndex((l) => l.id === id); if (idx === -1) return; const niveis = linhas.map(nivelFn); const temFilhosItem = idx + 1 < linhas.length && niveis[idx + 1] > niveis[idx]; const linha = linhas[idx]; if (!temFilhosItem) { const atualizada = await pidAtualizarValidadoLinhaAnaliseVerticalContabil(id, !linha.validado); linhas[idx] = atualizada; renderAnaliseVertical(); return; } const descendentes = dcDescendentes(linhas, nivelFn, id); const descendentesFolhas = dcDescendentes(linhas, nivelFn, id, true); const estado = dcEstadoValidacaoGrupo(linha, descendentes, descendentesFolhas); if (estado === "completo") { const alvos = [linha, ...descendentes].filter((item) => item.validado); const respostas = await Promise.all( alvos.map((item) => pidAtualizarValidadoLinhaAnaliseVerticalContabil(item.id, false)) ); respostas.forEach((atualizada) => { const i = linhas.findIndex((l) => l.id === atualizada.id); if (i >= 0) linhas[i] = atualizada; }); renderAnaliseVertical(); return; } if (estado === "parcial") { if (!(await pidConfirm("Deseja validar todas as contas deste grupo?"))) return; const alvos = [linha, ...descendentes].filter((item) => !item.validado); const respostas = await Promise.all( alvos.map((item) => pidAtualizarValidadoLinhaAnaliseVerticalContabil(item.id, true)) ); respostas.forEach((atualizada) => { const i = linhas.findIndex((l) => l.id === atualizada.id); if (i >= 0) linhas[i] = atualizada; }); renderAnaliseVertical(); return; } const atualizada = await pidAtualizarValidadoLinhaAnaliseVerticalContabil(id, true); linhas[idx] = atualizada; renderAnaliseVertical(); } // Conteúdo do botão de observação de uma conta/linha — pedido explícito // do usuário: a coluna "Observação" ficava poluída visualmente com // "+ Observação"/o texto da observação em si repetido em toda linha; // virou sempre o mesmo ícone, só a cor muda // (`.dc-conta-observacao-btn--preenchida`, ver dashboard-contabil.css) pra // indicar de relance quais linhas já têm observação, com um contador ao // lado (ver dcObsBotaoHtml). Clicar não abre um popup — abre o painel de // observações dentro da própria tabela (``), // pedido explícito do usuário pra não perder o texto por causa de um // clique fora de um modal. const PID_DC_OBSERVACAO_ICONE = ''; // Botão de "validado" (check) ao lado do de observação — pedido explícito // do usuário, um segundo controle puramente informativo pro contador // marcar que já conferiu aquela conta/linha durante a revisão (não afeta // achados/observação/relatório, só o próprio marcador). const PID_DC_VALIDADO_ICONE = ''; // Tooltip customizado no visual do Portal (ver `.dc-hover-tooltip` em // dashboard-contabil.css) — troca o balão nativo do navegador (que mostra // o `title`, sem estilo nenhum do app) por um `` posicionado via CSS // puro, pedido explícito do usuário depois de ver o tooltip nativo nos // badges abaixo. `gatilhoHtml` é o ícone (já pronto, com sua própria // classe/cor); `texto` vai só no `` do tooltip (nunca em `title`, // que voltaria a mostrar o balão nativo do navegador em cima do custom). function pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto) { return ` ${gatilhoHtml} ${pidDcEscapeHtml(texto)} `; } // Badge de alerta ao lado dos botões de validado/observação — marca uma // conta/linha que mudou no último reprocessamento (`alterada_reprocessamento`, // ver `ContabilApuracaoViewSet.reprocessar()`/`_contabil_sincroniza_*` em // views.py). Puramente informativo (não clicável) — permanece visível // mesmo depois do contador marcar a conta/linha como validada de novo // (pedido explícito do usuário: precisa dar pra identificar depois quais // itens já foram reprocessados E revalidados, não só sumir o aviso), só // muda de cor (`--danger` enquanto pendente, verde quando já validado) — // some de vez só num próximo reprocessamento sem essa mudança específica. const PID_DC_ALTERADA_ICONE = ''; // `valorAnteriorTexto` (já formatado pelo chamador — pidDcFormatMoeda() pra // Balancete/DRE, pidDcValorAnteriorAvTexto() pra Análise Vertical) some no // tooltip pra visualização rápida no hover, sem precisar abrir a apuração // anterior — pedido explícito do usuário. `null` quando o backend não tem // o valor anterior salvo (conta/linha nova, sem "antes" pra comparar). function pidDcAlteradaBadgeHtml(alterada, validado, valorAnteriorTexto) { if (!alterada) return ""; const classe = validado ? "dc-conta-alterada-badge dc-conta-alterada-badge--validada" : "dc-conta-alterada-badge"; const situacao = validado ? "Esta conta/linha mudou no último reprocessamento e já foi revalidada." : "Esta conta/linha mudou no último reprocessamento e voltou a ficar não validada."; const texto = valorAnteriorTexto ? `${situacao}\nValor antes do reprocessamento: ${valorAnteriorTexto}` : situacao; return pidDcHoverTooltipHtml(PID_DC_ALTERADA_ICONE, classe, texto); } // Só usado pela Análise Vertical (Balancete/DRE têm um valor único — // formatam com pidDcFormatMoeda() direto no chamador) — junta o valor // antes do reprocessamento de cada mês (mesmo alinhamento posicional de // ContabilApuracao.analise_vertical_meses/linha.valores, ver o model) numa // linha por mês, uma por `\n` (o atributo `title` já quebra linha nisso). function pidDcValorAnteriorAvTexto(linha) { const anteriores = linha.valores_anterior_reprocessamento; if (!anteriores || !anteriores.length) return null; const meses = apuracaoAtual.analise_vertical_meses || []; return anteriores .map((v, i) => `${pidDcFormatMesAnaliseVertical(meses[i])}: ${pidDcFormatMoeda(v.valor)} (${pidDcFormatPercentualAnaliseVertical(v.percentual)})`) .join("\n"); } // Aviso de "fonte de PDF atípica" (ContabilApuracao.fonte_pdf_atipica, // detectado em parser.py — ver _normaliza_titulo()/fonte_pdf_atipica) ao // lado do nome da empresa, tanto na lista quanto no cabeçalho da revisão. // Puramente informativo: avisa que esse PDF específico usa uma fonte // diferente da do relatório de referência, o que já causou palavras // grudadas em algumas descrições de conta em pelo menos um caso real // (confirmado contra `1751 - Balancete 07.2026.pdf`) — não há correção // automática segura pra isso (ver CLAUDE.md do pacote), então o jeito é // avisar o contador pra conferir com atenção os nomes de conta dessa // apuração específica. const PID_DC_FONTE_ATIPICA_ICONE = ''; function pidDcFontePdfAtipicaBadgeHtml(fontePdfAtipica) { if (!fontePdfAtipica) return ""; const texto = "Este relatório foi gerado a partir de um modelo de PDF diferente do padrão — pequenos erros de nomenclatura podem ocorrer em algumas contas/linhas devido à fonte usada neste documento. Vale conferir os nomes com atenção."; return pidDcHoverTooltipHtml(PID_DC_FONTE_ATIPICA_ICONE, "dc-fonte-atipica-badge", texto); } // ---- Observações: histórico por empresa+conta (ContabilObservacao) ---- // A observação deixou de ser um campo da linha e virou uma thread por // conta/linha que atravessa competências: o que o contador escreveu em // 07/2026 continua aparecendo em 08/2026, assinado e travado pra edição, // até ele encerrar ("ocultar das próximas execuções"). Por isso nada aqui // é casado por id de linha — a linha é recriada a cada apuração, a // observação não; o vínculo é a chave natural (`alvo_tipo`+`alvo_chave`), // as mesmas usadas pelo reprocessamento em `_contabil_sincroniza_*`. // Qual linha está com o painel de observações aberto, por tabela (no // máximo uma por tabela, mesmo espírito dos antigos dcContaObsEditId/ // dcDreObsEditId/dcAvObsEditId que este objeto substituiu). let dcObsPainelAberto = { conta: null, dre: null, analise_vertical: null }; // Observação (id) em edição inline dentro do painel — só uma por vez, e só // enquanto ela for da competência aberta (histórica nunca é editável). let dcObsEditandoId = null; // "todas" | "cliente" | "internas" — filtro das listas de resumo (chips em // cada aba e na aba "Dashboard"); não afeta a thread dentro do painel, que // é sempre o histórico completo daquela conta. let dcObsFiltro = "todas"; // Rótulo da aba de origem de cada tipo de alvo, usado na lista consolidada // da aba "Dashboard" (os resumos por aba não precisam, já são de um tipo). const PID_DC_OBS_ORIGENS = { conta: "Balancete", dre: "D.R.E.", analise_vertical: "Análise Vertical" }; // Mesma chave de ContabilObservacao.chave_conta() no backend — o `codigo` // de classificação sozinho não é único (o Questor reaproveita o mesmo // código pra várias contas analíticas de mesma natureza, ex. bancos // diferentes sob "Depósitos Bancários à Vista"), então a descrição // desambigua. Usar só `codigo` aqui fazia uma observação escrita numa // conta aparecer em todas as outras que compartilhavam a classificação. function dcChaveObsConta(conta) { return `${conta.codigo}|${conta.descricao}`; } // Mesma chave de ContabilObservacao.chave_linha() no backend (ver // portal_api/dashboard_contabil/chaves.py): o caminho na árvore, não a // descrição isolada. O mesmo rótulo aparece em ramos diferentes no mesmo // nível ("DESPESAS COM PESSOAL" sob "DESPESAS DE VENDAS" e sob "DESPESAS // ADMINISTRATIVAS") e o par `(descricao, nivel)` fazia a observação de uma // vazar pra outra, mesmo problema que a descrição já tinha resolvido no // Balancete. Como a chave depende dos grupos acima da linha, ela é // calculada pela tabela inteira em dcAplicaChavesObs() (no começo de // renderDre()/renderAnaliseVertical()) e guardada na própria linha — uma // linha isolada não sabe em que ramo está. function dcChaveObsLinha(linha) { return linha.chave_obs || `${linha.descricao}|${Math.max(0, linha.nivel)}`; } // Percorre a árvore na ordem de leitura mantendo a pilha de ancestrais — // mesmo algoritmo de chaves.caminhos_linhas() no backend, inclusive o // descarte de nível pulado (buraco na pilha) e o max(0, nivel). function dcAplicaChavesObs(linhas) { const pilha = []; (linhas || []).forEach((linha) => { const nivel = Math.max(0, linha.nivel); pilha.length = nivel; pilha.push(linha.descricao); const caminho = pilha.filter((parte) => parte !== undefined && parte !== "").join(" > "); linha.chave_obs = `${caminho}|${nivel}`; }); } function dcReindexaObservacoes() { dcObsIndice = { conta: new Map(), dre: new Map(), analise_vertical: new Map() }; dcObservacoes.forEach((obs) => { const mapa = dcObsIndice[obs.alvo_tipo]; if (!mapa) return; if (!mapa.has(obs.alvo_chave)) mapa.set(obs.alvo_chave, []); mapa.get(obs.alvo_chave).push(obs); }); } async function dcCarregarObservacoes() { dcObservacoes = await pidBuscarObservacoesContabil(apuracaoAtual.id); dcReindexaObservacoes(); } function dcObservacoesDe(tipo, chave) { return dcObsIndice[tipo].get(chave) || []; } function dcObservacoesDoTipo(tipo) { return dcObservacoes.filter((obs) => obs.alvo_tipo === tipo); } function dcObsAplicaFiltro(lista) { if (dcObsFiltro === "cliente") return lista.filter((obs) => obs.mostrar_ao_cliente); if (dcObsFiltro === "internas") return lista.filter((obs) => !obs.mostrar_ao_cliente); return lista; } function dcRenderTabelaDoTipo(tipo) { if (tipo === "conta") renderContas(); else if (tipo === "dre") renderDre(); else renderAnaliseVertical(); } // Qualquer mutação de observação afeta as 3 tabelas (a mesma conta pode // aparecer em mais de uma), os resumos por aba e a lista consolidada da // aba "Dashboard" — mais simples (e barato o bastante) re-renderizar tudo // do que tentar acertar só o pedaço que mudou. function dcRenderObservacoesTudo() { renderContas(); renderDre(); renderAnaliseVertical(); renderDashboardObservacoes(); } // Botão da coluna "Observação": ícone com a contagem de observações // vigentes daquela conta/linha (as desta competência mais as herdadas), // colorido quando há alguma — o texto em si nunca aparece na tabela, só no // painel (pedido antigo do usuário pra não poluir a coluna). // // `temObsDescendente` (pedido explícito do usuário: "quando é feito o // comentário em uma das contas filhas deve aparecer ressaltado o ícone na // conta mãe também, pois caso esteja recolhida, o usuário consegue // visualizar se há ou não observações realizadas") só importa quando a // própria conta/linha não tem observação própria — nesse caso o ícone // ganha um destaque mais discreto (`--descendente`, cor `--gold`, mesmo // tom do estado "parcial" do botão "validado") em vez do `--preenchida` // (`--accent`) usado quando a observação é da própria linha. function dcObsBotaoHtml(tipo, alvoId, observacoes, temObsDescendente) { const doMes = observacoes.filter((obs) => !obs.historica).length; const herdadas = observacoes.length - doMes; let titulo = "Adicionar observação"; if (observacoes.length) { const partes = []; if (doMes) partes.push(`${doMes} desta competência`); if (herdadas) partes.push(`${herdadas} do histórico`); titulo = `${observacoes.length} observação(ões): ${partes.join(", ")}`; } else if (temObsDescendente) { titulo = "Nenhuma observação nesta conta, mas há observações em contas dentro deste grupo"; } const classeDestaque = observacoes.length ? " dc-conta-observacao-btn--preenchida" : temObsDescendente ? " dc-conta-observacao-btn--descendente" : ""; const contador = observacoes.length ? `${observacoes.length}` : ""; return ``; } // Ícone de "Encerrar" trocado nesta rodada — o desenho antigo (tampa+alça+ // corpo) é literalmente o glifo padrão de lixeira, e agora existe um botão // de excluir de verdade na mesma linha de ações (PID_DC_ICON_LIXEIRA // abaixo), então os dois não podiam mais compartilhar a mesma forma sem // confundir "encerrar" (só some das próximas competências, o texto // continua no histórico) com "excluir" (apaga o registro). Ícone novo é um // "arquivo" (caixa com uma linha), sem nenhuma associação com apagar. const PID_DC_ICON_ENCERRAR = ''; const PID_DC_ICON_REATIVAR = ''; function dcObsItemHtml(obs, concluida) { // "Histórica" = veio de uma competência anterior: texto/autor/data // congelados (o backend também recusa a edição, ver // `ContabilObservacaoViewSet._garante_texto_editavel`). const podeEditarTexto = !concluida && !obs.historica; const assinatura = `${pidDcEscapeHtml(obs.criado_por_nome || "Usuário removido")} · ${pidDcFormatData(obs.criado_em)} · competência ${pidDcFormatCompetencia(obs.competencia_origem)}`; const selos = []; if (obs.historica) selos.push('Histórico'); if (obs.encerrada) { selos.push( `Encerrada em ${pidDcFormatCompetencia(obs.encerrada_em_competencia)}` ); } if (obs.editada) selos.push('Editada'); selos.push( obs.mostrar_ao_cliente ? 'Aparece ao cliente' : 'Interna' ); const acoes = [ ``, ]; if (obs.editada) { acoes.push( `` ); } if (podeEditarTexto) { acoes.push( `` ); } if (!concluida) { acoes.push( obs.encerrada ? `` : `` ); } // Excluir de verdade — pedido explícito do usuário: só quem criou pode // excluir, e só enquanto a apuração ainda está "Em revisão" (depois de // "Concluir Análise" nem edição nem exclusão são mais permitidas). O // servidor confere as duas regras de novo (ContabilObservacaoViewSet. // perform_destroy) — esconder o botão aqui é só UX. if (!concluida && obs.criado_por === me.id) { acoes.push( `` ); } const corpo = dcObsEditandoId === obs.id && podeEditarTexto ? ` ` : `

    ${pidDcEscapeHtml(obs.texto)}

    `; return `
    ${assinatura} ${selos.join("")} ${acoes.join("")}
    ${corpo}
    `; } // Modal "Histórico de edições" — só existe pra observações com `editada` // (ver `ContabilObservacaoEdicao` no backend). `obs.edicoes` já vem // carregado junto da observação (`ContabilObservacaoSerializer`), então // não faz nenhuma chamada de API própria. function pidDcAbrirHistoricoObservacao(obs) { const edicoes = [...(obs.edicoes || [])].sort((a, b) => new Date(b.editado_em) - new Date(a.editado_em)); const lista = edicoes .map( (edicao) => `
    ${pidDcEscapeHtml(edicao.editado_por_nome || "Usuário removido")} · ${pidDcFormatData(edicao.editado_em)}

    ${pidDcEscapeHtml(edicao.texto_anterior)}

    ${pidDcEscapeHtml(edicao.texto_novo)}

    ` ) .join(""); document.getElementById("dc-obs-historico-lista").innerHTML = lista || '

    Nenhuma edição registrada.

    '; document.getElementById("dc-obs-historico-modal").hidden = false; } // `` extra logo abaixo da conta/linha (mesma `.dc-obs-edit-row` de // antes): a thread do histórico em cima, o campo de observação nova // embaixo. Uma observação nunca é sobrescrita — escrever de novo cria mais // um item na thread. function dcObsPainelHtml(tipo, alvoId, observacoes, colspan, concluida) { const thread = observacoes.length ? observacoes.map((obs) => dcObsItemHtml(obs, concluida)).join("") : '

    Nenhuma observação registrada até agora.

    '; const nova = concluida ? "" : `
    `; const rodape = concluida ? '

    Análise concluída, histórico somente leitura.

    ' : ""; return `
    ${thread}
    ${nova}${rodape}
    `; } // Handler único das ações de observação, compartilhado pelos três `tbody` // (Balancete/DRE/Análise Vertical) — devolve `true` quando tratou o clique, // pra cada listener de tabela continuar cuidando só do que é dele // (expandir/recolher, validado). async function dcTrataCliqueObservacao(event, tipo) { const abrirBtn = event.target.closest("[data-dc-obs-abrir]"); if (abrirBtn) { const [tipoBtn, idTexto] = abrirBtn.getAttribute("data-dc-obs-abrir").split(":"); const id = Number(idTexto); dcObsPainelAberto[tipoBtn] = dcObsPainelAberto[tipoBtn] === id ? null : id; dcObsEditandoId = null; dcRenderTabelaDoTipo(tipo); return true; } const fecharBtn = event.target.closest("[data-dc-obs-fechar]"); if (fecharBtn) { dcObsPainelAberto[fecharBtn.getAttribute("data-dc-obs-fechar")] = null; dcObsEditandoId = null; dcRenderTabelaDoTipo(tipo); return true; } const criarBtn = event.target.closest("[data-dc-obs-criar]"); if (criarBtn) { const [alvoTipo, idTexto] = criarBtn.getAttribute("data-dc-obs-criar").split(":"); const alvoId = Number(idTexto); const erroEl = document.getElementById(`dc-obs-nova-erro-${alvoTipo}-${alvoId}`); const texto = document.getElementById(`dc-obs-nova-texto-${alvoTipo}-${alvoId}`).value.trim(); const mostrar = document.getElementById(`dc-obs-nova-mostrar-${alvoTipo}-${alvoId}`).checked; erroEl.textContent = ""; if (!texto) { erroEl.textContent = "Escreva a observação antes de salvar."; return true; } criarBtn.disabled = true; try { await pidCriarObservacaoContabil(apuracaoAtual.id, alvoTipo, alvoId, texto, mostrar); await dcCarregarObservacoes(); dcRenderObservacoesTudo(); } catch (e) { erroEl.textContent = e.message; criarBtn.disabled = false; } return true; } const historicoBtn = event.target.closest("[data-dc-obs-historico]"); if (historicoBtn) { const id = Number(historicoBtn.getAttribute("data-dc-obs-historico")); const obs = dcObservacoes.find((o) => o.id === id); if (obs) pidDcAbrirHistoricoObservacao(obs); return true; } const editarBtn = event.target.closest("[data-dc-obs-editar]"); if (editarBtn) { dcObsEditandoId = Number(editarBtn.getAttribute("data-dc-obs-editar")); dcRenderTabelaDoTipo(tipo); return true; } const editCancelarBtn = event.target.closest("[data-dc-obs-edit-cancelar]"); if (editCancelarBtn) { dcObsEditandoId = null; dcRenderTabelaDoTipo(tipo); return true; } const editSalvarBtn = event.target.closest("[data-dc-obs-edit-salvar]"); if (editSalvarBtn) { const id = Number(editSalvarBtn.getAttribute("data-dc-obs-edit-salvar")); const erroEl = document.getElementById(`dc-obs-edit-erro-${id}`); const texto = document.getElementById(`dc-obs-edit-texto-${id}`).value.trim(); erroEl.textContent = ""; if (!texto) { erroEl.textContent = "A observação não pode ficar vazia. Oculte-a se não quiser mais exibi-la nas próximas competências."; return true; } editSalvarBtn.disabled = true; try { await pidAtualizarObservacaoContabil(id, { texto }); dcObsEditandoId = null; await dcCarregarObservacoes(); dcRenderObservacoesTudo(); } catch (e) { erroEl.textContent = e.message; editSalvarBtn.disabled = false; } return true; } const encerrarBtn = event.target.closest("[data-dc-obs-encerrar]"); if (encerrarBtn) { const id = Number(encerrarBtn.getAttribute("data-dc-obs-encerrar")); const ok = await pidConfirm( "Ocultar esta observação das próximas competências? Ela continua registrada aqui e no histórico desta análise." ); if (!ok) return true; try { await pidEncerrarObservacaoContabil(id, apuracaoAtual.id); await dcCarregarObservacoes(); dcRenderObservacoesTudo(); } catch (e) { await pidAlert(e.message); } return true; } const reativarBtn = event.target.closest("[data-dc-obs-reativar]"); if (reativarBtn) { const id = Number(reativarBtn.getAttribute("data-dc-obs-reativar")); try { await pidReativarObservacaoContabil(id, apuracaoAtual.id); await dcCarregarObservacoes(); dcRenderObservacoesTudo(); } catch (e) { await pidAlert(e.message); } return true; } // Excluir de verdade — só aparece pra quem criou a observação e só // enquanto a apuração ainda está em revisão (ver dcObsItemHtml()/ // dcObsResumoItemHtml(), que já escondem o botão fora dessas condições; // o servidor confere as duas de novo, então isso aqui é só UX). const excluirBtn = event.target.closest("[data-dc-obs-excluir]"); if (excluirBtn) { const id = Number(excluirBtn.getAttribute("data-dc-obs-excluir")); const ok = await pidConfirm("Excluir esta observação? Essa ação não pode ser desfeita.", { perigoso: true }); if (!ok) return true; try { await pidExcluirObservacaoContabil(id); await dcCarregarObservacoes(); dcRenderObservacoesTudo(); } catch (e) { await pidAlert(e.message); } return true; } const visibilidadeBtn = event.target.closest("[data-dc-obs-visibilidade]"); if (visibilidadeBtn) { if (visibilidadeBtn.disabled) return true; const id = Number(visibilidadeBtn.getAttribute("data-dc-obs-visibilidade")); const obs = dcObservacoes.find((o) => o.id === id); visibilidadeBtn.disabled = true; try { await pidAtualizarObservacaoContabil(id, { mostrar_ao_cliente: !obs.mostrar_ao_cliente }); await dcCarregarObservacoes(); dcRenderObservacoesTudo(); } catch (e) { visibilidadeBtn.disabled = false; await pidAlert(e.message); } return true; } return false; } // Os chips "Todas / Visíveis ao cliente / Internas" existem em quatro // lugares (resumo de cada aba + lista consolidada da aba "Dashboard") e // compartilham a mesma variável — filtrar numa aba filtra em todas, que é o // comportamento esperado de um filtro de visibilidade global. document.querySelectorAll("[data-dc-obs-filtros]").forEach((grupo) => { grupo.addEventListener("click", (event) => { const btn = event.target.closest("[data-dc-obs-filtro]"); if (!btn) return; dcObsFiltro = btn.getAttribute("data-dc-obs-filtro"); document.querySelectorAll("[data-dc-obs-filtro]").forEach((chip) => { chip.classList.toggle("is-active", chip.getAttribute("data-dc-obs-filtro") === dcObsFiltro); }); if (apuracaoAtual) dcRenderObservacoesTudo(); }); }); function renderContas() { const body = document.getElementById("dc-contas-body"); body.innerHTML = ""; const concluida = apuracaoAtual.status === "concluida"; const contas = apuracaoAtual.contas; const niveis = contas.map(dcContaNivel); const temFilhos = contas.map((_, i) => i + 1 < contas.length && niveis[i + 1] > niveis[i]); // Pilha dos níveis de ancestrais recolhidos — enquanto uma linha tiver // algum ancestral na pilha, ela fica oculta (é descendente de um grupo // fechado), mesmo padrão de renderização de árvore a partir de uma // lista plana com profundidade já conhecida. const pilhaColapso = []; contas.forEach((conta, i) => { const nivel = niveis[i]; while (pilhaColapso.length && pilhaColapso[pilhaColapso.length - 1] >= nivel) { pilhaColapso.pop(); } const oculta = pilhaColapso.length > 0; if (temFilhos[i] && dcContasColapsadas.has(conta.id)) { pilhaColapso.push(nivel); } if (oculta) return; const expandida = !dcContasColapsadas.has(conta.id); const toggle = temFilhos[i] ? `` : ``; const validadoInfo = dcValidadoInfo(conta, contas, dcContaNivel, temFilhos[i]); const observacoesDaConta = dcObservacoesDe("conta", dcChaveObsConta(conta)); const temObsDescendente = temFilhos[i] && dcTemObservacaoDescendente("conta", contas, dcContaNivel, dcChaveObsConta, conta.id); const tr = document.createElement("tr"); tr.setAttribute("data-dc-conta-row-id", conta.id); if (conta.tipo === "S") tr.classList.add("dc-conta-row--sintetica"); if (dcContasDestaque.has(conta.id)) tr.classList.add("dc-conta-row--destaque"); if (dcFocoLinha && dcFocoLinha.tipo === "conta" && dcFocoLinha.id === conta.id) tr.classList.add("dc-conta-row--foco"); tr.innerHTML = ` ${pidDcEscapeHtml(conta.conta_numero)} ${pidDcEscapeHtml(conta.codigo)} ${toggle}${pidDcEscapeHtml(conta.descricao)} ${pidDcFormatMoeda(conta.saldo_anterior)} ${pidDcFormatMoeda(conta.debito)} ${pidDcFormatMoeda(conta.credito)} ${pidDcFormatMoeda(conta.saldo_atual)}
    ${dcObsBotaoHtml("conta", conta.id, observacoesDaConta, temObsDescendente)} ${pidDcAlteradaBadgeHtml( conta.alterada_reprocessamento, conta.validado, conta.valor_anterior_reprocessamento != null ? pidDcFormatMoeda(conta.valor_anterior_reprocessamento) : null )}
    `; 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); } }); dcAtualizaBotaoArvore("balancete", dcContasColapsadas, "do Balancete"); 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); dcContasExpandidos.add(id); } else { dcContasColapsadas.add(id); dcContasExpandidos.delete(id); // Um grupo aninhado que estava aberto dentro deste some junto — // deixa de valer pro destaque enquanto o pai estiver recolhido. dcDescendentes(apuracaoAtual.contas, dcContaNivel, id).forEach((d) => dcContasExpandidos.delete(d.id)); } dcContasDestaque = dcDestaqueGrupos(apuracaoAtual.contas, dcContaNivel, dcContasColapsadas, dcContasExpandidos); 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; // A cada render, e não só ao abrir a apuração: marcar uma linha como // validada troca o objeto pelo retorno da API (dcClicarValidadoLinha), // que vem sem a chave. dcAplicaChavesObs(linhas); 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] ? `` : ``; 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 = ` ${toggle}${pidDcEscapeHtml(linha.descricao)} ${pidDcFormatMoeda(linha.valor)}
    ${dcObsBotaoHtml("dre", linha.id, observacoesDaLinha, temObsDescendente)} ${pidDcAlteradaBadgeHtml( linha.alterada_reprocessamento, linha.validado, linha.valor_anterior_reprocessamento != null ? pidDcFormatMoeda(linha.valor_anterior_reprocessamento) : null )}
    `; 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); } }); dcAtualizaBotaoArvore("dre", dcDreColapsadas, "da DRE"); 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); const nivelFn = (linha) => Math.max(0, linha.nivel); if (estavaColapsada) { dcDreColapsadas.delete(id); dcDreExpandidos.add(id); } else { dcDreColapsadas.add(id); dcDreExpandidos.delete(id); dcDescendentes(apuracaoAtual.linhas_dre, nivelFn, id).forEach((d) => dcDreExpandidos.delete(d.id)); } dcDreDestaque = dcDestaqueGrupos(apuracaoAtual.linhas_dre, nivelFn, dcDreColapsadas, dcDreExpandidos); 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 = ` Descrição ${meses .map((mes) => { const label = pidDcEscapeHtml(pidDcFormatMesAnaliseVertical(mes)); return `${label} — Valor${label} — Variação`; }) .join("")} Observação `; } // 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; // Ver o comentário em renderDre(). dcAplicaChavesObs(linhas); 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] ? `` : ``; 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) => `${pidDcFormatMoeda(v.valor)}${pidDcFormatPercentualAnaliseVertical(v.percentual)}` ) .join(""); const tr = document.createElement("tr"); tr.setAttribute("data-dc-av-row-id", linha.id); 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"); if (dcFocoLinha && dcFocoLinha.tipo === "av" && dcFocoLinha.id === linha.id) tr.classList.add("dc-conta-row--foco"); const indentacao = nivel * 16; tr.innerHTML = ` ${toggle}${pidDcEscapeHtml(linha.descricao)} ${colunasMensais}
    ${dcObsBotaoHtml("analise_vertical", linha.id, observacoesDaLinha, temObsDescendente)} ${pidDcAlteradaBadgeHtml(linha.alterada_reprocessamento, linha.validado, pidDcValorAnteriorAvTexto(linha))}
    `; 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); } }); dcAtualizaBotaoArvore("analise-vertical", dcAvColapsadas, "da Análise Vertical"); 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); const nivelFn = (linha) => Math.max(0, linha.nivel); if (estavaColapsada) { dcAvColapsadas.delete(id); dcAvExpandidos.add(id); } else { dcAvColapsadas.add(id); dcAvExpandidos.delete(id); dcDescendentes(apuracaoAtual.linhas_analise_vertical, nivelFn, id).forEach((d) => dcAvExpandidos.delete(d.id)); } dcAvDestaque = dcDestaqueGrupos( apuracaoAtual.linhas_analise_vertical, nivelFn, dcAvColapsadas, dcAvExpandidos ); 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"); }); // ---- "Ver na tabela" (botão dos cards de achado) ---- // // Cada achado automático que referencia uma conta do Balancete ou uma // linha da Análise Vertical (ver ContabilAchadoSerializer) ganha um botão // que troca de aba, garante que a linha esteja visível (abrindo só os // ancestrais dela, não a árvore inteira) e aplica um destaque temporário // (.dc-conta-row--foco, ver dashboard-contabil.css) — pedido explícito do // usuário: contas que aparecem em mais de um centro de custo/grupo eram // difíceis de localizar manualmente na tabela a partir só da descrição no // card do achado. // // dcFocoLinha é sempre derivado/temporário (mesmo espírito de // dcContasDestaque) — nunca mais de uma linha em foco por vez, mesmo entre // tabelas diferentes, e o próprio botão sempre reinicia o timeout. let dcFocoLinha = null; // { tipo: "conta" | "av", id } let dcFocoTimeoutId = null; const DC_IR_PARA_CONFIG = { conta: { tab: "balancete", itens: () => apuracaoAtual.contas, nivelFn: dcContaNivel, colapsadas: () => dcContasColapsadas, render: () => renderContas(), seletorLinha: (id) => `#dc-contas-body tr[data-dc-conta-row-id="${id}"]`, }, av: { tab: "analise-vertical", itens: () => apuracaoAtual.linhas_analise_vertical, nivelFn: (linha) => Math.max(0, linha.nivel), colapsadas: () => dcAvColapsadas, render: () => renderAnaliseVertical(), seletorLinha: (id) => `#dc-av-body tr[data-dc-av-row-id="${id}"]`, }, }; // Ids dos ancestrais (não a linha em si) de `itens[idx]`, pela mesma pilha // de níveis usada em toda a árvore desta tela — precisa só abrir quem // esconde a linha alvo, não a árvore inteira (diferente do botão "+"). function dcAncestraisIds(itens, nivelFn, idx) { const niveis = itens.map(nivelFn); const pilha = []; for (let i = 0; i < idx; i++) { const nivel = niveis[i]; while (pilha.length && pilha[pilha.length - 1].nivel >= nivel) pilha.pop(); pilha.push({ nivel, id: itens[i].id }); } while (pilha.length && pilha[pilha.length - 1].nivel >= niveis[idx]) pilha.pop(); return pilha.map((p) => p.id); } function dcIrParaLinha(tipo, id) { const config = DC_IR_PARA_CONFIG[tipo]; if (!config) return; const itens = config.itens(); const idx = itens.findIndex((item) => item.id === id); if (idx === -1) return; dcAncestraisIds(itens, config.nivelFn, idx).forEach((ancestralId) => config.colapsadas().delete(ancestralId)); clearTimeout(dcFocoTimeoutId); dcFocoLinha = { tipo, id }; config.render(); const tabBtn = document.querySelector(`#dc-tabs [data-dc-tab="${config.tab}"]`); if (tabBtn) tabBtn.click(); requestAnimationFrame(() => { document.querySelector(config.seletorLinha(id))?.scrollIntoView({ behavior: "smooth", block: "center" }); }); dcFocoTimeoutId = setTimeout(() => { dcFocoLinha = null; config.render(); }, 2400); } // ---- 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(); }); // ---- Cabeçalho: toggle da árvore e "Restaurar formatação padrão" ---- // Dois botões por tabela, em pontas opostas da linha de cabeçalho. O padrão // de abertura é a árvore **inteira expandida** (ver renderRevisao), e o botão // da primeira coluna é um **toggle** entre esse padrão e a visão compacta de // dcColapsoPadrao() (só os totais de cada nível): // // - Mostra "−" e recolhe, enquanto nada estiver recolhido. // - Mostra "+" e volta ao padrão (tudo aberto), assim que houver qualquer // grupo recolhido. // // A face do botão é **derivada** de `colapsadas.size` a cada render (ver // dcAtualizaBotaoArvore), não de um flag próprio — assim ela continua certa // mesmo quando o contador recolhe/expande uma conta pelo toggle da linha, sem // passar por aqui. O botão de restaurar, na coluna "Observação", faz o mesmo // que o "+" (volta ao padrão); ficou redundante quando este virou toggle, e // foi mantido de propósito — é a "borracha" que o usuário já conhece das // outras telas do Portal. // // Cada árvore (Balancete/DRE/Análise Vertical) tem o seu par, porque cada uma // tem seu próprio estado de colapso/destaque. // // Os dois zeram `dc*Expandidos` junto (não populam com todos os grupos): com // o conjunto vazio o destaque volta a sair de dcUltimaLevaVisivel(), que // marca a última leva visível de cada ramo — as folhas, quando nada está // recolhido. Popular `dc*Expandidos` destacaria quase toda linha da tabela, o // que na prática é o mesmo que não destacar nada (ver dcDestaqueGrupos acima). function dcAtualizaBotaoArvore(chave, colapsadas, nomeTabela) { const btn = document.querySelector(`[data-dc-recolher-grupos="${chave}"]`); if (!btn) return; const temGrupoRecolhido = colapsadas.size > 0; btn.innerHTML = temGrupoRecolhido ? PID_DC_ICON_EXPANDIR_ARVORE : PID_DC_ICON_RECOLHER_ARVORE; btn.title = temGrupoRecolhido ? "Expandir todos os níveis (padrão desta tela)" : "Recolher os grupos (mostrar só os totais de cada nível)"; btn.setAttribute( "aria-label", temGrupoRecolhido ? `Expandir todos os níveis ${nomeTabela}` : `Recolher os grupos ${nomeTabela}` ); } document.getElementById("dc-contas-thead").addEventListener("click", (event) => { if (event.target.closest("[data-dc-recolher-grupos]")) { dcContasColapsadas = dcContasColapsadas.size ? new Set() : dcColapsoPadrao(apuracaoAtual.contas, dcContaNivel); dcContasExpandidos = new Set(); dcContasDestaque = dcUltimaLevaVisivel(apuracaoAtual.contas, dcContaNivel, dcContasColapsadas); renderContas(); return; } if (!event.target.closest("[data-dc-reset-formatacao]")) return; dcContasColapsadas = new Set(); dcContasExpandidos = new Set(); dcContasDestaque = dcUltimaLevaVisivel(apuracaoAtual.contas, dcContaNivel, dcContasColapsadas); renderContas(); }); document.getElementById("dc-dre-thead").addEventListener("click", (event) => { const nivelFn = (linha) => Math.max(0, linha.nivel); if (event.target.closest("[data-dc-recolher-grupos]")) { dcDreColapsadas = dcDreColapsadas.size ? new Set() : dcColapsoPadrao(apuracaoAtual.linhas_dre, nivelFn); dcDreExpandidos = new Set(); dcDreDestaque = dcUltimaLevaVisivel(apuracaoAtual.linhas_dre, nivelFn, dcDreColapsadas); renderDre(); return; } if (!event.target.closest("[data-dc-reset-formatacao]")) return; dcDreColapsadas = new Set(); dcDreExpandidos = new Set(); dcDreDestaque = dcUltimaLevaVisivel(apuracaoAtual.linhas_dre, nivelFn, dcDreColapsadas); renderDre(); }); document.getElementById("dc-av-thead").addEventListener("click", (event) => { const nivelFn = (linha) => Math.max(0, linha.nivel); if (event.target.closest("[data-dc-recolher-grupos]")) { dcAvColapsadas = dcAvColapsadas.size ? new Set() : dcColapsoPadrao(apuracaoAtual.linhas_analise_vertical, nivelFn); dcAvExpandidos = new Set(); dcAvDestaque = dcUltimaLevaVisivel(apuracaoAtual.linhas_analise_vertical, nivelFn, dcAvColapsadas); renderAnaliseVertical(); return; } if (!event.target.closest("[data-dc-reset-formatacao]")) return; dcAvColapsadas = new Set(); dcAvExpandidos = new Set(); dcAvDestaque = dcUltimaLevaVisivel(apuracaoAtual.linhas_analise_vertical, nivelFn, dcAvColapsadas); 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 = `

    ${pidDcEscapeHtml(nomeGrupo)}

    `; 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 = `
    ${pidDcEscapeHtml(meta.nome)} ${pidDcFormatIndicadorGenerico(meta.formato, indicadores[chave])}
    `; card.addEventListener("click", (event) => { if (event.target.closest("button")) return; pidDcAbrirFormulaModal(meta); }); grid.appendChild(card); }); container.appendChild(secao); }); } document.getElementById("dc-dash-indicadores").addEventListener("click", async (event) => { const editarBtn = event.target.closest("[data-dc-dash-indicador-editar]"); if (editarBtn) { if (editarBtn.disabled) return; const definicaoId = Number(editarBtn.getAttribute("data-dc-dash-indicador-editar")); try { const definicao = await pidBuscarIndicadorContabilDefinicao(definicaoId); pidDcAbrirIndicadorModal(definicao); } catch (e) { await pidAlert(e.message); } return; } const btn = event.target.closest("[data-dc-dash-indicador-toggle]"); if (!btn || btn.disabled) return; const chave = btn.getAttribute("data-dc-dash-indicador-toggle"); const atual = dcIndicadoresAtual.indicadores_ocultos; const novaLista = atual.includes(chave) ? atual.filter((c) => c !== chave) : [...atual, chave]; btn.disabled = true; try { const resposta = await pidAtualizarIndicadoresOcultosContabil(apuracaoAtual.id, novaLista); dcIndicadoresAtual.indicadores_ocultos = resposta.indicadores_ocultos; renderDashboardIndicadores(); } catch (e) { btn.disabled = false; await pidAlert(e.message); } }); // ---- Modal "Ver fórmula" ---- // Aceita um objeto {nome, descricao, formula_texto} direto — não só uma // chave de `dcIndicadoresAtual.metadados` (que só lista indicadores // ATIVOS nesta apuração). Um indicador não padrão ainda não selecionado // pra esta apuração não aparece em `metadados`, mas o hub de // "Gerenciar Indicadores" (ver pidDcCriaLinhaHub) ainda precisa poder // mostrar a fórmula dele — por isso o chamador monta o objeto certo em // cada caso, em vez desta função sempre resolver por chave. function pidDcAbrirFormulaModal(meta) { if (!meta) return; document.getElementById("dc-indicador-formula-modal-titulo").textContent = meta.nome; document.getElementById("dc-indicador-formula-modal-descricao").textContent = meta.descricao || "Sem descrição cadastrada."; document.getElementById("dc-indicador-formula-modal-formula").textContent = meta.formula_texto || "—"; document.getElementById("dc-indicador-formula-modal").hidden = false; } document.getElementById("dc-indicador-formula-modal-fechar-btn").addEventListener("click", () => { document.getElementById("dc-indicador-formula-modal").hidden = true; }); document.getElementById("dc-indicador-formula-modal").addEventListener("click", (event) => { if (event.target.id === "dc-indicador-formula-modal") { document.getElementById("dc-indicador-formula-modal").hidden = true; } }); document.getElementById("dc-obs-historico-modal-fechar-btn").addEventListener("click", () => { document.getElementById("dc-obs-historico-modal").hidden = true; }); document.getElementById("dc-obs-historico-modal").addEventListener("click", (event) => { if (event.target.id === "dc-obs-historico-modal") { document.getElementById("dc-obs-historico-modal").hidden = true; } }); // ---- Modal "Novo/Editar Indicador" ---- // Opções de ícone do card no relatório "Gerar Dashboard" — cópia irmã de // `_CONTABIL_ICONES_SVG` em portal_api/views.py (mesmas chaves/desenhos), // mantida em sincronia manualmente (ver comentário em // IndicadorContabilDefinicao.icone, models.py, sobre por que não dá pra // ter uma fonte única entre o backend e este JS). const PID_DC_INDICADOR_ICONES = [ { chave: "barras", rotulo: "Barras", svg: '' }, { chave: "tendencia-alta", rotulo: "Tendência de alta", svg: '', }, { chave: "tendencia-baixa", rotulo: "Tendência de baixa", svg: '', }, { chave: "percentual", rotulo: "Percentual", svg: '', }, { chave: "pizza", rotulo: "Gráfico de pizza", svg: '', }, { chave: "atividade", rotulo: "Atividade", svg: '' }, { chave: "moeda", rotulo: "Cifrão", svg: '', }, { chave: "alvo", rotulo: "Alvo/meta", svg: '', }, { chave: "camadas", rotulo: "Camadas", svg: '' + '', }, { chave: "cartao", rotulo: "Cartão", svg: '', }, { chave: "selo", rotulo: "Selo/prêmio", svg: '', }, ]; const PID_DC_INDICADOR_ICONE_PADRAO = "barras"; const indicadorModal = document.getElementById("dc-indicador-modal"); const indicadorComponentesEl = document.getElementById("dc-indicador-componentes"); const indicadorIconesEl = document.getElementById("dc-indicador-icones"); let dcIndicadorEmEdicao = null; // definição completa (id/chave/.../componentes) ou null = criando let dcIndicadorIconeSelecionado = PID_DC_INDICADOR_ICONE_PADRAO; let dcIndicadorComponenteSeq = 0; // Grade de ícones é montada uma única vez (não depende da definição em // edição, só a seleção ativa muda) — cada botão já é o próprio preview do // ícone, sem precisar de nenhum outro elemento de preview separado. PID_DC_INDICADOR_ICONES.forEach((icone) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = "dc-ind-icone-btn"; btn.setAttribute("data-dc-icone", icone.chave); btn.setAttribute("aria-label", icone.rotulo); btn.title = icone.rotulo; btn.innerHTML = `${icone.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 = `
    ${orfaos.map((codigo) => ` `).join("")} ${apuracaoAtual.contas.map((conta) => ` `).join("")}
    `; } 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 = `
    ${orfaos.map((descricao) => ` `).join("")} ${apuracaoAtual.linhas_dre.map((linha) => ` `).join("")}
    `; } else if (tipo === "indicador") { const atual = valoresIniciais.indicador_referenciado || ""; const chaveEditando = dcIndicadorEmEdicao ? dcIndicadorEmEdicao.chave : null; const metadados = (dcIndicadoresAtual && dcIndicadoresAtual.metadados) || {}; const opcoes = Object.keys(metadados) .filter((chave) => chave !== chaveEditando) .sort((a, b) => metadados[a].nome.localeCompare(metadados[b].nome)); picker.innerHTML = ` `; } else if (tipo === "resultado_liquido") { // Sem seleção nenhuma — sempre resolve pra última linha da DRE desta // apuração, seja qual for o rótulo (evita quebrar quando o resultado // muda de sinal e a linha passa de "LUCRO" pra "PREJUÍZO", ou // vice-versa, ver dashboard_contabil/models.py). picker.innerHTML = `

    Usa automaticamente o resultado líquido do período (última linha da DRE desta apuração), sem precisar escolher nada aqui.

    `; } 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 = `
    `; pidDcRenderComponentePicker(linha, tipoInicial, valoresIniciais); return linha; } indicadorComponentesEl.addEventListener("change", (event) => { if (event.target.classList.contains("dc-ind-comp-tipo")) { const linha = event.target.closest(".dc-ind-componente"); pidDcRenderComponentePicker(linha, event.target.value, {}); } }); indicadorComponentesEl.addEventListener("input", (event) => { if (!event.target.hasAttribute("data-dc-comp-busca")) return; const linha = event.target.closest(".dc-ind-componente"); const termo = event.target.value.trim().toLowerCase(); linha.querySelectorAll("[data-dc-comp-item]").forEach((item) => { item.hidden = termo !== "" && !item.getAttribute("data-dc-comp-item").includes(termo); }); }); indicadorComponentesEl.addEventListener("click", (event) => { const removerBtn = event.target.closest(".dc-ind-comp-remover"); if (removerBtn) removerBtn.closest(".dc-ind-componente").remove(); }); document.getElementById("dc-indicador-add-componente-btn").addEventListener("click", () => { indicadorComponentesEl.appendChild(pidDcCriaLinhaComponente({})); }); function pidDcAbrirIndicadorModal(definicao) { dcIndicadorEmEdicao = definicao || null; document.getElementById("dc-indicador-modal-titulo").textContent = definicao ? "Editar Indicador" : "Novo Indicador"; document.getElementById("dc-indicador-modal-nome").value = definicao ? definicao.nome : ""; document.getElementById("dc-indicador-modal-descricao").value = definicao ? definicao.descricao : ""; document.getElementById("dc-indicador-modal-formato").value = definicao ? definicao.formato : "indice"; document.getElementById("dc-indicador-modal-formula").value = definicao ? definicao.formula : ""; document.getElementById("dc-indicador-modal-formula-exibicao").value = definicao ? definicao.formula_exibicao : ""; document.getElementById("dc-indicador-modal-padrao").checked = definicao ? definicao.padrao : true; pidDcSelecionaIcone(definicao ? definicao.icone : PID_DC_INDICADOR_ICONE_PADRAO); document.getElementById("dc-indicador-modal-error").textContent = ""; document.getElementById("dc-indicador-modal-excluir-btn").hidden = !definicao; document.getElementById("dc-indicador-preview").hidden = true; indicadorComponentesEl.innerHTML = ""; const componentesIniciais = definicao ? definicao.componentes : []; if (componentesIniciais.length) { componentesIniciais.forEach((componente) => indicadorComponentesEl.appendChild(pidDcCriaLinhaComponente(componente))); } else { indicadorComponentesEl.appendChild(pidDcCriaLinhaComponente({})); } indicadorModal.hidden = false; } function pidDcFecharIndicadorModal() { indicadorModal.hidden = true; dcIndicadorEmEdicao = null; } // Fechar por "Cancelar" ou clicando fora (overlay) sempre pede confirmação // — pedido explícito do usuário, mesmo padrão já usado no modal de "Mais // informações" (ajuda-aplicacao.js) e no antigo "Fechar sem salvar" do // Cadastro de Regras de Plano de Saúde (ver "Modal de confirmação // genérico" no CLAUDE.md da raiz). Só esses dois caminhos passam por // aqui — depois de salvar/excluir com sucesso, o próprio fluxo chama // `pidDcFecharIndicadorModal()` direto, sem perguntar (não há o que // descartar). async function pidDcFecharIndicadorModalComConfirmacao() { if (!(await pidConfirm("Sair sem salvar as alterações?", { perigoso: true }))) return; pidDcFecharIndicadorModal(); } document.getElementById("dc-indicador-modal-cancel-btn").addEventListener("click", pidDcFecharIndicadorModalComConfirmacao); indicadorModal.addEventListener("click", (event) => { if (event.target === indicadorModal) pidDcFecharIndicadorModalComConfirmacao(); }); // Depois de criar/editar/excluir, além de recarregar os cards da aba // "Dashboard", atualiza o hub também se ele estiver aberto por trás // (o form de indicador é sempre stacked por cima dele, ver // `.modal-overlay--top`/pidDcAbrirHubIndicadores) — sem isso, o hub // ficaria com a lista desatualizada até ser reaberto. async function pidDcAposSalvarOuExcluirIndicador() { dcIndicadoresAtual = null; await renderDashboardTab(); if (!indicadoresHubModal.hidden) await pidDcRenderHubIndicadores(); } // Reaproveitada pelo "Salvar" e pelo "Calcular com esta apuração" — os // dois precisam exatamente da mesma leitura do formulário (chave/tipo/ // seleção de cada linha de componente). function pidDcColetaComponentesForm() { return [...indicadorComponentesEl.querySelectorAll(".dc-ind-componente")].map((linha) => { const tipo = linha.querySelector(".dc-ind-comp-tipo").value; const chave = linha.querySelector(".dc-ind-comp-chave").value.trim(); const componente = { chave, tipo, contas_codigos: [], linhas_dre_descricoes: [], indicador_referenciado: "" }; if (tipo === "contas" || tipo === "variacao_conta") { componente.contas_codigos = [...linha.querySelectorAll('[data-dc-comp-lista] input[type="checkbox"]:checked')].map( (cb) => cb.value ); } else if (tipo === "linha_dre") { componente.linhas_dre_descricoes = [ ...linha.querySelectorAll('[data-dc-comp-lista] input[type="checkbox"]:checked'), ].map((cb) => cb.value); } else if (tipo === "indicador") { const select = linha.querySelector(".dc-ind-comp-indicador"); componente.indicador_referenciado = select ? select.value : ""; } return componente; }); } document.getElementById("dc-indicador-modal-save-btn").addEventListener("click", async () => { const errorEl = document.getElementById("dc-indicador-modal-error"); errorEl.textContent = ""; const componentes = pidDcColetaComponentesForm(); const payload = { nome: document.getElementById("dc-indicador-modal-nome").value.trim(), descricao: document.getElementById("dc-indicador-modal-descricao").value.trim(), formato: document.getElementById("dc-indicador-modal-formato").value, formula: document.getElementById("dc-indicador-modal-formula").value.trim(), formula_exibicao: document.getElementById("dc-indicador-modal-formula-exibicao").value.trim(), padrao: document.getElementById("dc-indicador-modal-padrao").checked, icone: dcIndicadorIconeSelecionado, componentes, }; if (!payload.nome) { errorEl.textContent = "Informe o nome do indicador."; return; } if (!componentes.length) { errorEl.textContent = "Adicione pelo menos um componente."; return; } if (componentes.some((c) => !c.chave)) { errorEl.textContent = "Preencha a chave de todos os componentes."; return; } if (!payload.formula) { errorEl.textContent = "Informe a fórmula do indicador."; return; } const saveBtn = document.getElementById("dc-indicador-modal-save-btn"); saveBtn.disabled = true; try { if (dcIndicadorEmEdicao) { await pidAtualizarIndicadorContabil(dcIndicadorEmEdicao.id, payload); } else { await pidCriarIndicadorContabil(payload); } pidDcFecharIndicadorModal(); await pidDcAposSalvarOuExcluirIndicador(); } catch (e) { errorEl.textContent = e.message; } finally { saveBtn.disabled = false; } }); // Calcula, contra a apuração aberta agora, o valor de cada componente e // o resultado final — sem salvar nada — pro contador conferir se a // fórmula pegou a conta/linha certa antes de confirmar. Reaproveita a // mesma coleta do formulário do botão "Salvar" (`pidDcColetaComponentesForm`). document.getElementById("dc-indicador-calcular-btn").addEventListener("click", async () => { const previewEl = document.getElementById("dc-indicador-preview"); const componentes = pidDcColetaComponentesForm(); const formula = document.getElementById("dc-indicador-modal-formula").value.trim(); previewEl.hidden = false; if (!componentes.length || componentes.some((c) => !c.chave) || !formula) { previewEl.innerHTML = `

    Preencha a chave de todos os componentes e a fórmula antes de calcular.

    `; 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 = `

    Calculando...

    `; try { const resultado = await pidPreVisualizarIndicadorContabil(apuracaoAtual.id, payload); if (resultado.erro) { previewEl.innerHTML = `

    ${pidDcEscapeHtml(resultado.erro)}

    `; } else { previewEl.innerHTML = `
      ${resultado.componentes.map((c) => `
    • ${pidDcEscapeHtml(c.chave)}${c.valor_formatado === null ? "—" : pidDcEscapeHtml(c.valor_formatado)}
    • `).join("")}
    Resultado: ${resultado.resultado_formatado === null ? "—" : pidDcEscapeHtml(resultado.resultado_formatado)}
    `; } } catch (e) { previewEl.innerHTML = `

    ${pidDcEscapeHtml(e.message)}

    `; } finally { calcularBtn.disabled = false; } }); document.getElementById("dc-indicador-modal-excluir-btn").addEventListener("click", async () => { if (!dcIndicadorEmEdicao) return; if ( !(await pidConfirm(`Excluir o indicador "${dcIndicadorEmEdicao.nome}"? Essa ação não pode ser desfeita.`, { perigoso: true, })) ) { return; } const errorEl = document.getElementById("dc-indicador-modal-error"); errorEl.textContent = ""; try { await pidExcluirIndicadorContabil(dcIndicadorEmEdicao.id); pidDcFecharIndicadorModal(); await pidDcAposSalvarOuExcluirIndicador(); } catch (e) { errorEl.textContent = e.message; } }); // ---- Modal "Gerenciar Indicadores" (hub: lista todos os indicadores — // padrão e não padrão — pra visualizar/editar/ativar, mesmo os que não // estão aparecendo nesta apuração agora) ---- const indicadoresHubModal = document.getElementById("dc-indicadores-hub-modal"); function pidDcCriaLinhaHub(definicao, ativo, concluida) { const linha = document.createElement("div"); linha.className = "dc-ind-hub-item"; linha.innerHTML = ` ${ ativo === null ? `` : `` } `; return linha; } async function pidDcRenderHubIndicadores() { const listaPadrao = document.getElementById("dc-ind-hub-lista-padrao"); const listaNaoPadrao = document.getElementById("dc-ind-hub-lista-nao-padrao"); const vazioPadrao = document.getElementById("dc-ind-hub-vazio-padrao"); const vazioNaoPadrao = document.getElementById("dc-ind-hub-vazio-nao-padrao"); listaPadrao.innerHTML = ""; listaNaoPadrao.innerHTML = ""; let definicoes; try { definicoes = await pidBuscarDefinicoesIndicadoresContabil(); } catch (e) { await pidAlert(e.message); return; } const selecionados = new Set((dcIndicadoresAtual && dcIndicadoresAtual.indicadores_selecionados) || []); const concluida = apuracaoAtual.status === "concluida"; const padrao = definicoes.filter((d) => d.padrao); const naoPadrao = definicoes.filter((d) => !d.padrao); vazioPadrao.hidden = padrao.length > 0; vazioNaoPadrao.hidden = naoPadrao.length > 0; padrao.forEach((definicao) => listaPadrao.appendChild(pidDcCriaLinhaHub(definicao, null, concluida))); naoPadrao.forEach((definicao) => listaNaoPadrao.appendChild(pidDcCriaLinhaHub(definicao, selecionados.has(definicao.chave), concluida)) ); } async function pidDcAbrirHubIndicadores() { indicadoresHubModal.hidden = false; await pidDcRenderHubIndicadores(); } document.getElementById("dc-indicadores-gerenciar-btn").addEventListener("click", pidDcAbrirHubIndicadores); document.getElementById("dc-indicadores-hub-fechar-btn").addEventListener("click", () => { indicadoresHubModal.hidden = true; }); indicadoresHubModal.addEventListener("click", (event) => { if (event.target === indicadoresHubModal) indicadoresHubModal.hidden = true; }); document.getElementById("dc-indicadores-hub-novo-btn").addEventListener("click", () => pidDcAbrirIndicadorModal(null)); indicadoresHubModal.addEventListener("click", async (event) => { const verBtn = event.target.closest("[data-dc-hub-ver]"); if (verBtn) { const definicaoId = Number(verBtn.getAttribute("data-dc-hub-ver")); try { const definicao = await pidBuscarIndicadorContabilDefinicao(definicaoId); pidDcAbrirFormulaModal({ nome: definicao.nome, descricao: definicao.descricao, formula_texto: definicao.formula, }); } catch (e) { await pidAlert(e.message); } return; } const editarBtn = event.target.closest("[data-dc-hub-editar]"); if (editarBtn) { if (editarBtn.disabled) return; const definicaoId = Number(editarBtn.getAttribute("data-dc-hub-editar")); try { const definicao = await pidBuscarIndicadorContabilDefinicao(definicaoId); pidDcAbrirIndicadorModal(definicao); } catch (e) { await pidAlert(e.message); } } }); indicadoresHubModal.addEventListener("change", async (event) => { const checkbox = event.target.closest("[data-dc-hub-toggle]"); if (!checkbox || checkbox.disabled) return; const chave = checkbox.getAttribute("data-dc-hub-toggle"); const atual = new Set(dcIndicadoresAtual.indicadores_selecionados || []); if (checkbox.checked) atual.add(chave); else atual.delete(chave); checkbox.disabled = true; try { await pidAtualizarIndicadoresSelecionadosContabil(apuracaoAtual.id, [...atual]); dcIndicadoresAtual = null; await renderDashboardTab(); await pidDcRenderHubIndicadores(); } catch (e) { checkbox.checked = !checkbox.checked; checkbox.disabled = false; await pidAlert(e.message); } }); // Lista consolidada da aba "Dashboard": as observações vigentes das três // tabelas (Balancete/DRE/Análise Vertical) mais as justificativas de // auditoria — pré-visualização do que vai (ou não) pro relatório do // cliente. Observação segue o histórico (`ContabilObservacao`), auditoria // continua presa à apuração (`ContabilAchado.oculto_no_relatorio`), então // os dois tipos de item têm ações diferentes. function renderDashboardObservacoes() { const lista = document.getElementById("dc-dash-observacoes-list"); const empty = document.getElementById("dc-dash-observacoes-empty"); const concluida = apuracaoAtual.status === "concluida"; const observacoes = dcObsAplicaFiltro(dcObservacoes); const achados = apuracaoAtual.achados.filter((achado) => { if (!achado.observacao_contador) return false; if (dcObsFiltro === "cliente") return !achado.oculto_no_relatorio; if (dcObsFiltro === "internas") return achado.oculto_no_relatorio; return true; }); const html = [ ...observacoes.map((obs) => dcObsResumoItemHtml(obs, PID_DC_OBS_ORIGENS[obs.alvo_tipo], concluida)), ...achados.map( (achado) => `
    Auditoria ${pidDcEscapeHtml(achado.titulo)}

    ${pidDcEscapeHtml(achado.observacao_contador)}

    ` ), ]; empty.hidden = html.length > 0; lista.innerHTML = html.join(""); } document.getElementById("dc-dash-observacoes-list").addEventListener("click", async (event) => { const achadoBtn = event.target.closest("[data-dc-dash-achado-toggle]"); if (achadoBtn) { if (achadoBtn.disabled) return; const id = Number(achadoBtn.getAttribute("data-dc-dash-achado-toggle")); const achado = apuracaoAtual.achados.find((a) => a.id === id); achadoBtn.disabled = true; try { const atualizado = await pidAlternarOcultoAchadoContabil(id, !achado.oculto_no_relatorio); const indice = apuracaoAtual.achados.findIndex((a) => a.id === id); apuracaoAtual.achados[indice] = atualizado; renderDashboardObservacoes(); } catch (e) { achadoBtn.disabled = false; await pidAlert(e.message); } return; } await dcTrataCliqueObservacao(event, "conta"); }); // ---- Resumo de observações no final de cada aba ---- // Cada aba (Balancete/DRE/Análise Vertical) lista as observações vigentes // daquela seção — as escritas nesta competência e as herdadas do histórico // —, com a assinatura de quem escreveu, o botão de olho (mostrar ao // cliente) e o de encerrar/reativar, sem precisar abrir conta por conta. // Os chips "Todas / Visíveis ao cliente / Internas" filtram as quatro // listas ao mesmo tempo (ver dcObsFiltro). function dcObsResumoItemHtml(obs, origem, concluida) { const selos = []; if (obs.historica) selos.push('Histórico'); if (obs.encerrada) { selos.push( `Encerrada em ${pidDcFormatCompetencia(obs.encerrada_em_competencia)}` ); } const acaoEncerrar = concluida ? "" : obs.encerrada ? `` : ``; // Mesma regra de dcObsItemHtml() — só quem criou, só antes de concluir. const acaoExcluir = !concluida && obs.criado_por === me.id ? `` : ""; return `
    ${origem ? `${pidDcEscapeHtml(origem)}` : ""} ${pidDcEscapeHtml(obs.alvo_rotulo || obs.alvo_chave)}

    ${pidDcEscapeHtml(obs.texto)}

    ${pidDcEscapeHtml(obs.criado_por_nome || "Usuário removido")} · ${pidDcFormatData(obs.criado_em)} · competência ${pidDcFormatCompetencia(obs.competencia_origem)} ${selos.length ? `${selos.join("")}` : ""}
    ${acaoEncerrar} ${acaoExcluir}
    `; } 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"); });