portal_publico/static/js/dashboard-contabil.js

795 lines
34 KiB
JavaScript

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 10 regras/chaves de regras.py (REGRAS) — enumeradas aqui pra sempre
// mostrar as 10 categorias no resumo, mesmo as que não geraram achado nesta
// apuração (mesmo espírito do "Nenhum registro encontrado" do sistema externo
// que inspirou esta visão).
const PID_DC_REGRAS = [
{ chave: "balanceamento_ativo_passivo", label: "Balanceamento Ativo x Passivo" },
{ chave: "debito_credito_divergente", label: "Débito x Crédito" },
{ chave: "saldo_negativo_caixa", label: "Caixa Negativo" },
{ chave: "conta_transitoria_com_saldo", label: "Contas Transitórias" },
{ chave: "conta_deveria_zerar", label: "Contas que Deveriam Zerar" },
{ chave: "saldo_sinal_invertido", label: "Sinal de Saldo Invertido" },
{ chave: "descricao_generica", label: "Descrição Genérica" },
{ chave: "variacao_atipica_saldo", label: "Variação Atípica de Saldo" },
{ chave: "variacao_atipica_dre", label: "Variação Atípica na DRE" },
{ chave: "percentual_custo_receita_atipico", label: "Percentual Custo/Receita" },
];
// 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" });
}
function pidDcFormatCompetencia(iso) {
if (!iso) return "";
const [ano, mes] = iso.split("-");
return `${mes}/${ano}`;
}
function pidDcFormatData(iso) {
if (!iso) return "";
return new Date(iso).toLocaleDateString("pt-BR");
}
function pidDcEscapeHtml(texto) {
const div = document.createElement("div");
div.textContent = texto == null ? "" : String(texto);
return div.innerHTML;
}
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" });
}
/**
* /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");
}
async function pidAtualizarObservacaoContaContabil(id, observacao) {
return pidApiRequest(`/contabil-contas/${id}/`, { method: "PATCH", body: { observacao } });
}
async function pidAtualizarObservacaoLinhaDreContabil(id, observacao) {
return pidApiRequest(`/contabil-linhas-dre/${id}/`, { method: "PATCH", body: { observacao } });
}
async function pidAtualizarAchadoContabil(id, status, observacaoContador) {
return pidApiRequest(`/contabil-achados/${id}/`, {
method: "PATCH",
body: { status, observacao_contador: observacaoContador },
});
}
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;
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();
// 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();
function showView(view) {
listView.hidden = view !== "list";
formView.hidden = view !== "form";
reviewView.hidden = view !== "review";
}
// ---- Histórico ----
async function carregarLista() {
const apuracoes = await pidFetchApuracoesContabil();
const body = document.getElementById("dc-list-body");
const empty = document.getElementById("dc-list-empty");
body.innerHTML = "";
empty.hidden = apuracoes.length > 0;
apuracoes.forEach((apuracao) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${pidDcEscapeHtml(apuracao.nome_empresa)}</td>
<td>${pidDcFormatCompetencia(apuracao.competencia)}</td>
<td>${apuracao.total_achados_pendentes}</td>
<td><span class="status-pill ${apuracao.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"}">${PID_DC_STATUS_LABELS[apuracao.status] || apuracao.status}</span></td>
<td>${pidDcEscapeHtml(apuracao.criado_por_nome || "-")}</td>
<td>${pidDcFormatData(apuracao.criado_em)}</td>
<td class="pa-row-actions">
<button type="button" class="btn-outline" data-dc-abrir="${apuracao.id}">Abrir</button>
<button type="button" class="icon-btn" data-dc-excluir="${apuracao.id}" aria-label="Excluir" title="Excluir">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</td>
`;
body.appendChild(tr);
});
}
document.getElementById("dc-list-body").addEventListener("click", async (event) => {
const abrirBtn = event.target.closest("[data-dc-abrir]");
if (abrirBtn) {
await abrirRevisao(Number(abrirBtn.getAttribute("data-dc-abrir")));
return;
}
const excluirBtn = event.target.closest("[data-dc-excluir]");
if (excluirBtn) {
if (!(await pidConfirm("Excluir esta análise? Essa ação não pode ser desfeita.", { perigoso: true }))) return;
await pidExcluirApuracaoContabil(Number(excluirBtn.getAttribute("data-dc-excluir")));
await carregarLista();
}
});
// ---- Nova Análise ----
function wireArquivoField(inputId) {
const input = document.getElementById(inputId);
const nameEl = document.getElementById(`${inputId}-name`);
const removeBtn = document.getElementById(`${inputId}-remove`);
function atualizar() {
const arquivo = input.files[0];
nameEl.textContent = arquivo ? arquivo.name : "Nenhum arquivo selecionado";
removeBtn.hidden = !arquivo;
}
input.addEventListener("change", atualizar);
removeBtn.addEventListener("click", () => {
input.value = "";
atualizar();
});
return atualizar;
}
const resetArquivoField = wireArquivoField("dc-form-arquivo");
document.getElementById("dc-new-btn").addEventListener("click", () => {
document.getElementById("dc-form-arquivo").value = "";
resetArquivoField();
document.getElementById("dc-form-error").textContent = "";
showView("form");
});
document.getElementById("dc-form-back-btn").addEventListener("click", () => showView("list"));
document.getElementById("dc-form-cancel-btn").addEventListener("click", () => showView("list"));
document.getElementById("dc-form-submit-btn").addEventListener("click", async () => {
const errorEl = document.getElementById("dc-form-error");
errorEl.textContent = "";
const arquivo = document.getElementById("dc-form-arquivo").files[0];
if (!arquivo) {
errorEl.textContent = "Anexe o PDF de Balancete + DRE.";
return;
}
const formData = new FormData();
formData.append("arquivo", arquivo);
const submitBtn = document.getElementById("dc-form-submit-btn");
submitBtn.disabled = true;
try {
const apuracao = await pidCriarApuracaoContabil(formData);
apuracaoAtual = apuracao;
filtroSeveridade = "todas";
filtroStatusAchado = "pendente";
filtroRegra = null;
renderRevisao();
showView("review");
await carregarLista();
} catch (e) {
errorEl.textContent = e.message;
} finally {
submitBtn.disabled = false;
}
});
// ---- Revisão ----
async function abrirRevisao(id) {
apuracaoAtual = await pidFetchApuracaoContabil(id);
filtroSeveridade = "todas";
filtroStatusAchado = "pendente";
filtroRegra = null;
renderRevisao();
showView("review");
}
function renderRevisao() {
const apuracao = apuracaoAtual;
const concluida = apuracao.status === "concluida";
document.getElementById("dc-review-empresa").textContent = `— ${apuracao.nome_empresa}`;
document.getElementById("dc-review-subtitle").textContent =
`CNPJ ${apuracao.cnpj || "não informado"} · Competência ${pidDcFormatCompetencia(apuracao.competencia)} · ${apuracao.contas.length} conta(s)`;
document.getElementById("dc-concluida-aviso").hidden = !concluida;
document.getElementById("dc-concluir-btn").hidden = concluida;
dcContasColapsadas = new Set();
dcDreColapsadas = new Set();
dcAchadosContaExpandida = new Set();
renderAchados();
renderContas();
renderDre();
}
const PID_DC_SEVERIDADE_ORDEM = { alta: 0, media: 1, baixa: 2 };
function achadosFiltrados() {
return apuracaoAtual.achados
.filter((achado) => {
if (filtroSeveridade !== "todas" && achado.severidade !== filtroSeveridade) return false;
if (filtroStatusAchado === "pendente" && achado.status !== "pendente") return false;
if (filtroRegra && achado.regra !== filtroRegra) return false;
return true;
})
.slice()
.sort((a, b) => (PID_DC_SEVERIDADE_ORDEM[a.severidade] ?? 3) - (PID_DC_SEVERIDADE_ORDEM[b.severidade] ?? 3));
}
function pidDcScrollParaLista() {
document.getElementById("dc-achados-list").scrollIntoView({ behavior: "smooth", block: "nearest" });
}
// Resumo do topo da aba Observações: donut por severidade (clicável, filtra
// igual aos chips Alta/Média/Baixa) + grade de cards por categoria/regra
// (clicáveis, isolam achado.regra na lista abaixo). Contagens sempre sobre
// TODOS os achados da apuração, independente dos filtros já aplicados —
// visão geral estável, mesmo espírito do "Distribuição das Análises" do
// sistema externo que inspirou esta seção.
function renderAchadosResumo() {
const achados = apuracaoAtual.achados;
const total = achados.length;
// ---- Donut por severidade ----
const porSeveridade = { alta: 0, media: 0, baixa: 0 };
achados.forEach((achado) => {
if (achado.severidade in porSeveridade) porSeveridade[achado.severidade] += 1;
});
document.getElementById("dc-achados-donut-total").textContent = String(total);
const svg = document.getElementById("dc-achados-donut-svg");
const legend = document.getElementById("dc-achados-donut-legend");
legend.innerHTML = "";
let anelSvg = `<circle class="dc-donut-svg__bg" cx="18" cy="18" r="15.9155" />`;
if (total > 0) {
let acumulado = 0;
PID_DC_SEVERIDADE_LISTA.forEach((severidade) => {
const qtd = porSeveridade[severidade];
if (qtd === 0) return;
const pct = (qtd / total) * 100;
const offset = 25 - acumulado;
acumulado += pct;
anelSvg += `
<circle
class="dc-donut-svg__seg${filtroSeveridade === severidade ? " is-active" : ""}"
data-dc-donut-severidade="${severidade}"
cx="18" cy="18" r="15.9155"
stroke="${PID_DC_SEVERIDADE_CORES[severidade]}"
stroke-dasharray="${pct} ${100 - pct}"
stroke-dashoffset="${offset}"
/>
`;
});
}
svg.innerHTML = anelSvg;
svg.querySelectorAll("[data-dc-donut-severidade]").forEach((seg) => {
seg.addEventListener("click", () => {
pidDcSelecionarSeveridade(seg.getAttribute("data-dc-donut-severidade"));
pidDcScrollParaLista();
});
});
PID_DC_SEVERIDADE_LISTA.forEach((severidade) => {
const li = document.createElement("li");
li.className = `dc-donut-legend__item${filtroSeveridade === severidade ? " is-active" : ""}`;
li.innerHTML = `<span class="dc-donut-legend__dot" style="background:${PID_DC_SEVERIDADE_CORES[severidade]}"></span>${PID_DC_SEVERIDADE_LABELS[severidade]} (${porSeveridade[severidade]})`;
li.addEventListener("click", () => {
pidDcSelecionarSeveridade(severidade);
pidDcScrollParaLista();
});
legend.appendChild(li);
});
// ---- Cards por categoria (regra) ----
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 grid = document.getElementById("dc-achados-categorias");
grid.innerHTML = "";
PID_DC_REGRAS.forEach((regraInfo, index) => {
const itens = porRegra.get(regraInfo.chave);
const cor = PID_DC_CATEGORIA_CORES[index % PID_DC_CATEGORIA_CORES.length];
const card = document.createElement("div");
card.className = `dc-categoria-card${filtroRegra === regraInfo.chave ? " is-active" : ""}`;
card.style.setProperty("--dc-categoria-cor", cor);
card.innerHTML = `
<div class="dc-categoria-card__header">
<span class="dc-categoria-card__dot"></span>
<span class="dc-categoria-card__titulo">${pidDcEscapeHtml(regraInfo.label)}</span>
<span class="dc-categoria-card__contagem">${itens.length}</span>
</div>
${itens.length === 0 ? `
<p class="dc-categoria-card__vazio">Nenhum apontamento encontrado.</p>
` : `
<table class="dc-categoria-card__table">
<tbody>
${itens.map((achado) => {
const conta = achado.conta != null ? apuracaoAtual.contas.find((c) => c.id === achado.conta) : null;
const referencia = conta ? `${pidDcEscapeHtml(conta.codigo)} — ${pidDcEscapeHtml(conta.descricao)}` : "Geral";
return `
<tr>
<td>${referencia}</td>
<td><span class="dc-badge dc-badge--${achado.status}">${PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status}</span></td>
</tr>
`;
}).join("")}
</tbody>
</table>
`}
`;
card.addEventListener("click", () => {
filtroRegra = filtroRegra === regraInfo.chave ? null : regraInfo.chave;
renderAchados();
pidDcScrollParaLista();
});
grid.appendChild(card);
});
}
function renderAchados() {
renderAchadosResumo();
const filtroAtivo = document.getElementById("dc-regra-filtro-ativo");
const regraInfo = filtroRegra ? PID_DC_REGRAS.find((r) => r.chave === filtroRegra) : null;
filtroAtivo.hidden = !regraInfo;
if (regraInfo) document.getElementById("dc-regra-filtro-ativo-label").textContent = regraInfo.label;
const lista = document.getElementById("dc-achados-list");
const empty = document.getElementById("dc-achados-empty");
const achados = achadosFiltrados();
lista.innerHTML = "";
empty.hidden = achados.length > 0;
achados.forEach((achado) => {
const card = document.createElement("div");
card.className = `dc-achado-card dc-achado-card--${achado.severidade}`;
const podeEditar = apuracaoAtual.status !== "concluida" && achado.status === "pendente";
const conta = achado.conta != null ? apuracaoAtual.contas.find((c) => c.id === achado.conta) : null;
const expandida = dcAchadosContaExpandida.has(achado.id);
card.innerHTML = `
<div class="dc-achado-card__main">
<div class="dc-achado-card__titulo">
<span class="dc-badge dc-badge--${achado.severidade}">${PID_DC_SEVERIDADE_LABELS[achado.severidade] || achado.severidade}</span>
<span class="dc-badge dc-badge--${achado.status}">${PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status}</span>
${pidDcEscapeHtml(achado.titulo)}
</div>
<div class="dc-achado-card__mensagem">${pidDcEscapeHtml(achado.mensagem)}</div>
${conta ? `
<button type="button" class="dc-achado-card__toggle-conta${expandida ? " is-expanded" : ""}" data-dc-achado-toggle-conta="${achado.id}">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
${expandida ? "Ocultar conta usada no apontamento" : "Ver conta usada no apontamento"}
</button>
${expandida ? `
<dl class="dc-achado-card__conta">
<div><dt>Conta</dt><dd>${pidDcEscapeHtml(conta.codigo)} — ${pidDcEscapeHtml(conta.descricao)}</dd></div>
<div><dt>Saldo Anterior</dt><dd>${pidDcFormatMoeda(conta.saldo_anterior)}</dd></div>
<div><dt>Débito</dt><dd>${pidDcFormatMoeda(conta.debito)}</dd></div>
<div><dt>Crédito</dt><dd>${pidDcFormatMoeda(conta.credito)}</dd></div>
<div><dt>Saldo Atual</dt><dd>${pidDcFormatMoeda(conta.saldo_atual)}</dd></div>
</dl>
` : ""}
` : ""}
${achado.observacao_contador ? `<div class="dc-achado-card__observacao">${pidDcEscapeHtml(achado.observacao_contador)}${achado.tratado_por_nome ? ` — ${pidDcEscapeHtml(achado.tratado_por_nome)}` : ""}</div>` : ""}
</div>
<div class="dc-achado-card__actions">
${podeEditar ? `<button type="button" class="btn-outline" data-dc-achado-editar="${achado.id}">Revisar</button>` : ""}
</div>
`;
lista.appendChild(card);
});
}
document.getElementById("dc-achados-list").addEventListener("click", (event) => {
const toggleBtn = event.target.closest("[data-dc-achado-toggle-conta]");
if (toggleBtn) {
const id = Number(toggleBtn.getAttribute("data-dc-achado-toggle-conta"));
if (dcAchadosContaExpandida.has(id)) dcAchadosContaExpandida.delete(id);
else dcAchadosContaExpandida.add(id);
renderAchados();
return;
}
const btn = event.target.closest("[data-dc-achado-editar]");
if (!btn) return;
abrirModalAchado(Number(btn.getAttribute("data-dc-achado-editar")));
});
// Reaproveitado tanto pelos chips de severidade quanto pelo clique no
// donut/legenda do resumo (ver renderAchadosResumo) — mesmo efeito de
// clicar o chip correspondente, sem duplicar a lógica de estado+re-render.
function pidDcSelecionarSeveridade(severidade) {
filtroSeveridade = severidade;
document
.querySelectorAll("#dc-achados-severidade-chips .dc-chip")
.forEach((c) => c.classList.toggle("is-active", c.getAttribute("data-dc-severidade") === severidade));
renderAchados();
}
document.querySelectorAll("#dc-achados-severidade-chips [data-dc-severidade]").forEach((chip) => {
chip.addEventListener("click", () => pidDcSelecionarSeveridade(chip.getAttribute("data-dc-severidade")));
});
document.querySelectorAll("#dc-achados-status-chips [data-dc-status]").forEach((chip) => {
chip.addEventListener("click", () => {
filtroStatusAchado = chip.getAttribute("data-dc-status");
document.querySelectorAll("#dc-achados-status-chips .dc-chip").forEach((c) => c.classList.toggle("is-active", c === chip));
renderAchados();
});
});
document.getElementById("dc-regra-filtro-limpar-btn").addEventListener("click", () => {
filtroRegra = null;
renderAchados();
});
// O código da conta (ex. "1.01.01.002.001") já chega do backend na mesma
// ordem hierárquica/sequencial do PDF (uma sintética seguida das suas
// analíticas, ver portal_api/dashboard_contabil/CLAUDE.md) — o nível de
// indentação vem direto da contagem de segmentos separados por ".", sem
// precisar reconstruir uma árvore por id/parent (o mesmo código pode se
// repetir em várias linhas irmãs, ex. três bancos diferentes sob o mesmo
// "1.01.01.002.001", então não dá pra usar o código como chave única).
function dcContaNivel(conta) {
return conta.codigo.split(".").length - 1;
}
function renderContas() {
const body = document.getElementById("dc-contas-body");
body.innerHTML = "";
const contas = apuracaoAtual.contas;
const niveis = contas.map(dcContaNivel);
const temFilhos = contas.map((_, i) => i + 1 < contas.length && niveis[i + 1] > niveis[i]);
// Pilha dos níveis de ancestrais recolhidos — enquanto uma linha tiver
// algum ancestral na pilha, ela fica oculta (é descendente de um grupo
// fechado), mesmo padrão de renderização de árvore a partir de uma
// lista plana com profundidade já conhecida.
const pilhaColapso = [];
contas.forEach((conta, i) => {
const nivel = niveis[i];
while (pilhaColapso.length && pilhaColapso[pilhaColapso.length - 1] >= nivel) {
pilhaColapso.pop();
}
const oculta = pilhaColapso.length > 0;
if (temFilhos[i] && dcContasColapsadas.has(conta.id)) {
pilhaColapso.push(nivel);
}
if (oculta) return;
const expandida = !dcContasColapsadas.has(conta.id);
const toggle = temFilhos[i]
? `<button type="button" class="dc-conta-toggle${expandida ? " is-expanded" : ""}" data-dc-conta-toggle="${conta.id}" aria-label="${expandida ? "Recolher" : "Expandir"} ${pidDcEscapeHtml(conta.descricao)}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>`
: `<span class="dc-conta-toggle-spacer"></span>`;
const tr = document.createElement("tr");
if (conta.tipo === "S") tr.classList.add("dc-conta-row--sintetica");
tr.innerHTML = `
<td>${pidDcEscapeHtml(conta.codigo)}</td>
<td style="padding-left: calc(var(--space-4) + ${nivel * 18}px)">
<span class="dc-conta-desc-cell">${toggle}<span>${pidDcEscapeHtml(conta.descricao)}</span></span>
</td>
<td>${pidDcFormatMoeda(conta.saldo_anterior)}</td>
<td>${pidDcFormatMoeda(conta.debito)}</td>
<td>${pidDcFormatMoeda(conta.credito)}</td>
<td>${pidDcFormatMoeda(conta.saldo_atual)}</td>
<td>
<button type="button" class="dc-conta-observacao-btn" data-dc-conta-observacao="${conta.id}" title="${pidDcEscapeHtml(conta.observacao || "Adicionar observação")}">
${conta.observacao ? pidDcEscapeHtml(conta.observacao) : "+ Observação"}
</button>
</td>
`;
body.appendChild(tr);
});
}
document.getElementById("dc-contas-body").addEventListener("click", (event) => {
const toggleBtn = event.target.closest("[data-dc-conta-toggle]");
if (toggleBtn) {
const id = Number(toggleBtn.getAttribute("data-dc-conta-toggle"));
if (dcContasColapsadas.has(id)) dcContasColapsadas.delete(id);
else dcContasColapsadas.add(id);
renderContas();
return;
}
const btn = event.target.closest("[data-dc-conta-observacao]");
if (!btn) return;
abrirModalObservacao("conta", Number(btn.getAttribute("data-dc-conta-observacao")));
});
// 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 linhas = apuracaoAtual.linhas_dre;
const niveis = linhas.map((linha) => Math.max(0, linha.nivel));
const temFilhos = linhas.map((_, i) => i + 1 < linhas.length && niveis[i + 1] > niveis[i]);
const pilhaColapso = [];
linhas.forEach((linha, i) => {
const nivel = niveis[i];
while (pilhaColapso.length && pilhaColapso[pilhaColapso.length - 1] >= nivel) {
pilhaColapso.pop();
}
const oculta = pilhaColapso.length > 0;
if (temFilhos[i] && dcDreColapsadas.has(linha.id)) {
pilhaColapso.push(nivel);
}
if (oculta) return;
const expandida = !dcDreColapsadas.has(linha.id);
const toggle = temFilhos[i]
? `<button type="button" class="dc-conta-toggle${expandida ? " is-expanded" : ""}" data-dc-dre-toggle="${linha.id}" aria-label="${expandida ? "Recolher" : "Expandir"} ${pidDcEscapeHtml(linha.descricao)}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>`
: `<span class="dc-conta-toggle-spacer"></span>`;
const tr = document.createElement("tr");
tr.className = linha.totalizador ? "dc-dre-row dc-dre-row--totalizador" : "dc-dre-row";
const indentacao = nivel * 16;
tr.innerHTML = `
<td style="padding-left: calc(var(--space-4) + ${indentacao}px)">
<span class="dc-conta-desc-cell">${toggle}<span>${pidDcEscapeHtml(linha.descricao)}</span></span>
</td>
<td>${pidDcFormatMoeda(linha.valor)}</td>
<td>
<button type="button" class="dc-conta-observacao-btn" data-dc-dre-observacao="${linha.id}" title="${pidDcEscapeHtml(linha.observacao || "Adicionar observação")}">
${linha.observacao ? pidDcEscapeHtml(linha.observacao) : "+ Observação"}
</button>
</td>
`;
body.appendChild(tr);
});
}
document.getElementById("dc-dre-body").addEventListener("click", (event) => {
const toggleBtn = event.target.closest("[data-dc-dre-toggle]");
if (toggleBtn) {
const id = Number(toggleBtn.getAttribute("data-dc-dre-toggle"));
if (dcDreColapsadas.has(id)) dcDreColapsadas.delete(id);
else dcDreColapsadas.add(id);
renderDre();
return;
}
const btn = event.target.closest("[data-dc-dre-observacao]");
if (!btn) return;
abrirModalObservacao("dre", Number(btn.getAttribute("data-dc-dre-observacao")));
});
// ---- Tabs ----
document.getElementById("dc-tabs").addEventListener("click", (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;
});
});
// ---- Modal de observação (reaproveitado por conta do Balancete e linha da DRE) ----
const observacaoModal = document.getElementById("dc-observacao-modal");
let observacaoEmEdicaoTipo = null; // "conta" | "dre"
let observacaoEmEdicaoId = null;
function abrirModalObservacao(tipo, id) {
const lista = tipo === "dre" ? apuracaoAtual.linhas_dre : apuracaoAtual.contas;
const item = lista.find((i) => i.id === id);
if (!item) return;
observacaoEmEdicaoTipo = tipo;
observacaoEmEdicaoId = id;
document.getElementById("dc-observacao-modal-conta").textContent =
tipo === "dre" ? item.descricao : `${item.codigo} — ${item.descricao}`;
document.getElementById("dc-observacao-modal-texto").value = item.observacao || "";
document.getElementById("dc-observacao-modal-error").textContent = "";
document.getElementById("dc-observacao-modal-texto").disabled = apuracaoAtual.status === "concluida";
document.getElementById("dc-observacao-modal-save-btn").hidden = apuracaoAtual.status === "concluida";
observacaoModal.hidden = false;
}
function fecharModalObservacao() {
observacaoModal.hidden = true;
observacaoEmEdicaoTipo = null;
observacaoEmEdicaoId = null;
}
document.getElementById("dc-observacao-modal-cancel-btn").addEventListener("click", fecharModalObservacao);
observacaoModal.addEventListener("click", (event) => {
if (event.target === observacaoModal) fecharModalObservacao();
});
document.getElementById("dc-observacao-modal-save-btn").addEventListener("click", async () => {
const errorEl = document.getElementById("dc-observacao-modal-error");
errorEl.textContent = "";
const texto = document.getElementById("dc-observacao-modal-texto").value;
try {
if (observacaoEmEdicaoTipo === "dre") {
const linha = await pidAtualizarObservacaoLinhaDreContabil(observacaoEmEdicaoId, texto);
const indice = apuracaoAtual.linhas_dre.findIndex((l) => l.id === observacaoEmEdicaoId);
if (indice >= 0) apuracaoAtual.linhas_dre[indice] = linha;
renderDre();
} else {
const conta = await pidAtualizarObservacaoContaContabil(observacaoEmEdicaoId, texto);
const indice = apuracaoAtual.contas.findIndex((c) => c.id === observacaoEmEdicaoId);
if (indice >= 0) apuracaoAtual.contas[indice] = conta;
renderContas();
}
fecharModalObservacao();
} catch (e) {
errorEl.textContent = e.message;
}
});
// ---- 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");
});