795 lines
34 KiB
JavaScript
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");
|
|
});
|