Validação das Observações - Dashboard Contábil

This commit is contained in:
Gabriel 2026-09-09 14:12:09 -03:00
parent 0f9358079e
commit 16c656193b
5 changed files with 131 additions and 59 deletions

View File

@ -57,3 +57,9 @@ Cards de categoria (regra) continuam mostrando a contagem por regra, mas agora c
Pedido: permitir exportar Balancete ou DRE em XLSX a partir do relatório HTML. Novo módulo `portal_api/dashboard_contabil/exportacao.py` (funções puras, openpyxl, mesmo espírito de `indicadores.py`/`regras.py` — sem tocar no ORM) com `gera_xlsx_balancete()`/`gera_xlsx_dre()`, recebendo dataclasses (`LinhaBalanceteXlsx`/`LinhaDreXlsx`) já resolvidas pela view. Nova action `ContabilApuracaoViewSet.exportar_xlsx()` (`GET /api/contabil-apuracoes/{id}/exportar-xlsx/?parte=balancete` ou `?parte=dre`, mesma permissão de toggle único) monta essas linhas reaproveitando a mesma fórmula de nível/indentação já usada por `dashboard()`.
Cada planilha nasce com cabeçalho (empresa/CNPJ/competência), cabeçalho de colunas com a mesma paleta roxo/dourado dos outros documentos gerados pelo escritório, conta sintética/linha totalizadora em negrito+fundo dourado claro, indentação de hierarquia via `Alignment(indent=nivel)` e colunas monetárias com `number_format` brasileiro (valor gravado como número, não texto — continua editável/somável no Excel). Botão "Exportar XLSX" novo em cada seção do relatório (`.dcr-export-btn`, `dashboard-contabil-relatorio.html`) é um link direto pra API, sem JS — o browser já baixa o arquivo pelo `Content-Disposition` da resposta; escondido na impressão junto do botão "Imprimir". `openpyxl` já era dependência do projeto (usado pra leitura em outras ferramentas), essa é a primeira vez que o projeto **escreve** um XLSX com ele. Detalhe completo no `CLAUDE.md` desta pasta.
### 100. Resumo da rodada 97 reorganizado em 3 cards por grupo temático
Pedido: os 10 cards por regra do resumo (rodada 97), cada um com uma mini-tabela de conta+status por achado, ficaram "muito poluídos visualmente" na prática (captura de tela real anexada pelo usuário). Alinhado por `AskUserQuestion` (com preview) o agrupamento das 10 regras em 3 temas fixos: "Divergências de Saldo" (balanceamento Ativo x Passivo, débito x crédito, caixa negativo, sinal de saldo invertido), "Contas Atípicas" (contas transitórias, contas que deveriam zerar, descrição genérica) e "Variações e Indicadores" (variação atípica de saldo, variação atípica na DRE, percentual custo/receita).
`PID_DC_GRUPOS` (nova constante, `dashboard-contabil.js`) substitui a iteração antes feita direto sobre `PID_DC_REGRAS` na grade de cards — agora 1 card por grupo (cabeçalho com o total do grupo) contendo uma linha por regra (label + contagem, sem mini-tabela de conta/status). A mini-tabela de conta+status por achado saiu do resumo (era a maior fonte de poluição visual), mas continua disponível na lista completa logo abaixo, ao clicar numa linha de regra — o clique por regra individual (não por grupo) foi preservado, mesmo comportamento de filtro da rodada 98. Puramente frontend (JS + CSS), nenhuma mudança de backend/model. Detalhe completo no `CLAUDE.md` desta pasta.

View File

@ -71,9 +71,11 @@ Diferente de `IndicadorApuracaoViewSet`/`ImportacaoPlanoSaudeViewSet` (onde a ch
**Lista de Observações ordenada por severidade** (pedido explícito do usuário): `achadosFiltrados()` filtra e depois ordena (`PID_DC_SEVERIDADE_ORDEM = {alta: 0, media: 1, baixa: 2}`) — Alta sempre primeiro, Baixa por último, preservando a ordem original (ordem em que as regras rodaram) dentro de uma mesma severidade, já que `Array.prototype.sort` é estável. Puramente ordenação de exibição no frontend, nada mudou no backend/model.
**Resumo clicável (donut por severidade + cards por categoria) no topo da aba Observações** (`.dc-achados-resumo`, `dashboard-contabil.html`/`.css`/`.js`, pedido explícito do usuário, inspirado numa tela de auditoria de outro sistema — ver rodadas 97/98 do `CHANGELOG.md`): acima dos chips de filtro, um donut em SVG puro (sem Chart.js — essa dependência só existe no relatório estático "Gerar Dashboard", não faz sentido carregar aqui numa tela interativa pequena) com a quantidade de observações por **severidade** (Alta/Média/Baixa, mesmas cores dos badges — `--danger`/`--gold`/`rgb(var(--slate-rgb))`) e o total no centro, mais uma grade de cards, um por **regra/categoria** de auditoria. `PID_DC_REGRAS` (constante no topo do arquivo) enumera as **10 regras de `regras.py`** por chave (`achado.regra`, campo que já existia no model `ContabilAchado`) — sempre as 10, mesmo as que não geraram nenhum achado nesta apuração, cada uma com "Nenhum apontamento encontrado" nesse caso (mesmo espírito do sistema de referência, que também lista checagens que passaram). Cada card mostra a contagem e uma mini-tabela com código+descrição da conta (quando `achado.conta` está preenchido — mesmas 6 regras que já preenchem `conta`, ver "Card de achado expande a conta usada no apontamento" acima) ou "Geral" (as 4 regras sem conta específica: balanceamento, débito/crédito, variação da DRE, percentual custo/receita) + o status (`.dc-badge`, mesmo padrão dos badges já existentes). `renderAchadosResumo()` (chamada no início de `renderAchados()`, então atualiza sozinha a cada mudança de status/filtro) conta sempre sobre `apuracaoAtual.achados` **completo**, nunca sobre `achadosFiltrados()` — é uma visão geral estável, não deve mudar quando o usuário filtra a lista detalhada logo abaixo. As 10 cores das categorias (`PID_DC_CATEGORIA_CORES`) reaproveitam os tokens `--accent-rgb`/`--danger-rgb`/`--gold-rgb`/`--teal-rgb`/`--slate-rgb`/`--coral-rgb` de `tokens.css` (já theme-aware, acompanham tema claro/escuro e a cor de tema escolhida pelo usuário) completadas até 10 com `color-mix(in srgb, ... , white/black)`, sem nenhum hex novo hardcoded. Decisão explícita de escopo (alinhada por pergunta ao usuário antes de implementar): **não** foi replicado o checklist pass/fail de checagens do sistema de referência (várias delas — folha, vencimento de fornecedor/cliente/imposto, saldo bancário — dependem de dado fora do Balancete/DRE anexado, fora do escopo já documentado desta ferramenta, ver "Decisões de escopo" acima) nem essa visualização foi levada pro relatório "Gerar Dashboard" (só a tela de revisão do Portal).
**Resumo clicável (donut por severidade + cards por grupo temático) no topo da aba Observações** (`.dc-achados-resumo`, `dashboard-contabil.html`/`.css`/`.js`, pedido explícito do usuário, inspirado numa tela de auditoria de outro sistema — ver rodadas 97/98 do `CHANGELOG.md`): acima dos chips de filtro, um donut em SVG puro (sem Chart.js — essa dependência só existe no relatório estático "Gerar Dashboard", não faz sentido carregar aqui numa tela interativa pequena) com a quantidade de observações por **severidade** (Alta/Média/Baixa, mesmas cores dos badges — `--danger`/`--gold`/`rgb(var(--slate-rgb))`) e o total no centro, mais uma grade de cards. `PID_DC_REGRAS` (constante no topo do arquivo) enumera as **10 regras de `regras.py`** por chave (`achado.regra`, campo que já existia no model `ContabilAchado`).
**Donut e cards são clicáveis, filtram a lista detalhada abaixo** (pedido explícito do usuário, rodada 98): cada fatia do donut (ou item da legenda) chama `pidDcSelecionarSeveridade(severidade)` — a mesma função que os chips "Alta"/"Média"/"Baixa" já usavam (extraída pra função reaproveitável, sem duplicar a lógica de atualizar `filtroSeveridade`+classe `.is-active`+`renderAchados()`); clicar num card de categoria alterna `filtroRegra` (novo estado, `achado.regra` exata ou `null`) — clicar de novo no mesmo card limpa o filtro. `achadosFiltrados()` ganhou uma terceira condição (`filtroRegra`) que se combina por E lógico com severidade/status já existentes — os três filtros funcionam juntos, não um substitui o outro. Como não existe um chip próprio pro filtro por categoria, uma faixa nova (`#dc-regra-filtro-ativo`, escondida quando `filtroRegra` é `null`) aparece entre os chips e a lista mostrando o nome da categoria ativa + um botão "Limpar" — sem essa faixa não haveria como o usuário perceber por que a lista ficou filtrada nem como sair do filtro sem adivinhar que precisa clicar de novo no card. Clicar em qualquer um dos dois (donut/card) também dá um `scrollIntoView` suave até `#dc-achados-list` (`pidDcScrollParaLista()`), já que o resumo pode empurrar a lista pra fora da tela em telas menores. O donut usa a técnica clássica de pizza/donut em SVG com `<circle r="15.9155">` (circunferência ≈ 100, então `stroke-dasharray`/`stroke-dashoffset` já funcionam direto em unidades de percentual, sem precisar de `pathLength`) — cada segmento é um `<circle>` próprio com seu `stroke-dashoffset` acumulado (offset inicial `25` desloca o início de "3 horas" pra "12 horas"), clicável individualmente porque `pointer-events` de SVG só considera pixel realmente pintado pelo `stroke`, sem precisar de hit-test manual por ângulo.
**Cards agrupados por tema, não um card por regra** (rodada seguinte, pedido explícito do usuário: a versão original — um card por regra, 10 ao todo, cada um com uma mini-tabela de conta+status por achado — ficou "muito poluída visualmente"). `PID_DC_GRUPOS` (constante logo abaixo de `PID_DC_REGRAS`) agrupa as 10 regras em 3 temas fixos: "Divergências de Saldo" (balanceamento Ativo x Passivo, débito x crédito, caixa negativo, sinal de saldo invertido), "Contas Atípicas" (contas transitórias, contas que deveriam zerar, descrição genérica) e "Variações e Indicadores" (variação atípica de saldo, variação atípica na DRE, percentual custo/receita) — agrupamento alinhado com o usuário antes de implementar (via pergunta com preview), não inferido sozinho. Cada card mostra só o total do grupo no cabeçalho e, por baixo, uma linha por regra (label + contagem, sem mini-tabela de conta/status) — regra sem nenhum achado nesta apuração continua listada com contagem `0`, só sem estar clicável (mesmo espírito de sempre mostrar as 10 regras, mesmo as que "passaram"). O detalhe por conta/status de cada achado (antes replicado dentro de cada card) não foi removido, só saiu do resumo — continua disponível na lista completa logo abaixo, ao clicar numa linha de regra pra filtrar. `renderAchadosResumo()` (chamada no início de `renderAchados()`, então atualiza sozinha a cada mudança de status/filtro) conta sempre sobre `apuracaoAtual.achados` **completo**, nunca sobre `achadosFiltrados()` — é uma visão geral estável, não deve mudar quando o usuário filtra a lista detalhada logo abaixo. As cores dos 3 grupos (`PID_DC_CATEGORIA_CORES`, mesma constante de antes, agora indexada por grupo em vez de por regra) reaproveitam os tokens `--accent-rgb`/`--danger-rgb`/`--gold-rgb` de `tokens.css` (já theme-aware, acompanham tema claro/escuro e a cor de tema escolhida pelo usuário), sem nenhum hex novo hardcoded. Decisão explícita de escopo (alinhada por pergunta ao usuário antes de implementar): **não** foi replicado o checklist pass/fail de checagens do sistema de referência (várias delas — folha, vencimento de fornecedor/cliente/imposto, saldo bancário — dependem de dado fora do Balancete/DRE anexado, fora do escopo já documentado desta ferramenta, ver "Decisões de escopo" acima) nem essa visualização foi levada pro relatório "Gerar Dashboard" (só a tela de revisão do Portal).
**Donut e linhas de regra são clicáveis, filtram a lista detalhada abaixo** (pedido explícito do usuário, rodada 98; a granularidade do clique por regra individual foi preservada na reorganização em grupos da rodada seguinte): cada fatia do donut (ou item da legenda) chama `pidDcSelecionarSeveridade(severidade)` — a mesma função que os chips "Alta"/"Média"/"Baixa" já usavam (extraída pra função reaproveitável, sem duplicar a lógica de atualizar `filtroSeveridade`+classe `.is-active`+`renderAchados()`); clicar numa linha de regra com contagem > 0 alterna `filtroRegra` (`achado.regra` exata ou `null`) — clicar de novo na mesma linha limpa o filtro; o card do grupo em si (cabeçalho) não é clicável, só as linhas de regra dentro dele. `achadosFiltrados()` ganhou uma terceira condição (`filtroRegra`) que se combina por E lógico com severidade/status já existentes — os três filtros funcionam juntos, não um substitui o outro. Como não existe um chip próprio pro filtro por categoria, uma faixa nova (`#dc-regra-filtro-ativo`, escondida quando `filtroRegra` é `null`) aparece entre os chips e a lista mostrando o nome da regra ativa + um botão "Limpar" — sem essa faixa não haveria como o usuário perceber por que a lista ficou filtrada nem como sair do filtro sem adivinhar que precisa clicar de novo na linha. Clicar em qualquer um dos dois (donut/linha de regra) também dá um `scrollIntoView` suave até `#dc-achados-list` (`pidDcScrollParaLista()`), já que o resumo pode empurrar a lista pra fora da tela em telas menores. O donut usa a técnica clássica de pizza/donut em SVG com `<circle r="15.9155">` (circunferência ≈ 100, então `stroke-dasharray`/`stroke-dashoffset` já funcionam direto em unidades de percentual, sem precisar de `pathLength`) — cada segmento é um `<circle>` próprio com seu `stroke-dashoffset` acumulado (offset inicial `25` desloca o início de "3 horas" pra "12 horas"), clicável individualmente porque `pointer-events` de SVG só considera pixel realmente pintado pelo `stroke`, sem precisar de hit-test manual por ângulo.
## Relatório "Gerar Dashboard" (`indicadores.py` + `dashboard-contabil-relatorio.html`)

View File

@ -0,0 +1,14 @@
# Generated by Django 6.0.7 on 2026-09-09 16:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('portal_api', '0055_ncocorrencia_data_finalizacao_ncocorrencia_fase'),
('portal_api', '0057_contabillinhadre_observacao'),
]
operations = [
]

View File

@ -260,10 +260,10 @@
}
.dc-achados-categorias {
flex: 1 1 480px;
flex: 1 1 640px;
min-width: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--space-3);
align-content: start;
}
@ -273,17 +273,6 @@
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
background: var(--bg-surface);
cursor: pointer;
transition: border-color var(--transition-fast), background var(--transition-fast);
}
.dc-categoria-card:hover {
border-color: var(--dc-categoria-cor);
}
.dc-categoria-card.is-active {
border-color: var(--dc-categoria-cor);
background: var(--bg-surface-raised);
}
.dc-categoria-card__header {
@ -319,33 +308,62 @@
font-weight: 700;
}
.dc-categoria-card__vazio {
margin: var(--space-2) 0 0;
font-size: 0.78rem;
color: var(--text-muted);
}
.dc-categoria-card__table {
width: 100%;
/* Uma linha por regra dentro do card do grupo — substitui a antiga tabela
por conta (uma linha por achado com badge de status), que era a maior
fonte de poluição visual com 10 cards soltos. Detalhe por conta/status
continua disponível na lista abaixo, ao clicar numa regra. */
.dc-categoria-card__regras {
display: flex;
flex-direction: column;
margin-top: var(--space-2);
border-collapse: collapse;
font-size: 0.76rem;
}
.dc-categoria-card__table td {
.dc-categoria-card__regra {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
border-top: 1px solid var(--border-subtle);
font-size: 0.78rem;
color: var(--text-secondary);
}
.dc-categoria-card__table tr:first-child td {
.dc-categoria-card__regra:first-child {
border-top: none;
}
.dc-categoria-card__table td:last-child {
.dc-categoria-card__regra--vazia {
color: var(--text-muted);
}
.dc-categoria-card__regra--clicavel {
cursor: pointer;
transition: color var(--transition-fast);
}
.dc-categoria-card__regra--clicavel:hover {
color: var(--text-primary);
}
.dc-categoria-card__regra--clicavel.is-active {
color: var(--text-primary);
font-weight: 700;
}
.dc-categoria-card__regra-label {
flex: 1 1 auto;
min-width: 0;
}
.dc-categoria-card__regra-contagem {
flex-shrink: 0;
min-width: 16px;
text-align: right;
white-space: nowrap;
padding-left: var(--space-2);
font-weight: 700;
}
.dc-categoria-card__regra--vazia .dc-categoria-card__regra-contagem {
font-weight: 400;
}
.dc-achados-toolbar {

View File

@ -24,6 +24,31 @@ const PID_DC_REGRAS = [
{ chave: "percentual_custo_receita_atipico", label: "Percentual Custo/Receita" },
];
// Agrupamento temático das 10 regras acima pro resumo da aba Observações —
// evita 10 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",
],
},
{
label: "Contas Atípicas",
regras: ["conta_transitoria_com_saldo", "conta_deveria_zerar", "descricao_generica"],
},
{
label: "Variações e Indicadores",
regras: ["variacao_atipica_saldo", "variacao_atipica_dre", "percentual_custo_receita_atipico"],
},
];
// 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.
@ -374,52 +399,59 @@ document.addEventListener("DOMContentLoaded", async () => {
legend.appendChild(li);
});
// ---- Cards por categoria (regra) ----
// ---- 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_REGRAS.forEach((regraInfo, index) => {
const itens = porRegra.get(regraInfo.chave);
const cor = PID_DC_CATEGORIA_CORES[index % PID_DC_CATEGORIA_CORES.length];
PID_DC_GRUPOS.forEach((grupo, grupoIndex) => {
const cor = PID_DC_CATEGORIA_CORES[grupoIndex % PID_DC_CATEGORIA_CORES.length];
const totalGrupo = grupo.regras.reduce((soma, chave) => soma + (porRegra.get(chave) || []).length, 0);
const card = document.createElement("div");
card.className = `dc-categoria-card${filtroRegra === regraInfo.chave ? " is-active" : ""}`;
card.className = "dc-categoria-card";
card.style.setProperty("--dc-categoria-cor", cor);
card.innerHTML = `
<div class="dc-categoria-card__header">
<span class="dc-categoria-card__dot"></span>
<span class="dc-categoria-card__titulo">${pidDcEscapeHtml(regraInfo.label)}</span>
<span class="dc-categoria-card__contagem">${itens.length}</span>
<span class="dc-categoria-card__titulo">${pidDcEscapeHtml(grupo.label)}</span>
<span class="dc-categoria-card__contagem">${totalGrupo}</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>
<div class="dc-categoria-card__regras"></div>
`;
}).join("")}
</tbody>
</table>
`}
const regrasEl = card.querySelector(".dc-categoria-card__regras");
grupo.regras.forEach((chave) => {
const regraInfo = regraPorChave.get(chave);
if (!regraInfo) return;
const itens = porRegra.get(chave) || [];
const clicavel = itens.length > 0;
const linha = document.createElement("div");
linha.className = [
"dc-categoria-card__regra",
clicavel ? "dc-categoria-card__regra--clicavel" : "dc-categoria-card__regra--vazia",
filtroRegra === chave ? "is-active" : "",
].filter(Boolean).join(" ");
linha.innerHTML = `
<span class="dc-categoria-card__regra-label">${pidDcEscapeHtml(regraInfo.label)}</span>
<span class="dc-categoria-card__regra-contagem">${itens.length}</span>
`;
card.addEventListener("click", () => {
filtroRegra = filtroRegra === regraInfo.chave ? null : regraInfo.chave;
if (clicavel) {
linha.addEventListener("click", () => {
filtroRegra = filtroRegra === chave ? null : chave;
renderAchados();
pidDcScrollParaLista();
});
}
regrasEl.appendChild(linha);
});
grid.appendChild(card);
});
}