Inclusão do HTML do Dashboard Contábil
This commit is contained in:
parent
aa417661f0
commit
e4bdfa1405
@ -216,7 +216,7 @@ Um único app, `portal_api/`:
|
|||||||
| `custo-contratacao.html` | Ferramenta de Geradoc: formulário de simulação de custo de contratação (Empregado CLT) + painel colapsável de parâmetros fiscais, gera um PDF (ver `portal_api/custo_contratacao/CLAUDE.md`). |
|
| `custo-contratacao.html` | Ferramenta de Geradoc: formulário de simulação de custo de contratação (Empregado CLT) + painel colapsável de parâmetros fiscais, gera um PDF (ver `portal_api/custo_contratacao/CLAUDE.md`). |
|
||||||
| `indicador-desempenho.html` | Ferramenta de Geradoc: histórico de apurações + nova apuração (upload das 2 planilhas) + cadastro de critérios/percentuais + revisão/geração dos recibos em PDF do Indicador de Desempenho do Fiscontábil (ver `portal_api/indicadores/CLAUDE.md`). |
|
| `indicador-desempenho.html` | Ferramenta de Geradoc: histórico de apurações + nova apuração (upload das 2 planilhas) + cadastro de critérios/percentuais + revisão/geração dos recibos em PDF do Indicador de Desempenho do Fiscontábil (ver `portal_api/indicadores/CLAUDE.md`). |
|
||||||
| `nao-conformidades.html` | Aplicação de Relatórios > Qualidade: gestão contínua das ocorrências/ações do Sigsistem, com dashboard e status interno de tratativa da Qualidade (ver `portal_api/nao_conformidades/CLAUDE.md`). |
|
| `nao-conformidades.html` | Aplicação de Relatórios > Qualidade: gestão contínua das ocorrências/ações do Sigsistem, com dashboard e status interno de tratativa da Qualidade (ver `portal_api/nao_conformidades/CLAUDE.md`). |
|
||||||
| `dashboard-contabil.html` | Aplicação de Relatórios > Contabilidade: histórico de análises + nova análise (upload do PDF de Balancete + DRE) + revisão de achados de auditoria (com observações por conta) — v1 sem a etapa de geração do dashboard final (ver `portal_api/dashboard_contabil/CLAUDE.md`). |
|
| `dashboard-contabil.html` | Aplicação de Relatórios > Contabilidade: histórico de análises + nova análise (upload do PDF de Balancete + DRE) + revisão de achados de auditoria (com observações por conta) + botão "Gerar Dashboard" (relatório HTML autocontido, ver `portal_api/dashboard_contabil/CLAUDE.md`). |
|
||||||
|
|
||||||
O item "Calendário De Paula" no menu **não é uma página local** — é um `<a href="#" id="calendario-depaula-btn">` (continua favoritável, já que ainda é um `<a class="nav-item">` — ver `docs/favoritos/favoritos.md`) cujo clique é interceptado em `sidebar.js` (`PID_CALENDARIO_DEPAULA_URL`) pra abrir `https://depaula-tvcorporativa.lovable.app/calendario` num modal com `<iframe>` (`#calendario-depaula-modal`, presente em todo shell) em vez de navegar — mesmo padrão do modal "Novo Chamado" de Ramais (`.ram-chamado-*` em `ramais.css`/`ramais.js`), só que genérico o bastante (`.iframe-modal-*` em `components.css`) pra existir em todo shell, não só em `ramais.html`. Funciona porque esse host (mesmo domínio do "Novo Chamado") não bloqueia ser embutido via `X-Frame-Options`/CSP, ao contrário do Asana (ver `docs/solicitacoes/solicitacoes.md`) — só foi possível confirmar isso testando de fato, não é garantia geral por domínio. Não recriar um `calendario.html` interno sem confirmar com o usuário; o antigo foi removido de propósito.
|
O item "Calendário De Paula" no menu **não é uma página local** — é um `<a href="#" id="calendario-depaula-btn">` (continua favoritável, já que ainda é um `<a class="nav-item">` — ver `docs/favoritos/favoritos.md`) cujo clique é interceptado em `sidebar.js` (`PID_CALENDARIO_DEPAULA_URL`) pra abrir `https://depaula-tvcorporativa.lovable.app/calendario` num modal com `<iframe>` (`#calendario-depaula-modal`, presente em todo shell) em vez de navegar — mesmo padrão do modal "Novo Chamado" de Ramais (`.ram-chamado-*` em `ramais.css`/`ramais.js`), só que genérico o bastante (`.iframe-modal-*` em `components.css`) pra existir em todo shell, não só em `ramais.html`. Funciona porque esse host (mesmo domínio do "Novo Chamado") não bloqueia ser embutido via `X-Frame-Options`/CSP, ao contrário do Asana (ver `docs/solicitacoes/solicitacoes.md`) — só foi possível confirmar isso testando de fato, não é garantia geral por domínio. Não recriar um `calendario.html` interno sem confirmar com o usuário; o antigo foi removido de propósito.
|
||||||
|
|
||||||
@ -377,7 +377,7 @@ Ver `portal_api/nao_conformidades/CLAUDE.md`.
|
|||||||
|
|
||||||
## Dashboard Contábil (Relatórios > Contabilidade)
|
## Dashboard Contábil (Relatórios > Contabilidade)
|
||||||
|
|
||||||
Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (`ITD-FISCO-7513`): o contador anexa o PDF de Balancete + DRE (modelo Questor, mesmo relatório hoje enviado ao cliente), a ferramenta extrai as contas e roda um motor de regras de auditoria (saldos negativos, contas transitórias/genéricas com saldo, contas que deveriam ficar zeradas, débito ≠ crédito, variações atípicas mês a mês contra o histórico já processado no Portal), com revisão de achados e observações por conta antes da conclusão. **v1: só execução/auditoria/análise** — a geração do dashboard final (HTML + exportação em XLSX, substituindo o BI Contábil) fica pra uma rodada futura.
|
Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (`ITD-FISCO-7513`): o contador anexa o PDF de Balancete + DRE (modelo Questor, mesmo relatório hoje enviado ao cliente), a ferramenta extrai as contas e roda um motor de regras de auditoria (saldos negativos, contas transitórias/genéricas com saldo, contas que deveriam ficar zeradas, débito ≠ crédito, variações atípicas mês a mês contra o histórico já processado no Portal), com revisão de achados e observações por conta antes da conclusão. O botão "Gerar Dashboard" gera um relatório HTML autocontido (indicadores financeiros, gráfico de evolução, DRE/Balancete agrupados, observações do contador — com a marca do escritório, não a "P.I.D." do Portal), com exportação de Balancete/DRE em XLSX a partir dele. **Ainda fora de escopo**: consolidação entre várias empresas/competências ao mesmo tempo (substituiria o BI Contábil por completo).
|
||||||
|
|
||||||
Ver `portal_api/dashboard_contabil/CLAUDE.md`.
|
Ver `portal_api/dashboard_contabil/CLAUDE.md`.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
10
plano.md
10
plano.md
@ -466,12 +466,6 @@ manter o card grid original (agora com 4 cards em vez de 2, cada um com o
|
|||||||
|
|
||||||
Usuário reportou "Erro ao processar a solicitação." ao reprocessar os mesmos dois arquivos "com finalizadas" da rodada 90, e perguntou se seria a extensão `.xls`/`.xlsx` ou incompatibilidade com o ambiente Linux de produção. Nenhum dos dois: reproduzido localmente com os arquivos reais, o upload funcionava, só levava ~60s — tempo o bastante pra estourar o timeout de um worker WSGI em produção (gunicorn, 30s por padrão) ou disparar o autoreload do `manage.py runserver` em dev, derrubando a conexão sem nenhum problema de dado por trás. Causa raiz: o upsert (rodada 88) fazia uma ida ao banco por item (`update_or_create`/`get_or_create`), aceitável nos ~300 registros da amostra inicial mas não nos ~1700 ocorrências/~3400 ações/~9000 acompanhamentos do export real (quase 28 mil idas ao banco). Reescrito pra lote (`bulk_create`/`bulk_update`) e pra pular quem não mudou nada desde a última importação (reimportação periódica traz de volta o histórico inteiro, não só o novo) — reimportar o mesmo arquivo sem mudança real caiu de ~60s pra ~4s. Nenhuma migração, nenhuma mudança de contrato de API. Detalhe completo em `portal_api/nao_conformidades/CHANGELOG.md` (rodada 91) e `portal_api/nao_conformidades/CLAUDE.md` (seção "Upsert").
|
Usuário reportou "Erro ao processar a solicitação." ao reprocessar os mesmos dois arquivos "com finalizadas" da rodada 90, e perguntou se seria a extensão `.xls`/`.xlsx` ou incompatibilidade com o ambiente Linux de produção. Nenhum dos dois: reproduzido localmente com os arquivos reais, o upload funcionava, só levava ~60s — tempo o bastante pra estourar o timeout de um worker WSGI em produção (gunicorn, 30s por padrão) ou disparar o autoreload do `manage.py runserver` em dev, derrubando a conexão sem nenhum problema de dado por trás. Causa raiz: o upsert (rodada 88) fazia uma ida ao banco por item (`update_or_create`/`get_or_create`), aceitável nos ~300 registros da amostra inicial mas não nos ~1700 ocorrências/~3400 ações/~9000 acompanhamentos do export real (quase 28 mil idas ao banco). Reescrito pra lote (`bulk_create`/`bulk_update`) e pra pular quem não mudou nada desde a última importação (reimportação periódica traz de volta o histórico inteiro, não só o novo) — reimportar o mesmo arquivo sem mudança real caiu de ~60s pra ~4s. Nenhuma migração, nenhuma mudança de contrato de API. Detalhe completo em `portal_api/nao_conformidades/CHANGELOG.md` (rodada 91) e `portal_api/nao_conformidades/CLAUDE.md` (seção "Upsert").
|
||||||
|
|
||||||
### 92. Dashboard Contábil (Relatórios > Contabilidade) — v1: execução, auditoria e análise
|
### 92. Dashboard Contábil (Relatórios > Contabilidade) — nova aplicação
|
||||||
|
|
||||||
Pedido: otimizar a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (processo descrito em `ITD-FISCO-7513`, "Roteiro de Conferência de Balancete"). Nova aplicação em Relatórios > Contabilidade: o contador anexa o PDF de Balancete + DRE (modelo Questor, mesmo relatório hoje enviado ao cliente), a ferramenta extrai as contas/linhas automaticamente e roda um motor de 10 regras de auditoria — só o que é derivável do próprio balancete/DRE, sem depender de sistemas externos ao Portal (Questor, extratos, folha): balanceamento Ativo x Passivo, débito ≠ crédito total, saldo negativo de caixa, contas transitórias/genéricas ("DIVERSOS") com saldo, contas que deveriam ficar zeradas (ex. adiantamento de salários), sinal de saldo invertido pela natureza da conta, variação atípica de saldo/DRE mês a mês e percentual custo/receita fora do padrão da própria empresa (comparado ao histórico dela mesma, nunca a um parâmetro geral entre empresas). O contador revisa os achados por severidade/status, registra observações por conta (mesmo em contas sem achado) e conclui a análise, travando edição.
|
Nova aplicação em Relatórios > Contabilidade (nasce restrita ao perfil "Inovação", mesmo padrão de "Não Conformidades"): o contador anexa o PDF de Balancete + DRE (modelo Questor), a ferramenta extrai as contas/linhas e roda um motor de regras de auditoria, com revisão de achados/observações. Subgrupo "Contabilidade" novo em `catalogo.py` dentro de `relatorios`. Histórico técnico completo (fórmulas, models, desafio de extração do PDF) e das rodadas seguintes (93+, incluindo o botão "Gerar Dashboard") em `portal_api/dashboard_contabil/CHANGELOG.md`/`CLAUDE.md` — a partir daqui, rodadas específicas desta aplicação não duplicam entrada aqui (ver nota no topo deste arquivo).
|
||||||
|
|
||||||
Decisões confirmadas com o usuário: **entrada só em PDF** — o Questor também exporta Balancete/DRE em XLSX estruturado (levantado como alternativa mais confiável de extrair, já que evita parsing de texto), mas o usuário optou por manter só PDF, como pedido originalmente; **histórico de variação mês a mês fica no próprio banco do Portal** (`ContabilApuracao`, chave natural empresa+competência), não depende do relatório "Análise Comparativa Mensal"/"Acompanhamento Mensal" (só um relatório complementar, derivado do balancete, enviado ao cliente à parte — não é upload desta ferramenta); **nasce restrita ao perfil "Inovação"** (dado financeiro de cliente é sensível o bastante pra não ter visibilidade ampla por padrão, mesmo padrão de "Não Conformidades" — diferente das ferramentas de Geradoc, que nasceram amplas); o botão "Gerar Dashboard HTML" (que substituiria o BI Contábil por um relatório final ao administrador da empresa, com exportação em XLSX) fica **desabilitado** nesta rodada — "vamos primeiro estruturar a parte de execução, auditoria e análise", escopo de uma rodada futura.
|
|
||||||
|
|
||||||
Desafio técnico principal, resolvido antes de escrever o parser definitivo (mesmo cuidado de sempre rodar contra o arquivo real, nunca desenhar regex de texto colado): o relatório Questor de Balancete/DRE desenha cada caractere em posição própria (sem kerning) e inclui, por baixo do texto real, uma grade densa de caracteres de espaço cobrindo toda a largura de cada linha — um artefato do gerador de relatório. Isso quebra a extração padrão do pdfplumber (`extract_words`/`extract_text`), que trata esses espaços "de fundo" como separadores de palavra reais e fragmenta números em dígitos isolados. Resolvido reconstruindo cada linha direto de `page.chars`: ignora todo caractere de espaço literal e reinsere um único espaço só quando o vão horizontal entre caracteres reais indica uma quebra de campo/palavra de verdade — calibrado e validado rodando de fato contra `792 - balancete 072026.pdf` (arquivo de referência real cedido pelo usuário). Confirmado também, empiricamente contra o mesmo arquivo, que a identidade "débito total = crédito total" só fecha somando as contas-raiz de Ativo+Passivo (não cada conta isolada), já que a DRE deste relatório não tem colunas de débito/crédito próprias — a movimentação de Resultado transita pelas contas de Patrimônio Líquido do Passivo.
|
|
||||||
|
|
||||||
4 models novos (`ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilAchado`, migração `0055`, testados ponta a ponta contra o banco de produção com limpeza confirmada), pacote `portal_api/dashboard_contabil/` sem ORM (`parser.py`/`regras.py`/`pipeline.py`/`modelos.py`, mesmo padrão de `indicadores`/`planos_saude`). Subgrupo "Contabilidade" novo em `catalogo.py` dentro de `relatorios` (mesmo formato de "Qualidade"). `ContabilApuracaoViewSet.create()` tem uma particularidade: diferente das outras ferramentas com histórico, `codigo_empresa`/`competencia` (a chave natural) só são conhecidos depois de extrair o PDF, não vêm do formulário — o pipeline recebe uma função de busca de histórico (não uma lista pronta), injetada pela view, que só é chamada depois da extração. Nova página `dashboard-contabil.html` (3 sub-views: histórico/upload/revisão com abas Achados-Balancete-DRE, mesmo padrão de `indicador-desempenho.html`), item de menu adicionado às 12 páginas-shell existentes (mais a nova). Detalhe técnico completo em `portal_api/dashboard_contabil/CLAUDE.md`.
|
|
||||||
|
|||||||
@ -9,3 +9,51 @@ Pedido: otimizar a conferência de balancetes hoje feita manualmente pelo Fisco/
|
|||||||
Decisões confirmadas com o usuário: entrada só em PDF (levantada a alternativa de XLSX estruturado do Questor, mais confiável de extrair, mas mantido PDF como pedido originalmente); histórico de variação mês a mês fica no próprio banco do Portal, não depende de nenhum relatório complementar anexado; nasce restrita ao perfil "Inovação" (dado financeiro de cliente sensível, mesmo padrão de "Não Conformidades"); o botão "Gerar Dashboard HTML" (que substituiria o BI Contábil por um relatório final ao administrador da empresa, com exportação em XLSX) fica **desabilitado** nesta rodada, escopo de uma rodada futura.
|
Decisões confirmadas com o usuário: entrada só em PDF (levantada a alternativa de XLSX estruturado do Questor, mais confiável de extrair, mas mantido PDF como pedido originalmente); histórico de variação mês a mês fica no próprio banco do Portal, não depende de nenhum relatório complementar anexado; nasce restrita ao perfil "Inovação" (dado financeiro de cliente sensível, mesmo padrão de "Não Conformidades"); o botão "Gerar Dashboard HTML" (que substituiria o BI Contábil por um relatório final ao administrador da empresa, com exportação em XLSX) fica **desabilitado** nesta rodada, escopo de uma rodada futura.
|
||||||
|
|
||||||
Desafio técnico principal: o relatório Questor de Balancete/DRE desenha cada caractere em posição própria e inclui, por baixo do texto real, uma grade densa de caracteres de espaço cobrindo toda a linha — isso quebra a extração padrão do pdfplumber (`extract_words`/`extract_text`), que trata esses espaços como separadores reais e fragmenta números em dígitos isolados. Resolvido reconstruindo cada linha direto de `page.chars`, ignorando espaços literais e reinserindo um só quando o vão horizontal indica uma quebra de campo real — validado rodando de fato contra o PDF real de referência do usuário antes de escrever o parser definitivo. 4 models novos (`ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilAchado`, migração `0055`), pacote `portal_api/dashboard_contabil/` sem ORM (`parser.py`/`regras.py`/`pipeline.py`/`modelos.py`), subgrupo "Contabilidade" novo em `catalogo.py` dentro de `relatorios`. Detalhe técnico completo no `CLAUDE.md` desta pasta.
|
Desafio técnico principal: o relatório Questor de Balancete/DRE desenha cada caractere em posição própria e inclui, por baixo do texto real, uma grade densa de caracteres de espaço cobrindo toda a linha — isso quebra a extração padrão do pdfplumber (`extract_words`/`extract_text`), que trata esses espaços como separadores reais e fragmenta números em dígitos isolados. Resolvido reconstruindo cada linha direto de `page.chars`, ignorando espaços literais e reinserindo um só quando o vão horizontal indica uma quebra de campo real — validado rodando de fato contra o PDF real de referência do usuário antes de escrever o parser definitivo. 4 models novos (`ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilAchado`, migração `0055`), pacote `portal_api/dashboard_contabil/` sem ORM (`parser.py`/`regras.py`/`pipeline.py`/`modelos.py`), subgrupo "Contabilidade" novo em `catalogo.py` dentro de `relatorios`. Detalhe técnico completo no `CLAUDE.md` desta pasta.
|
||||||
|
|
||||||
|
### 93. Dashboard Contábil — DRE agrupada por árvore + botão "Gerar Dashboard" implementado
|
||||||
|
|
||||||
|
A DRE (aba "DRE" da revisão) ganhou a mesma árvore recolhível que o Balancete já tinha (`renderDre()` reaproveita o algoritmo de `renderContas()`, agora sobre `ContabilLinhaDre.nivel`). O botão "Gerar Dashboard" (renomeado de "Gerar Dashboard HTML") saiu do estado desabilitado da rodada 92: gera um documento HTML autocontido, com a marca do escritório, com indicadores financeiros (ROA, ROE, Kanitz, EBIT, EBITDA, Liquidez Corrente/Seca/Geral, Composição/Grau de Endividamento, IPL), gráfico de evolução do Resultado Líquido (Chart.js via CDN), DRE/Balancete agrupados e as observações que o contador já registrou na aplicação. Escopo confirmado com o usuário: sempre uma apuração por vez (sem "Filial"/consolidação multi-empresa do BI antigo que este relatório substitui, fora de escopo).
|
||||||
|
|
||||||
|
Novo módulo `indicadores.py` (funções puras): grupos do Balancete por código fixo calibrado contra o balancete de referência (mesmo risco já aceito em `regra_saldo_negativo_caixa`), com o Passivo Não Circulante calculado por eliminação (sempre exato, não precisa de mais um código). Liquidez Corrente/Seca, Composição/Grau de Endividamento e IPL validados byte a byte contra a captura de tela do usuário (bateram exatamente). EBITDA fica indisponível na primeira apuração de uma empresa (precisa da variação de Depreciação/Amortização entre dois meses). Kanitz usa a fórmula padrão, não validada contra o BI antigo (valor de referência do usuário fora da faixa clássica do índice) — marcado como estimativa no relatório.
|
||||||
|
|
||||||
|
O relatório é dividido em 3 abas (Balancete → D.R.E. → Indicadores, nessa ordem pedida pelo usuário), com "Observações da Análise" fora das abas, sempre visível; na impressão a barra de abas some e as 3 aparecem seguidas, cada uma numa página.
|
||||||
|
|
||||||
|
Nova action `ContabilApuracaoViewSet.dashboard()` renderiza `templates/dashboard-contabil-relatorio.html` via `render_to_string` e devolve `text/html` puro — primeiro documento HTML autocontido do Portal (até então só PDF/CSV/ZIP). Novo `portal_api/templatetags/contabil_extras.py` (primeiro uso de template tags customizadas no projeto) com filtros `moeda`/`percentual`/`indice`/`competencia`. Detalhe técnico completo (todas as fórmulas e limitações conhecidas) no `CLAUDE.md` desta pasta.
|
||||||
|
|
||||||
|
**Dois ajustes, ainda na mesma rodada, antes do usuário testar de fato**: Balancete e D.R.E. do relatório ganharam a mesma árvore recolhível da tela de revisão (calculada no servidor — `_contabil_arvore_contexto()` em `views.py` — já que o relatório é HTML estático, não uma SPA); e a logo do escritório, que não estava carregando, foi corrigida trocando a action de POST pra **GET** — a causa raiz era o frontend abrir o resultado via `fetch`+blob (`URL.createObjectURL`), e um documento carregado de uma URL `blob:` tem origem sintética, quebrando a URL relativa `{% static %}` da logo; com GET, o frontend só faz `window.open()` direto na URL da API (navegação de verdade, sem blob).
|
||||||
|
|
||||||
|
**Terceiro ajuste, mesmo dia**: usuário achou o visual simples demais e pediu mais bonito/dinâmico/animado. Fontes "Manrope"/"Inter", gradiente+glow no cabeçalho, ícones SVG, abas com indicador deslizante, contagem animada nos cards (sempre terminando no valor exato já formatado pelos filtros Django, nunca recalculado em JS) e cor por sinal/limiar só onde é seguro sem inventar nada (ROA/ROE/EBIT/EBITDA por sinal, Liquidez por ≥1 — convenções de mercado; Kanitz/Endividamento/IPL ficam sem cor, sem limiar validado). Toda animação de entrada segue a regra de nunca fixar `opacity:0` fora de `@keyframes`, pra `@media print` bastar sozinho pra devolver tudo ao normal na impressão. Corrigido também um bug de condição de corrida entre dois listeners de `beforeprint` que competiam entre si quando a aba "Indicadores" nunca tinha sido aberta. Detalhe completo no `CLAUDE.md` desta pasta ("Visual").
|
||||||
|
|
||||||
|
### 94. Dashboard Contábil — observações do relatório redistribuídas por aba
|
||||||
|
|
||||||
|
Pedido: a seção única "Observações da Análise" (fora de todas as abas) misturava observações de Balancete, D.R.E. e achados de auditoria juntas, sem separar por contexto. Passou a ficar assim: a aba **Balancete** termina com "Observações do Balancete" (só as observações de conta); a aba **D.R.E.** termina com "Observações da D.R.E." (só as observações de linha); a aba **Indicadores** termina com "Todas as Observações da Análise" — as três listas juntas (contas + linhas de DRE + achados), cada item prefixado com a origem ("Balancete — ...", "D.R.E. — ...", "Auditoria — ..."), já que ali não há mais uma aba pra dar esse contexto sozinha. Puramente reorganização de template (`dashboard-contabil-relatorio.html`) — as três listas já vinham prontas do backend (`views.py`), nenhuma mudança de backend foi necessária.
|
||||||
|
|
||||||
|
**Correção ainda na mesma rodada**: o prefixo do terceiro grupo tinha nascido "Achado de Auditoria —", contrariando um pedido já feito antes pelo usuário de nunca expor a palavra "achado" em texto visível (mesmo motivo pelo qual a aba de revisão já se chama "Observações", não "Achados" — `dashboard-contabil.html`, `data-dc-tab="achados"` com o texto "Observações"). Trocado pra "Auditoria —", no mesmo padrão dos outros dois grupos (nomeado pela origem/demonstração, não pelo tipo de registro interno). Vale como regra geral pra qualquer texto novo desta aplicação: `achado`/`Achado` só em nome de variável/model/classe CSS, nunca em texto visível ao usuário. Detalhe completo no `CLAUDE.md` desta pasta.
|
||||||
|
|
||||||
|
### 95. Card de achado (tela de revisão) expande a conta usada no apontamento
|
||||||
|
|
||||||
|
Pedido: nos cards de "Observações" (aba Achados da revisão), permitir expandir e ver a conta do Balancete que embasou aquele apontamento — até então só o título/mensagem da regra apareciam, sem o dado de origem. `renderAchados()` (`dashboard-contabil.js`) resolve `achado.conta` (id) pra objeto completo procurando em `apuracaoAtual.contas`, já carregado junto na mesma resposta de `/api/contabil-apuracoes/{id}/` — nenhuma chamada de API nova, nenhuma mudança de backend. Um botão "Ver conta usada no apontamento" aparece só nos achados vinculados a uma conta específica (achados das regras 3-7, que têm `conta` preenchida); as duas regras gerais (balanceamento Ativo x Passivo, débito ≠ crédito) não mostram o botão, já que não têm uma conta única por trás. Expandido, mostra código/descrição/saldo anterior/débito/crédito/saldo atual da conta, num `<dl>` novo (`.dc-achado-card__conta`, `dashboard-contabil.css`). Detalhe completo no `CLAUDE.md` desta pasta.
|
||||||
|
|
||||||
|
### 96. Lista de Observações ordenada por severidade
|
||||||
|
|
||||||
|
Pedido: na aba "Observações" da revisão, as observações apareciam na ordem em que as regras rodaram (mistura de Alta/Média/Baixa), sem prioridade visual — o usuário pediu Alta primeiro, depois Média, depois Baixa. `achadosFiltrados()` (`dashboard-contabil.js`) ganhou um `.sort()` por `PID_DC_SEVERIDADE_ORDEM` (`{alta: 0, media: 1, baixa: 2}`) depois do filtro já existente (severidade/status); dentro de uma mesma severidade a ordem original é preservada (`sort` é estável). Só ordenação de exibição, nenhuma mudança de backend/modelo.
|
||||||
|
|
||||||
|
### 97. Resumo por categoria (donut + cards) no topo da aba Observações
|
||||||
|
|
||||||
|
Pedido: o usuário trouxe capturas de tela da auditoria de outro sistema (checklist de checagens + cards por categoria com a tabela de apontamentos + gráfico de distribuição) perguntando se dava pra estruturar algo parecido. Alinhado por `AskUserQuestion` que: (a) várias checagens daquele sistema dependem de dado que não vem do Balancete/DRE anexado (folha, vencimentos de fornecedor/cliente/imposto, saldo bancário) — fora do escopo já documentado desta ferramenta, não replicadas; (b) o formato escolhido foi "cards por categoria com tabela de apontamentos" + "gráfico de distribuição por categoria", só na tela de revisão do Portal (não no checklist pass/fail, não no relatório "Gerar Dashboard").
|
||||||
|
|
||||||
|
Nova seção `.dc-achados-resumo` no topo da aba Observações (`dashboard-contabil.html`), acima dos chips de filtro já existentes: um donut (CSS puro, `conic-gradient` — sem Chart.js/dependência nova nesta tela interativa, diferente do relatório estático que já usa Chart.js via CDN) com o total de observações no centro e legenda por categoria, mais uma grade de cards — um por regra de auditoria (as mesmas 10 de `regras.py`, `PID_DC_REGRAS` em `dashboard-contabil.js` — enumeradas sempre as 10, mesmo as que não geraram achado nesta apuração, mesmo espírito do "Nenhum registro encontrado" do sistema de referência) com a contagem e uma mini-tabela das contas/linhas apontadas (código+descrição da conta quando `achado.conta` está preenchido, "Geral" quando não — mesmas duas regras gerais que já não mostram conta no card expandido, ver rodada 95) e o status de cada uma. `renderAchadosResumo()` roda sempre sobre `apuracaoAtual.achados` **completo**, não sobre `achadosFiltrados()` — visão geral estável, independente dos chips Alta/Média/Baixa/Pendentes/Todos da lista detalhada logo abaixo. Cores das 10 categorias reaproveitam os tokens `--accent-rgb`/`--danger-rgb`/`--gold-rgb`/`--teal-rgb`/`--slate-rgb`/`--coral-rgb` já existentes (theme-aware) completados até 10 com `color-mix()`, sem hex novo hardcoded. Puramente frontend — nenhuma mudança de backend/model (o campo `regra` já existia em `ContabilAchado`). Detalhe completo no `CLAUDE.md` desta pasta.
|
||||||
|
|
||||||
|
### 98. Donut do resumo trocado pra severidade + donut/cards clicáveis (filtram a lista)
|
||||||
|
|
||||||
|
Dois pedidos, mesma rodada: (a) o donut da rodada 97 mostrava distribuição por categoria/regra — o usuário pediu pra mostrar por nível de complexidade (Alta/Média/Baixa) em vez disso; (b) permitir clicar no gráfico ou nos cards pra filtrar os apontamentos relacionados na lista detalhada abaixo.
|
||||||
|
|
||||||
|
Donut reconstruído em SVG puro (técnica clássica de `<circle r="15.9155">`, circunferência ≈ 100, então `stroke-dasharray`/`stroke-dashoffset` já valem como percentual sem precisar de `pathLength`) — 3 segmentos (Alta/Média/Baixa, mesmas cores dos badges: `--danger`/`--gold`/`rgb(var(--slate-rgb))`), cada um um `<circle>` clicável (`pointer-events` de SVG só considera o pixel realmente pintado pelo `stroke`, então cada fatia responde só na própria área, sem hit-test manual por ângulo). Clicar numa fatia (ou item da legenda) chama `pidDcSelecionarSeveridade()` — extraída da lógica que os chips "Alta"/"Média"/"Baixa" já tinham (mesmo efeito de clicar o chip: atualiza `filtroSeveridade`, `.is-active` e re-renderiza).
|
||||||
|
|
||||||
|
Cards de categoria (regra) continuam mostrando a contagem por regra, mas agora clicáveis também: alternam um novo estado `filtroRegra` (`achado.regra` exata ou `null`, clicar de novo no mesmo card limpa) — `achadosFiltrados()` ganhou essa terceira condição, combinando por E lógico com severidade/status. Como não há chip próprio pra esse filtro, uma faixa nova (`#dc-regra-filtro-ativo`) aparece entre os chips e a lista quando `filtroRegra` está ativo, com o nome da categoria e um botão "Limpar". Clicar em qualquer um dos dois (donut/card) dá um scroll suave até a lista (`pidDcScrollParaLista()`). Puramente frontend, nenhuma mudança de backend. Detalhe completo no `CLAUDE.md` desta pasta.
|
||||||
|
|
||||||
|
### 99. Exportar Balancete/DRE em XLSX a partir do relatório "Gerar Dashboard"
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Ferramenta que otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (processo descrito em `ITD-FISCO-7513`, "Roteiro de Conferência de Balancete"): o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor hoje enviado ao cliente), a ferramenta extrai as contas/linhas e roda um conjunto de checagens automáticas de auditoria, apresentando os achados numa tela de revisão onde o contador analisa, registra observações e conclui a análise. Permissão de **toggle único** (`apps["dashboard-contabil"]` em `permissoes["relatorios"]`, subgrupo "Contabilidade"), checada via `PermissaoApp("relatorios", "dashboard-contabil")` em todos os `ModelViewSet` relacionados. **Nasce restrita só ao perfil "Inovação"** (código 8) — mesmo padrão de "Não Conformidades" (ver override em `seed_portal.py`), já que expõe balancete/DRE completos dos clientes.
|
Ferramenta que otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (processo descrito em `ITD-FISCO-7513`, "Roteiro de Conferência de Balancete"): o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor hoje enviado ao cliente), a ferramenta extrai as contas/linhas e roda um conjunto de checagens automáticas de auditoria, apresentando os achados numa tela de revisão onde o contador analisa, registra observações e conclui a análise. Permissão de **toggle único** (`apps["dashboard-contabil"]` em `permissoes["relatorios"]`, subgrupo "Contabilidade"), checada via `PermissaoApp("relatorios", "dashboard-contabil")` em todos os `ModelViewSet` relacionados. **Nasce restrita só ao perfil "Inovação"** (código 8) — mesmo padrão de "Não Conformidades" (ver override em `seed_portal.py`), já que expõe balancete/DRE completos dos clientes.
|
||||||
|
|
||||||
**v1: só execução, auditoria e análise.** O botão "Gerar Dashboard HTML" (que geraria o relatório final em HTML para o administrador da empresa, com exportação em XLSX) existe na tela mas fica **desabilitado** — decisão explícita do usuário, "vamos primeiro estruturar a parte de execução, auditoria e análise". Sem endpoint de backend correspondente ainda.
|
O botão "Gerar Dashboard" (relatório final em HTML para o administrador da empresa) já está implementado — ver "Relatório 'Gerar Dashboard'" abaixo, que inclui exportação de Balancete/DRE em XLSX a partir dele (`exportacao.py`, ver "Exportação em XLSX"). **Ainda fora de escopo**: consolidação entre várias empresas/competências ao mesmo tempo (o BI Contábil externo que este Dashboard substitui tem filtros "Ano-Mês"/"Empresa-Filial" que sugerem isso, mas o escopo confirmado com o usuário é sempre uma apuração por vez — ver seção própria).
|
||||||
|
|
||||||
## Decisões de escopo (confirmadas com o usuário)
|
## Decisões de escopo (confirmadas com o usuário)
|
||||||
|
|
||||||
@ -63,4 +63,83 @@ Diferente de `IndicadorApuracaoViewSet`/`ImportacaoPlanoSaudeViewSet` (onde a ch
|
|||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
`templates/dashboard-contabil.html` (`page-content--wide`) segue o padrão de 3 sub-views de `indicador-desempenho.html`: `#dc-list-view` (histórico + botão "Nova Análise") / `#dc-form-view` (upload de um único PDF — sem campo de competência, é extraído do arquivo) / `#dc-review-view` (abas Achados/Balancete/DRE, via `.pa-tabs`/`.pa-tab-panel` de `perfis-acesso.css`). Achados têm filtro por severidade e por status (pendentes/todos); tratar/ignorar um achado abre um modal próprio (`#dc-achado-modal`) que exige observação não-vazia; observação de conta abre outro modal (`#dc-observacao-modal`), sem essa exigência (pode ficar em branco). Botão "Gerar Dashboard HTML" existe (`#dc-gerar-dashboard-btn`) mas fica `disabled` com `title="Em desenvolvimento"` — sem chamada de API nenhuma atrás dele nesta v1.
|
`templates/dashboard-contabil.html` (`page-content--wide`) segue o padrão de 3 sub-views de `indicador-desempenho.html`: `#dc-list-view` (histórico + botão "Nova Análise") / `#dc-form-view` (upload de um único PDF — sem campo de competência, é extraído do arquivo) / `#dc-review-view` (abas Achados/Balancete/DRE, via `.pa-tabs`/`.pa-tab-panel` de `perfis-acesso.css`). Achados têm filtro por severidade e por status (pendentes/todos); tratar/ignorar um achado abre um modal próprio (`#dc-achado-modal`) que exige observação não-vazia; observação de conta abre outro modal (`#dc-observacao-modal`), sem essa exigência (pode ficar em branco). Botão "Gerar Dashboard" (`#dc-gerar-dashboard-btn`) chama `pidGerarDashboardContabil()` — ver "Relatório 'Gerar Dashboard'" abaixo.
|
||||||
|
|
||||||
|
**Balancete e DRE usam a mesma árvore recolhível** (`dashboard-contabil.js`): o Balancete já construía uma árvore expansível a partir do nível de indentação derivado do código de classificação (`dcContaNivel()`, contando segmentos separados por `.`) — a DRE não tem código de classificação (ver "Extração do PDF" acima), mas já carregava `nivel` pronto do backend (`ContabilLinhaDre.nivel`, derivado do `x0` de cada linha no PDF), então `renderDre()` reaproveita exatamente o mesmo algoritmo de `renderContas()` (pilha de níveis recolhidos, "tem filhos" = a próxima linha tem nível maior) só que sobre `linha.nivel` direto, sem precisar de um `dcContaNivel` equivalente. Reaproveita as mesmas classes CSS do toggle (`.dc-conta-toggle`/`.dc-conta-toggle-spacer`/`.dc-conta-desc-cell`, `dashboard-contabil.css`) — o nome genérico ("conta") já cobre as duas árvores, não precisou de classe nova. Estado de colapso é independente por aba (`dcContasColapsadas`/`dcDreColapsadas`, dois `Set()` reiniciados juntos em `renderRevisao()`).
|
||||||
|
|
||||||
|
**Card de achado expande a conta usada no apontamento** (`renderAchados()`, pedido explícito do usuário): `achado.conta` (id, já vem no payload de `/api/contabil-apuracoes/{id}/`, junto de `conta_codigo`/`conta_descricao`) é resolvido pra objeto completo procurando em `apuracaoAtual.contas` (`.find((c) => c.id === achado.conta)`) — sem chamada de API extra, já que a apuração inteira (contas + linhas de DRE + achados) já vem de uma vez só nesse endpoint. Só achados vinculados a uma conta específica ganham o botão "Ver conta usada no apontamento" (`.dc-achado-card__toggle-conta`) — as duas regras gerais (`balanceamento_ativo_passivo`/`debito_credito_divergente`, `conta` nulo no model) não têm uma conta única por trás, então não mostram o toggle. Expandido, mostra código/descrição/saldo anterior/débito/crédito/saldo atual da conta (`.dc-achado-card__conta`, um `<dl>` em grid). Estado de expansão (`dcAchadosContaExpandida`, um `Set()` de ids de achado) segue o mesmo padrão de `dcContasColapsadas`/`dcDreColapsadas` — reiniciado em `renderRevisao()`.
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## Relatório "Gerar Dashboard" (`indicadores.py` + `dashboard-contabil-relatorio.html`)
|
||||||
|
|
||||||
|
`ContabilApuracaoViewSet.dashboard()` (`GET /api/contabil-apuracoes/{id}/dashboard/`, mesma permissão de toggle único das outras actions) gera um documento HTML autocontido (não estende o shell do Portal — nunca a marca "P.I.D.", ver "Logos" no CLAUDE.md raiz) com os indicadores financeiros, a DRE/Balancete agrupados por nível (recolhível, ver "Árvore recolhível" abaixo), um gráfico de evolução do Resultado Líquido e as observações que o contador já registrou (contas, linhas de DRE, achados tratados/ignorados com `observacao_contador`). Escopo confirmado com o usuário: **sempre uma apuração por vez** (a que está sendo revisada), sem "Filial" (não existe no modelo) nem consolidação entre empresas — isso ficaria pra um BI à parte, fora de escopo aqui.
|
||||||
|
|
||||||
|
**É GET, não POST** (diferente do padrão `/gerar/` de outras ferramentas, que mutam estado ou recebem multipart) — decisão de uma rodada seguinte, corrigindo um bug real: a primeira versão era POST, e o frontend chamava via `fetch` + `URL.createObjectURL(blob)` + `window.open(url)` pra abrir numa nova aba (mesmo padrão de `pidGerarArquivoPlanoSaude`). Só que um documento carregado de uma URL `blob:` tem uma origem sintética — URLs relativas dentro do HTML (como as que `{% static %}` gera, ex. `/static/img/logo-branco.png`) não resolvem de forma confiável contra a origem real do Portal nesse contexto, e a logo do escritório no cabeçalho simplesmente não carregava. Com GET, o frontend abre a URL da API direto (`window.open(`/api/contabil-apuracoes/${id}/dashboard/`, "_blank")`, `pidGerarDashboardContabil()` em `dashboard-contabil.js`) — navegação de verdade, mesma origem, sem blob nenhum de permeio; `{% static %}` funciona igual a qualquer outra página do Portal. Também simplificou o JS (sem `pidEnsureCsrfCookie`/CSRF manual — GET não precisa).
|
||||||
|
|
||||||
|
### Indicadores financeiros (`indicadores.py`)
|
||||||
|
|
||||||
|
Funções puras, mesmo espírito de `regras.py` — não tocam no ORM, recebem os dados já extraídos. **Os grupos do Balancete são identificados por código de classificação fixo** (`CODIGO_*`), calibrado contra o balancete de referência do usuário (`792 - balancete 072026.pdf`) — mesma decisão de risco já aceita em `regra_saldo_negativo_caixa` (código fixo `"1.01.01.001"` pro grupo Caixa). **Se um cliente usar uma numeração de plano de contas diferente da vista até agora, os indicadores desse cliente saem errados silenciosamente** — revisar contra mais balancetes reais de outras empresas antes de confiar cegamente no valor exibido ao cliente.
|
||||||
|
|
||||||
|
Códigos calibrados: Ativo Total `"1"`, Ativo Circulante `"1.01"`, Estoques `"1.01.08"`, Imobilizado `"1.02.05"`, Depreciação Acumulada `"1.02.05.007"`, Passivo Total `"2"`, Passivo Circulante `"2.01"`, Patrimônio Líquido `"2.04"`. O Passivo Não Circulante/Exigível a Longo Prazo **não tem código calibrado** — não aparece no balancete de referência, que não tem dívida de longo prazo — e é calculado **por eliminação** (Passivo Total − Passivo Circulante − Patrimônio Líquido), sempre exato pela identidade contábil, sem depender de adivinhar mais um código.
|
||||||
|
|
||||||
|
**Validados byte a byte contra a captura de tela do Balancete anexada pelo usuário** (Ativo Total 2.721.721,59 / Ativo Circulante 2.582.105,87 / Estoques 1.702.326,05 / Passivo Circulante 408.765,09 / Patrimônio Líquido 2.312.956,50 / Imobilizado 134.203,88 — bateram exatamente):
|
||||||
|
- Liquidez Corrente = Ativo Circulante / Passivo Circulante → 6,32
|
||||||
|
- Liquidez Seca = (Ativo Circulante − Estoques) / Passivo Circulante → 2,15
|
||||||
|
- Composição do Endividamento = Passivo Circulante / Exigível Total → 100,00% (bate porque essa empresa não tem Passivo Não Circulante)
|
||||||
|
- Grau de Endividamento = Exigível Total / Patrimônio Líquido → 17,67%
|
||||||
|
- IPL (Imobilização do Patrimônio Líquido) = Imobilizado / Patrimônio Líquido → 5,80%
|
||||||
|
|
||||||
|
**Aproximado, sem exemplo real pra validar**: Liquidez Geral = Ativo Circulante / Exigível Total — trata o Realizável a Longo Prazo como indisponível/0, porque o Ativo Não Circulante (`"1.02"`) hoje mistura Investimentos/Imobilizado com um eventual Realizável a Longo Prazo, sem separar (o parser não distingue isso). Coincide com a Liquidez Corrente quando a empresa não tem Passivo Não Circulante (era o caso do balancete de referência).
|
||||||
|
|
||||||
|
**DRE**: `resultado_liquido` é passado pra `calcula_indicadores()` já resolvido pela view (`linhas_dre[-1].valor`, a última linha na ordem do relatório) — não por texto, mais seguro. EBIT = Resultado Líquido − linha "(+/-) Despesas/Receitas Financeiras" (casada por substring via `LINHA_DRE_DESPESAS_RECEITAS_FINANCEIRAS` em `parser.py`, **não validada** contra um PDF real como `LINHA_DRE_RECEITA_LIQUIDA`/`LINHA_DRE_CUSTOS_TOTAIS` já são). Depreciação/Amortização do mês = variação do saldo da conta de depreciação acumulada entre a apuração atual e a anterior da mesma empresa — **indisponível na primeira apuração de uma empresa** (mesmo espírito das regras 8-10 de auditoria, que também dependem de histórico); EBITDA = EBIT + essa variação, também indisponível quando ela for. ROA = Resultado Líquido / Ativo Total; ROE = Resultado Líquido / Patrimônio Líquido.
|
||||||
|
|
||||||
|
**Kanitz (Termômetro de Insolvência)** usa a fórmula-livro-texto padrão (`FI = 0,05×ROE + 1,65×LiquidezGeral + 3,55×LiquidezSeca − 1,06×LiquidezCorrente − 0,33×GrauEndividamento`), **não validada** contra o BI antigo que este Dashboard substitui — o valor mostrado numa captura do usuário ("11,31") está fora da faixa clássica do índice (−7 a +7), sugerindo que o BI antigo usa uma variação/escala diferente. O relatório marca esse card como estimativa; ajustar se o usuário trouxer a fórmula exata usada pelo BI antigo.
|
||||||
|
|
||||||
|
Todo indicador que pode ficar indisponível é `Decimal | None` no dataclass `IndicadoresFinanceiros` — `None` significa indisponível, nunca é tratado como zero (os filtros de template descritos abaixo respeitam essa distinção).
|
||||||
|
|
||||||
|
### Template (`templates/dashboard-contabil-relatorio.html`) e filtros (`portal_api/templatetags/contabil_extras.py`)
|
||||||
|
|
||||||
|
Documento HTML autocontido — `<style>` inline com a mesma paleta já usada nos documentos gerados (`indicadores/recibo.py`: roxo `#3d2178`, dourado `#b4872a`), cabeçalho com `static/img/logo-branco.png` (texto branco — não `logo.png`, que ficaria ilegível sobre o banner roxo escuro, ver "Logos em `static/img/`" no CLAUDE.md raiz). Botão "Imprimir" (`onclick="window.print()"`) escondido via `@media print`.
|
||||||
|
|
||||||
|
**Conteúdo dividido em 3 abas** (`.dcr-tabs`/`.dcr-tab`/`[data-dcr-panel]`, JS puro inline no próprio template — não reaproveita `.pa-tabs` de `perfis-acesso.css`, que não é carregado neste documento autocontido), nesta ordem pedida pelo usuário: **Balancete** (aba inicial) → **D.R.E.** → **Indicadores** (os dois grupos de cards + o gráfico de evolução, que fazem mais sentido juntos). Na impressão (`@media print`), a barra de abas some e as 3 ficam visíveis ao mesmo tempo, cada uma numa página própria (`page-break-after`) — documento impresso não deve esconder conteúdo atrás de uma aba não clicada.
|
||||||
|
|
||||||
|
**Observações espalhadas nas 3 abas, cada uma com o recorte certo** (pedido explícito do usuário — antes ficavam todas juntas numa única seção fora das abas): a aba **Balancete** termina com "Observações do Balancete" (só `observacoes_contas`); a aba **D.R.E.** termina com "Observações da D.R.E." (só `observacoes_dre`); a aba **Indicadores** termina com "Todas as Observações da Análise" (`observacoes_contas` + `observacoes_dre` + `achados_com_observacao` juntos, cada item com um prefixo indicando a origem — "Balancete — ...", "D.R.E. — ...", "Auditoria — ..." — já que aqui não há mais uma aba própria pra inferir o contexto). **Nunca "Achado"/"Achado de Auditoria" em texto visível** — pedido explícito do usuário, mesmo motivo pelo qual a aba de revisão já se chama "Observações" (`data-dc-tab="achados"` com o texto "Observações", `dashboard-contabil.html`) e não "Achados"; `achado`/`ContabilAchado`/`achados_com_observacao` continuam normais como nome de variável/model/classe, só não podem aparecer como palavra na tela. As três seções reaproveitam o mesmo markup (`.dcr-obs-lista`/`.dcr-obs-item`), só filtrando quais das três listas do contexto (`observacoes_contas`/`observacoes_dre`/`achados_com_observacao`, já vindas prontas de `views.py`) cada uma itera — nenhuma mudança no backend foi necessária, é só reorganização do template. Estado vazio próprio por seção ("Nenhuma observação registrada no Balancete."/"...na D.R.E."/"...nesta análise.").
|
||||||
|
|
||||||
|
**Balancete e D.R.E. têm árvore recolhível igual à tela de revisão** (pedido explícito do usuário — a primeira versão do relatório vinha totalmente expandida, sem toggle). Diferença de arquitetura em relação a `renderContas()`/`renderDre()` em `dashboard-contabil.js`: lá é uma SPA que re-renderiza a tabela inteira a cada clique; aqui é HTML estático gerado uma vez, então o nível de cada linha e se ela "tem filhos" (`nivel`/`tem_filhos`) são calculados **no servidor** (`_contabil_arvore_contexto()` em `views.py`, mesmo algoritmo — "tem filhos" = a próxima linha tem nível maior) e ficam como atributos `data-dcr-nivel`/`data-dcr-tem-filhos`/`data-dcr-id` em cada `<tr>` já renderizada. `pidDcrArvore(tbodyId)` (JS inline no template) só alterna o atributo `hidden` das `<tr>` existentes com a mesma lógica de pilha de níveis recolhidos, sem reconstruir HTML nenhum. Tudo nasce expandido (nenhum recolhido por padrão). Na impressão, `.dcr-tabela tbody tr[hidden] { display: table-row !important; }` força toda linha a aparecer mesmo que o usuário tenha recolhido algum grupo na tela — documento impresso não deve esconder conta nenhuma atrás de um grupo recolhido.
|
||||||
|
|
||||||
|
Gráfico de evolução do Resultado Líquido via Chart.js (`<script src="https://cdn.jsdelivr.net/npm/chart.js@4">`, snippet oficial recomendado pela biblioteca) — série injetada com `{{ evolucao|json_script:"dcr-evolucao-data" }}` (usa `DjangoJSONEncoder`, que já serializa `Decimal`/`date` sem precisar de nenhum encoder customizado) e lida em JS puro. **Inicializado sob demanda**, não no carregamento da página: o `<canvas>` nasce dentro da aba "Indicadores", escondida por padrão (Balancete é a aba inicial), e Chart.js não desenha corretamente num canvas com largura/altura zero — `inicializaGrafico()` só roda no primeiro clique nessa aba (ou no evento `beforeprint`, pra garantir que o gráfico exista na versão impressa mesmo que a aba nunca tenha sido aberta na tela).
|
||||||
|
|
||||||
|
`portal_api/templatetags/contabil_extras.py` — **primeiro uso de template tags customizadas no projeto** (precisou de `portal_api/templatetags/__init__.py`, auto-descoberto pelo Django por `portal_api` já estar em `INSTALLED_APPS`). Filtros `moeda`/`percentual`/`indice`/`competencia`, todos no padrão brasileiro (separador de milhar `.`, decimal `,`) — mesmo espírito do helper `_moeda()` que já existe, duplicado por arquivo, nos dois geradores de PDF (`indicadores/recibo.py`, `custo_contratacao/pdf.py`), mas como filtro reaproveitável, já que este template tem tabelas inteiras de valores monetários (Balancete/DRE), não um valor por vez. `None` sempre vira "—", nunca "R$ 0,00"/"0,00%" — ver acima por quê. Um quinto filtro, `numero_bruto`, existe só pra alimentar a animação de contagem dos cards (ver "Visual" abaixo) — devolve o valor cru (`str(float(valor))`, `""` se `None`) exclusivamente para um atributo `data-count`, nunca pro texto exibido.
|
||||||
|
|
||||||
|
### Visual: fontes, animações e contagem animada dos cards
|
||||||
|
|
||||||
|
Pedido explícito do usuário — "deixar mais bonito, complexo, dinâmico, com animações fluidas". Fontes "Manrope" (títulos/cards/abas) + "Inter" (corpo, `font-variant-numeric: tabular-nums` nas colunas de valor) via Google Fonts. Paleta estendida da mesma família roxo/dourado já usada nos documentos gerados, com gradiente + glow radial sutil no cabeçalho (`.dcr-header::before`, `@keyframes dcrGlow`) e no fundo da página.
|
||||||
|
|
||||||
|
**Regra de ouro pra toda animação de entrada aqui**: nunca fixar `opacity:0`/`transform` como estilo estático fora de um `@keyframes` — só via `animation: nome duração easing both;`. Isso garante que `@media print { * { animation: none !important; } }` sozinho já basta pra devolver o elemento ao estado normal (visível, posição natural) na impressão, sem precisar de um reset explícito por seletor — se alguma animação nova for adicionada aqui, seguir essa mesma disciplina, senão a impressão pode sair com conteúdo em branco. O restante do `@media print` já existente (abas somem, todas as 3 aparecem juntas, linhas recolhidas forçadas a aparecer) continua igual.
|
||||||
|
|
||||||
|
Cards ganharam `data-count`/`data-final`/`data-format`/`data-color-rule` (ver `pidDcrAnimaContadores()`): a contagem anima de 0 até o valor com `requestAnimationFrame`/easing, formatando os quadros intermediários com `Intl`/`toLocaleString("pt-BR", ...)` nativo do navegador — mas o texto **final** escrito ao fim da animação é sempre `data-final`, a mesma string que os filtros Django já geraram (nunca um valor recalculado em JS, só decoração da transição). Cor por sinal/threshold só onde é seguro sem inventar limiar nenhum: `data-color-rule="sign"` (ROA/ROE/EBIT/EBITDA — positivo verde, negativo vermelho, convenção universal) e `data-color-rule="liquidez"` (Liquidez Corrente/Seca/Geral — verde se ≥ 1, âmbar/vermelho se < 1, também convenção padrão de mercado). Kanitz, Composição/Grau de Endividamento e IPL **não** ganham cor nenhuma — não existe um limiar validado pra eles nesta implementação (ver "Fórmulas" acima), então colorir feito "bom"/"ruim" daria uma falsa segurança num número que o próprio card já avisa ser estimativa/aproximação.
|
||||||
|
|
||||||
|
**Cards e gráfico só existem/animam depois da aba "Indicadores" ser aberta pela primeira vez** (mesmo motivo do gráfico: canvas com tamanho zero não desenha certo, e contar um número invisível não faz sentido). Isso criou um risco real de impressão: se o usuário nunca abrir essa aba e mandar imprimir direto, a contagem começaria do zero bem na hora que o navegador captura a página. `pidDcrAnimaContadores(true)` (parâmetro `instantaneo`) resolve isso — o único listener de `beforeprint` do documento decide entre inicializar tudo já no valor final (se a aba nunca foi aberta) ou só finalizar uma contagem já em andamento (`finalizadores`, um array de callbacks que força cada card pro texto final) — nunca as duas coisas competindo (era um bug real de uma versão intermediária desta mesma rodada, corrigido antes do usuário testar: dois listeners de `beforeprint` separados podiam disparar uma animação nova bem na hora de imprimir, sem tempo de terminar).
|
||||||
|
|
||||||
|
Abas ganharam um indicador deslizante (`.dcr-tabs__indicator`, `getBoundingClientRect`-like via `offsetLeft`/`offsetWidth`, recalculado no `resize` e no `load` — texto de fonte customizada pode mudar a largura da aba depois do primeiro paint). Árvore recolhível do Balancete/D.R.E. ganhou um fade rápido só nas linhas que acabaram de aparecer (`.dcr-row-in`, reflow forçado via `void tr.offsetWidth` pra poder reiniciar a animação a cada clique), não a tabela inteira — evita flicker num clique que só afeta um grupo pequeno.
|
||||||
|
|
||||||
|
### Frontend (`static/js/dashboard-contabil.js`)
|
||||||
|
|
||||||
|
`pidGerarDashboardContabil(id)` é só `window.open(`/api/contabil-apuracoes/${id}/dashboard/`, "_blank")` — navegação direta, sem `fetch`/blob/CSRF nenhum (ver por que isso importa em "É GET, não POST" acima). Bem mais simples do que o padrão de `pidGerarArquivoPlanoSaude` (`importacao-plano-saude.js`), que precisa de fetch manual porque `/gerar/` ali é POST e devolve um arquivo pra download, não uma página pra navegar.
|
||||||
|
|
||||||
|
### Exportação em XLSX (`exportacao.py`)
|
||||||
|
|
||||||
|
Pedido explícito do usuário: um botão "Exportar XLSX" dentro do relatório "Gerar Dashboard", um por seção (Balancete/D.R.E.), pra baixar aquela tabela em planilha. `ContabilApuracaoViewSet.exportar_xlsx()` (`GET /api/contabil-apuracoes/{id}/exportar-xlsx/?parte=balancete` ou `?parte=dre`, mesma permissão de toggle único das outras actions) monta as linhas já com o nível de indentação calculado (mesma fórmula de `dashboard()`: `conta.codigo.count(".")` pro Balancete, `max(0, linha.nivel)` pra DRE) e chama `dashboard_contabil.exportacao.gera_xlsx_balancete()`/`gera_xlsx_dre()` — funções puras (openpyxl, sem tocar no ORM, mesmo espírito de `indicadores.py`/`regras.py`) que recebem dataclasses (`LinhaBalanceteXlsx`/`LinhaDreXlsx`) já prontas, não os models do Django.
|
||||||
|
|
||||||
|
Cada planilha nasce com cabeçalho (título/empresa/CNPJ/competência, linhas 1-3, mescladas), uma linha de cabeçalho de colunas com fundo roxo (`#3d2178`, mesma paleta dos outros documentos gerados pelo escritório — `indicadores/recibo.py`/`custo_contratacao/pdf.py`, nunca a marca "P.I.D." do Portal) e a tabela de dados a partir da linha 6, com `freeze_panes` logo abaixo do cabeçalho de colunas. Conta sintética (Balancete, `tipo="S"`) e linha totalizadora (DRE, `totalizador=True`) ganham negrito + um fundo dourado claro (`#f6ecd4`), mesmo destaque visual que essas linhas já têm na tela de revisão e no relatório HTML. A hierarquia (nível de indentação) vira `Alignment(indent=nivel)` na célula de descrição — não dá pra reproduzir o toggle recolher/expandir de uma planilha, então a árvore sempre nasce "totalmente expandida" (mesmo espírito do relatório HTML impresso). Colunas monetárias usam `number_format = '"R$" #,##0.00'` (valor gravado como `float`, não como texto formatado — continua editável/somável no Excel).
|
||||||
|
|
||||||
|
Botão "Exportar XLSX" (`.dcr-export-btn`, `dashboard-contabil-relatorio.html`) é um `<a href="/api/contabil-apuracoes/{{ apuracao.id }}/exportar-xlsx/?parte=...">` puro — sem JS nenhum, mesmo espírito de link direto de download; o browser já lida com o `Content-Disposition: attachment` da resposta. Escondido em `@media print` junto do botão "Imprimir" (`.dcr-print-btn`), já que exportar não faz sentido numa versão impressa.
|
||||||
|
|
||||||
|
### Nova consulta de histórico (`_contabil_monta_historico_completo`, `views.py`)
|
||||||
|
|
||||||
|
Diferente de `_contabil_monta_historico` (capada em 2 apurações anteriores, usada só pelas regras de auditoria no `create()`), esta busca **todo** o histórico da empresa — usada só pelo gráfico de evolução e pelo cálculo de Depreciação/Amortização do relatório. Recebe `codigo_empresa`/`competencia_atual` diretamente (não um `CabecalhoExtraido`) porque roda contra uma `ContabilApuracao` já persistida, ao contrário de `_contabil_monta_historico` (que roda durante o `create()`, antes de qualquer coisa existir no banco).
|
||||||
|
|||||||
@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
> Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico (extração do PDF, regras de auditoria, models). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal.
|
> Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico (extração do PDF, regras de auditoria, models). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal.
|
||||||
|
|
||||||
Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil: o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor hoje enviado ao cliente), a ferramenta extrai as contas automaticamente e roda um conjunto de checagens de auditoria (saldos negativos de caixa, contas transitórias/genéricas com saldo, contas que deveriam estar zeradas, diferença débito/crédito, variações atípicas mês a mês) — o contador revisa os achados, registra observações por conta e conclui a análise.
|
Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil: o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor hoje enviado ao cliente), a ferramenta extrai as contas automaticamente e roda um conjunto de checagens de auditoria (saldos negativos de caixa, contas transitórias/genéricas com saldo, contas que deveriam estar zeradas, diferença débito/crédito, variações atípicas mês a mês) — o contador revisa os achados, registra observações por conta e conclui a análise. O botão "Gerar Dashboard" gera um relatório HTML autocontido (indicadores financeiros, gráfico de evolução, DRE/Balancete agrupados, observações do contador) com a marca do escritório, pronto pra enviar ao administrador da empresa.
|
||||||
|
|
||||||
**Escopo atual: só a etapa de execução/auditoria/análise.** A geração do dashboard HTML final (para o administrador da empresa, com exportação em XLSX) fica para uma rodada futura — o botão já existe na tela, mas desabilitado.
|
**Ainda fora de escopo**: exportação em XLSX; consolidação entre várias empresas/competências ao mesmo tempo (é sempre uma apuração por vez).
|
||||||
|
|
||||||
## Onde mexer
|
## Onde mexer
|
||||||
|
|
||||||
- `portal_api/dashboard_contabil/` — `parser.py` (extração do PDF via `pdfplumber`), `regras.py` (motor de regras de auditoria), `pipeline.py` (orquestração), `modelos.py` (dataclasses).
|
- `portal_api/dashboard_contabil/` — `parser.py` (extração do PDF via `pdfplumber`), `regras.py` (motor de regras de auditoria), `indicadores.py` (indicadores financeiros do relatório), `pipeline.py` (orquestração), `modelos.py` (dataclasses).
|
||||||
- `ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilAchado` (`portal_api/models.py`).
|
- `ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilAchado` (`portal_api/models.py`).
|
||||||
- `dashboard-contabil.html` / `static/js/dashboard-contabil.js` / `static/css/dashboard-contabil.css`.
|
- `dashboard-contabil.html` / `static/js/dashboard-contabil.js` / `static/css/dashboard-contabil.css`.
|
||||||
|
- `dashboard-contabil-relatorio.html` (relatório em si) / `portal_api/templatetags/contabil_extras.py` (filtros de formatação).
|
||||||
|
|||||||
146
portal_api/dashboard_contabil/exportacao.py
Normal file
146
portal_api/dashboard_contabil/exportacao.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
"""Exportação em XLSX de Balancete/DRE a partir do relatório "Gerar
|
||||||
|
Dashboard" (botão "Exportar XLSX", `dashboard-contabil-relatorio.html`, ver
|
||||||
|
`ContabilApuracaoViewSet.exportar_xlsx()` em views.py) — funções puras
|
||||||
|
(openpyxl), recebem os dados já consultados/derivados pela view, nunca tocam
|
||||||
|
no ORM (mesmo espírito de `indicadores.py`/`regras.py` deste pacote)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Alignment, Font, PatternFill
|
||||||
|
from openpyxl.worksheet.worksheet import Worksheet
|
||||||
|
|
||||||
|
# Mesma paleta roxo/dourado já usada nos outros documentos gerados pelo
|
||||||
|
# escritório (indicadores/recibo.py, custo_contratacao/pdf.py) — nunca a
|
||||||
|
# marca "P.I.D." do Portal aqui (ver "Logos" no CLAUDE.md raiz).
|
||||||
|
COR_ROXO_ESCURO = "FF3D2178"
|
||||||
|
COR_DOURADO_CLARO = "FFF6ECD4"
|
||||||
|
COR_BRANCO = "FFFFFFFF"
|
||||||
|
FORMATO_MOEDA = '"R$" #,##0.00'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LinhaBalanceteXlsx:
|
||||||
|
codigo: str
|
||||||
|
descricao: str
|
||||||
|
tipo: str # "S" (sintética) ou "A" (analítica) — mesmo campo de ContabilConta.tipo
|
||||||
|
nivel: int
|
||||||
|
saldo_anterior: Decimal
|
||||||
|
debito: Decimal
|
||||||
|
credito: Decimal
|
||||||
|
saldo_atual: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LinhaDreXlsx:
|
||||||
|
descricao: str
|
||||||
|
nivel: int
|
||||||
|
totalizador: bool
|
||||||
|
valor: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
def _cabecalho(ws: Worksheet, titulo: str, nome_empresa: str, cnpj: str, competencia: date, ultima_coluna: str) -> int:
|
||||||
|
"""Escreve título/empresa/CNPJ/competência nas primeiras linhas da
|
||||||
|
planilha e devolve a linha em que a tabela de dados deve começar."""
|
||||||
|
ws.merge_cells(f"A1:{ultima_coluna}1")
|
||||||
|
ws["A1"] = titulo
|
||||||
|
ws["A1"].font = Font(size=14, bold=True, color=COR_ROXO_ESCURO)
|
||||||
|
|
||||||
|
ws.merge_cells(f"A2:{ultima_coluna}2")
|
||||||
|
ws["A2"] = nome_empresa
|
||||||
|
ws["A2"].font = Font(size=11, bold=True)
|
||||||
|
|
||||||
|
ws.merge_cells(f"A3:{ultima_coluna}3")
|
||||||
|
ws["A3"] = f"CNPJ {cnpj or 'não informado'} · Competência {competencia:%m/%Y}"
|
||||||
|
ws["A3"].font = Font(size=9, italic=True)
|
||||||
|
|
||||||
|
return 5
|
||||||
|
|
||||||
|
|
||||||
|
def _escreve_cabecalho_colunas(ws: Worksheet, linha: int, titulos: list[str]) -> None:
|
||||||
|
for coluna, titulo in enumerate(titulos, start=1):
|
||||||
|
celula = ws.cell(row=linha, column=coluna, value=titulo)
|
||||||
|
celula.font = Font(bold=True, color=COR_BRANCO)
|
||||||
|
celula.fill = PatternFill("solid", fgColor=COR_ROXO_ESCURO)
|
||||||
|
celula.alignment = Alignment(horizontal="left" if coluna <= 2 else "center", vertical="center")
|
||||||
|
|
||||||
|
|
||||||
|
def gera_xlsx_balancete(nome_empresa: str, cnpj: str, competencia: date, linhas: list[LinhaBalanceteXlsx]) -> bytes:
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Balancete"
|
||||||
|
|
||||||
|
linha_inicial = _cabecalho(ws, "Balancete Contábil", nome_empresa, cnpj, competencia, "F")
|
||||||
|
_escreve_cabecalho_colunas(
|
||||||
|
ws, linha_inicial, ["Conta", "Descrição", "Saldo Anterior", "Débito", "Crédito", "Saldo Atual"]
|
||||||
|
)
|
||||||
|
|
||||||
|
largura_descricao = 30
|
||||||
|
for indice, item in enumerate(linhas):
|
||||||
|
linha = linha_inicial + 1 + indice
|
||||||
|
negrito = item.tipo == "S" # conta sintética — mesmo destaque visual do PDF original e da tela de revisão
|
||||||
|
|
||||||
|
ws.cell(row=linha, column=1, value=item.codigo).font = Font(bold=negrito, size=9)
|
||||||
|
|
||||||
|
celula_descricao = ws.cell(row=linha, column=2, value=item.descricao)
|
||||||
|
celula_descricao.font = Font(bold=negrito)
|
||||||
|
celula_descricao.alignment = Alignment(indent=item.nivel)
|
||||||
|
largura_descricao = max(largura_descricao, len(item.descricao) + item.nivel + 2)
|
||||||
|
|
||||||
|
for coluna, valor in zip((3, 4, 5, 6), (item.saldo_anterior, item.debito, item.credito, item.saldo_atual)):
|
||||||
|
celula = ws.cell(row=linha, column=coluna, value=float(valor))
|
||||||
|
celula.number_format = FORMATO_MOEDA
|
||||||
|
celula.font = Font(bold=negrito)
|
||||||
|
|
||||||
|
if negrito:
|
||||||
|
for coluna in range(1, 7):
|
||||||
|
ws.cell(row=linha, column=coluna).fill = PatternFill("solid", fgColor=COR_DOURADO_CLARO)
|
||||||
|
|
||||||
|
ws.freeze_panes = f"A{linha_inicial + 1}"
|
||||||
|
ws.column_dimensions["A"].width = 16
|
||||||
|
ws.column_dimensions["B"].width = min(largura_descricao, 60)
|
||||||
|
for coluna in "CDEF":
|
||||||
|
ws.column_dimensions[coluna].width = 16
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
wb.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def gera_xlsx_dre(nome_empresa: str, cnpj: str, competencia: date, linhas: list[LinhaDreXlsx]) -> bytes:
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "DRE"
|
||||||
|
|
||||||
|
linha_inicial = _cabecalho(ws, "Demonstração do Resultado do Exercício", nome_empresa, cnpj, competencia, "B")
|
||||||
|
_escreve_cabecalho_colunas(ws, linha_inicial, ["Descrição", "Valor"])
|
||||||
|
|
||||||
|
largura_descricao = 30
|
||||||
|
for indice, item in enumerate(linhas):
|
||||||
|
linha = linha_inicial + 1 + indice
|
||||||
|
|
||||||
|
celula_descricao = ws.cell(row=linha, column=1, value=item.descricao)
|
||||||
|
celula_descricao.font = Font(bold=item.totalizador)
|
||||||
|
celula_descricao.alignment = Alignment(indent=item.nivel)
|
||||||
|
largura_descricao = max(largura_descricao, len(item.descricao) + item.nivel + 2)
|
||||||
|
|
||||||
|
celula_valor = ws.cell(row=linha, column=2, value=float(item.valor))
|
||||||
|
celula_valor.number_format = FORMATO_MOEDA
|
||||||
|
celula_valor.font = Font(bold=item.totalizador)
|
||||||
|
|
||||||
|
if item.totalizador:
|
||||||
|
for coluna in (1, 2):
|
||||||
|
ws.cell(row=linha, column=coluna).fill = PatternFill("solid", fgColor=COR_DOURADO_CLARO)
|
||||||
|
|
||||||
|
ws.freeze_panes = f"A{linha_inicial + 1}"
|
||||||
|
ws.column_dimensions["A"].width = min(largura_descricao, 60)
|
||||||
|
ws.column_dimensions["B"].width = 18
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
wb.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
164
portal_api/dashboard_contabil/indicadores.py
Normal file
164
portal_api/dashboard_contabil/indicadores.py
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
"""Indicadores financeiros do relatório "Gerar Dashboard" (ver
|
||||||
|
`ContabilApuracaoViewSet.dashboard()` em views.py) — funções puras, mesmo
|
||||||
|
espírito de `regras.py`: recebem os dados já extraídos (nunca tocam no ORM)
|
||||||
|
e devolvem um resultado calculado, fácil de revisar/testar isoladamente.
|
||||||
|
|
||||||
|
Os grupos do Balancete (Ativo Circulante, Passivo Circulante, Patrimônio
|
||||||
|
Líquido...) são identificados por código de classificação **fixo**,
|
||||||
|
calibrado contra o balancete de referência do usuário
|
||||||
|
(`792 - balancete 072026.pdf`) — mesma decisão de risco já aceita em
|
||||||
|
`regras.regra_saldo_negativo_caixa` (código fixo "1.01.01.001" pro grupo
|
||||||
|
Caixa). Se um cliente usar uma numeração de plano de contas diferente da
|
||||||
|
vista até agora, os indicadores desse cliente saem errados silenciosamente —
|
||||||
|
revisar contra mais balancetes reais antes de confiar cegamente no valor
|
||||||
|
exibido ao cliente (ver "Fórmulas" no CLAUDE.md do pacote)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from .modelos import SnapshotHistorico
|
||||||
|
from .parser import LINHA_DRE_DESPESAS_RECEITAS_FINANCEIRAS
|
||||||
|
|
||||||
|
CODIGO_ATIVO_TOTAL = "1"
|
||||||
|
CODIGO_ATIVO_CIRCULANTE = "1.01"
|
||||||
|
CODIGO_ESTOQUES = "1.01.08"
|
||||||
|
CODIGO_IMOBILIZADO = "1.02.05"
|
||||||
|
CODIGO_DEPRECIACAO_ACUMULADA = "1.02.05.007"
|
||||||
|
CODIGO_PASSIVO_TOTAL = "2"
|
||||||
|
CODIGO_PASSIVO_CIRCULANTE = "2.01"
|
||||||
|
CODIGO_PATRIMONIO_LIQUIDO = "2.04"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IndicadoresFinanceiros:
|
||||||
|
ativo_total: Decimal
|
||||||
|
ativo_circulante: Decimal
|
||||||
|
passivo_circulante: Decimal
|
||||||
|
passivo_nao_circulante: Decimal
|
||||||
|
patrimonio_liquido: Decimal
|
||||||
|
exigivel_total: Decimal
|
||||||
|
estoques: Decimal
|
||||||
|
imobilizado: Decimal
|
||||||
|
resultado_liquido: Decimal
|
||||||
|
ebit: Decimal
|
||||||
|
ebitda: Decimal | None
|
||||||
|
depreciacao_amortizacao: Decimal | None
|
||||||
|
roa: Decimal | None
|
||||||
|
roe: Decimal | None
|
||||||
|
liquidez_corrente: Decimal | None
|
||||||
|
liquidez_seca: Decimal | None
|
||||||
|
liquidez_geral: Decimal | None
|
||||||
|
composicao_endividamento: Decimal | None
|
||||||
|
grau_endividamento: Decimal | None
|
||||||
|
ipl: Decimal | None
|
||||||
|
kanitz: Decimal | None
|
||||||
|
|
||||||
|
|
||||||
|
def _saldo(contas: dict[str, Decimal], codigo: str) -> Decimal:
|
||||||
|
"""Contas do Passivo/Patrimônio Líquido vêm com saldo negativo no
|
||||||
|
relatório (documentado no CLAUDE.md do pacote) — todo indicador aqui
|
||||||
|
trabalha em valor absoluto."""
|
||||||
|
return abs(contas.get(codigo, Decimal(0)))
|
||||||
|
|
||||||
|
|
||||||
|
def _divide(numerador: Decimal, denominador: Decimal) -> Decimal | None:
|
||||||
|
if denominador == 0:
|
||||||
|
return None
|
||||||
|
return numerador / denominador
|
||||||
|
|
||||||
|
|
||||||
|
def _busca_linha_por_trecho(dre: dict[str, Decimal], trecho: str) -> Decimal:
|
||||||
|
return next((valor for descricao, valor in dre.items() if trecho in descricao.upper()), Decimal(0))
|
||||||
|
|
||||||
|
|
||||||
|
def calcula_indicadores(
|
||||||
|
contas_atuais: dict[str, Decimal],
|
||||||
|
dre_atual: dict[str, Decimal],
|
||||||
|
resultado_liquido: Decimal,
|
||||||
|
historico: list[SnapshotHistorico],
|
||||||
|
) -> IndicadoresFinanceiros:
|
||||||
|
"""`resultado_liquido` é passado à parte (não lido de `dre_atual` por
|
||||||
|
descrição) porque é sempre a última linha da DRE, na ordem em que o
|
||||||
|
relatório imprime — mais seguro do que casar por texto (ver
|
||||||
|
`ContabilApuracaoViewSet.dashboard`, que já tem a queryset ordenada por
|
||||||
|
`ordem` à mão). `historico` vem ordenado da apuração mais recente pra
|
||||||
|
mais antiga (mesma convenção de `regras.py`) — só `historico[0]`, quando
|
||||||
|
existir, é usado, pro cálculo de Depreciação/Amortização do mês."""
|
||||||
|
ativo_total = _saldo(contas_atuais, CODIGO_ATIVO_TOTAL)
|
||||||
|
ativo_circulante = _saldo(contas_atuais, CODIGO_ATIVO_CIRCULANTE)
|
||||||
|
passivo_total = _saldo(contas_atuais, CODIGO_PASSIVO_TOTAL)
|
||||||
|
passivo_circulante = _saldo(contas_atuais, CODIGO_PASSIVO_CIRCULANTE)
|
||||||
|
patrimonio_liquido = _saldo(contas_atuais, CODIGO_PATRIMONIO_LIQUIDO)
|
||||||
|
estoques = _saldo(contas_atuais, CODIGO_ESTOQUES)
|
||||||
|
imobilizado = _saldo(contas_atuais, CODIGO_IMOBILIZADO)
|
||||||
|
|
||||||
|
# Passivo Não Circulante/Exigível a Longo Prazo não tem código calibrado
|
||||||
|
# (não aparece no balancete de referência, que não tem dívida de longo
|
||||||
|
# prazo) — por eliminação é sempre exato pela identidade contábil, sem
|
||||||
|
# depender de mais um código a adivinhar.
|
||||||
|
passivo_nao_circulante = passivo_total - passivo_circulante - patrimonio_liquido
|
||||||
|
exigivel_total = passivo_circulante + passivo_nao_circulante
|
||||||
|
|
||||||
|
despesas_receitas_financeiras = _busca_linha_por_trecho(dre_atual, LINHA_DRE_DESPESAS_RECEITAS_FINANCEIRAS)
|
||||||
|
ebit = resultado_liquido - despesas_receitas_financeiras
|
||||||
|
|
||||||
|
depreciacao_amortizacao: Decimal | None = None
|
||||||
|
if historico:
|
||||||
|
deprec_anterior = historico[0].contas.get(CODIGO_DEPRECIACAO_ACUMULADA)
|
||||||
|
if deprec_anterior is not None:
|
||||||
|
deprec_atual = _saldo(contas_atuais, CODIGO_DEPRECIACAO_ACUMULADA)
|
||||||
|
depreciacao_amortizacao = deprec_atual - abs(deprec_anterior)
|
||||||
|
ebitda = ebit + depreciacao_amortizacao if depreciacao_amortizacao is not None else None
|
||||||
|
|
||||||
|
liquidez_corrente = _divide(ativo_circulante, passivo_circulante)
|
||||||
|
# Liquidez Geral: aproximação. O Ativo Não Circulante ("1.02") hoje
|
||||||
|
# mistura Investimentos/Imobilizado com um eventual Realizável a Longo
|
||||||
|
# Prazo, sem separar — então tratamos o Realizável a Longo Prazo como
|
||||||
|
# indisponível/0. Coincide com a Liquidez Corrente quando a empresa não
|
||||||
|
# tem Passivo Não Circulante (era o caso do balancete de referência).
|
||||||
|
liquidez_geral = _divide(ativo_circulante, exigivel_total)
|
||||||
|
liquidez_seca = _divide(ativo_circulante - estoques, passivo_circulante)
|
||||||
|
composicao_endividamento = _divide(passivo_circulante, exigivel_total)
|
||||||
|
grau_endividamento = _divide(exigivel_total, patrimonio_liquido)
|
||||||
|
ipl = _divide(imobilizado, patrimonio_liquido)
|
||||||
|
roa = _divide(resultado_liquido, ativo_total)
|
||||||
|
roe = _divide(resultado_liquido, patrimonio_liquido)
|
||||||
|
|
||||||
|
# Termômetro de Insolvência de Kanitz, fórmula-livro-texto padrão — não
|
||||||
|
# validada contra o BI antigo que este relatório substitui (ver CLAUDE.md
|
||||||
|
# do pacote, "Fórmulas").
|
||||||
|
kanitz = None
|
||||||
|
if None not in (roe, liquidez_geral, liquidez_seca, liquidez_corrente, grau_endividamento):
|
||||||
|
kanitz = (
|
||||||
|
Decimal("0.05") * roe
|
||||||
|
+ Decimal("1.65") * liquidez_geral
|
||||||
|
+ Decimal("3.55") * liquidez_seca
|
||||||
|
- Decimal("1.06") * liquidez_corrente
|
||||||
|
- Decimal("0.33") * grau_endividamento
|
||||||
|
)
|
||||||
|
|
||||||
|
return IndicadoresFinanceiros(
|
||||||
|
ativo_total=ativo_total,
|
||||||
|
ativo_circulante=ativo_circulante,
|
||||||
|
passivo_circulante=passivo_circulante,
|
||||||
|
passivo_nao_circulante=passivo_nao_circulante,
|
||||||
|
patrimonio_liquido=patrimonio_liquido,
|
||||||
|
exigivel_total=exigivel_total,
|
||||||
|
estoques=estoques,
|
||||||
|
imobilizado=imobilizado,
|
||||||
|
resultado_liquido=resultado_liquido,
|
||||||
|
ebit=ebit,
|
||||||
|
ebitda=ebitda,
|
||||||
|
depreciacao_amortizacao=depreciacao_amortizacao,
|
||||||
|
roa=roa,
|
||||||
|
roe=roe,
|
||||||
|
liquidez_corrente=liquidez_corrente,
|
||||||
|
liquidez_seca=liquidez_seca,
|
||||||
|
liquidez_geral=liquidez_geral,
|
||||||
|
composicao_endividamento=composicao_endividamento,
|
||||||
|
grau_endividamento=grau_endividamento,
|
||||||
|
ipl=ipl,
|
||||||
|
kanitz=kanitz,
|
||||||
|
)
|
||||||
@ -51,6 +51,13 @@ _RE_CODIGO_NOME_EMPRESA = re.compile(r"^(?P<codigo>\d+)\s+(?P<nome>.+)$")
|
|||||||
LINHA_DRE_RECEITA_LIQUIDA = "(=) RECEITA OPERACIONAL LIQUIDA"
|
LINHA_DRE_RECEITA_LIQUIDA = "(=) RECEITA OPERACIONAL LIQUIDA"
|
||||||
LINHA_DRE_CUSTOS_TOTAIS = "(-) CUSTOS TOTAIS"
|
LINHA_DRE_CUSTOS_TOTAIS = "(-) CUSTOS TOTAIS"
|
||||||
|
|
||||||
|
# Trecho (não o texto exato) da linha "(+/-) Despesas/Receitas Financeiras",
|
||||||
|
# usado só pelo cálculo de EBIT/EBITDA em dashboard_contabil.indicadores —
|
||||||
|
# diferente das duas constantes acima, ainda não foi calibrado contra o
|
||||||
|
# texto exato de um PDF real, por isso casa por substring (ver
|
||||||
|
# `_busca_linha_por_trecho` em indicadores.py) em vez de igualdade exata.
|
||||||
|
LINHA_DRE_DESPESAS_RECEITAS_FINANCEIRAS = "DESPESAS/RECEITAS FINANCEIRAS"
|
||||||
|
|
||||||
|
|
||||||
class ExtracaoInvalidaError(Exception):
|
class ExtracaoInvalidaError(Exception):
|
||||||
"""PDF não tem o formato esperado (cabeçalho/tabela do Balancete/DRE não
|
"""PDF não tem o formato esperado (cabeçalho/tabela do Balancete/DRE não
|
||||||
|
|||||||
0
portal_api/templatetags/__init__.py
Normal file
0
portal_api/templatetags/__init__.py
Normal file
63
portal_api/templatetags/contabil_extras.py
Normal file
63
portal_api/templatetags/contabil_extras.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
"""Filtros de template usados só por `templates/dashboard-contabil-relatorio.html`
|
||||||
|
— formatação de moeda/percentual/índice/competência no padrão brasileiro.
|
||||||
|
Mesmo espírito do helper `_moeda()` já usado (por arquivo) nos geradores de
|
||||||
|
PDF (`indicadores/recibo.py`, `custo_contratacao/pdf.py`), mas como filtro
|
||||||
|
reaproveitável — este template tem muito mais valores monetários numa
|
||||||
|
tabela (Balancete/DRE inteiros) do que um documento de PDF por linha.
|
||||||
|
|
||||||
|
`None` sempre vira "—" (não "R$ 0,00"/"0,00%") — em `indicadores.py`, `None`
|
||||||
|
significa "indisponível" (ex.: EBITDA sem histórico), não zero; misturar os
|
||||||
|
dois daria uma falsa impressão de precisão num documento que pode ir ao
|
||||||
|
cliente."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
def _agrupa_milhar(texto_us: str) -> str:
|
||||||
|
""""1,234.56" (formatação `,.2f` do Python) -> "1.234,56" (padrão BR)."""
|
||||||
|
return texto_us.replace(",", "_").replace(".", ",").replace("_", ".")
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def moeda(valor: Decimal | None) -> str:
|
||||||
|
if valor is None:
|
||||||
|
return "—"
|
||||||
|
return f"R$ {_agrupa_milhar(f'{valor:,.2f}')}"
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def percentual(valor: Decimal | None) -> str:
|
||||||
|
if valor is None:
|
||||||
|
return "—"
|
||||||
|
return f"{_agrupa_milhar(f'{valor * 100:,.2f}')}%"
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def indice(valor: Decimal | None) -> str:
|
||||||
|
"""Kanitz não é moeda nem percentual, só um número com 2 casas."""
|
||||||
|
if valor is None:
|
||||||
|
return "—"
|
||||||
|
return _agrupa_milhar(f"{valor:,.2f}")
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def competencia(valor: date | None) -> str:
|
||||||
|
return valor.strftime("%m/%Y") if valor else "—"
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def numero_bruto(valor: Decimal | None) -> str:
|
||||||
|
"""Valor numérico cru (sem formatação BR), só pra alimentar a animação
|
||||||
|
de contagem dos cards (`data-count` em `dashboard-contabil-relatorio.html`)
|
||||||
|
— o texto final exibido depois da animação continua sendo sempre o
|
||||||
|
resultado dos filtros acima (`moeda`/`percentual`/`indice`), nunca um
|
||||||
|
valor recalculado em JS; isso é só o ponto de partida da transição
|
||||||
|
visual, não uma segunda fonte de verdade pro número."""
|
||||||
|
return "" if valor is None else str(float(valor))
|
||||||
@ -7,7 +7,8 @@ import re
|
|||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from typing import Any, Iterable
|
from decimal import Decimal
|
||||||
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
import holidays
|
import holidays
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
@ -18,6 +19,7 @@ from django.contrib.auth import authenticate, login, logout
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.middleware.csrf import get_token
|
from django.middleware.csrf import get_token
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.template.loader import render_to_string
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.dateparse import parse_date
|
from django.utils.dateparse import parse_date
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
@ -32,6 +34,8 @@ from rest_framework.serializers import ModelSerializer
|
|||||||
from . import catalogo
|
from . import catalogo
|
||||||
from .custo_contratacao.calculo import calcula_custo_empregado
|
from .custo_contratacao.calculo import calcula_custo_empregado
|
||||||
from .custo_contratacao.pdf import gera_pdf_simulacao
|
from .custo_contratacao.pdf import gera_pdf_simulacao
|
||||||
|
from .dashboard_contabil import exportacao as dashboard_contabil_exportacao
|
||||||
|
from .dashboard_contabil import indicadores as dashboard_contabil_indicadores
|
||||||
from .dashboard_contabil import pipeline as dashboard_contabil_pipeline
|
from .dashboard_contabil import pipeline as dashboard_contabil_pipeline
|
||||||
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
|
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
|
||||||
from .dashboard_contabil.modelos import SnapshotHistorico as ContabilSnapshotHistorico
|
from .dashboard_contabil.modelos import SnapshotHistorico as ContabilSnapshotHistorico
|
||||||
@ -3299,6 +3303,50 @@ def _contabil_monta_historico(cabecalho: ContabilCabecalhoExtraido) -> list[Cont
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _contabil_monta_historico_completo(codigo_empresa: str, competencia_atual: date) -> list[ContabilSnapshotHistorico]:
|
||||||
|
"""Mesma ideia de `_contabil_monta_historico`, mas **sem** o `[:2]` —
|
||||||
|
histórico completo da empresa (mais recente primeiro), usado só pelo
|
||||||
|
relatório "Gerar Dashboard" (`ContabilApuracaoViewSet.dashboard`), pro
|
||||||
|
gráfico de evolução e pro cálculo de Depreciação/Amortização do mês
|
||||||
|
(`dashboard_contabil.indicadores`). As regras de auditoria continuam
|
||||||
|
usando `_contabil_monta_historico` (capado em 2), sem mudar."""
|
||||||
|
anteriores = (
|
||||||
|
ContabilApuracao.objects.filter(codigo_empresa=codigo_empresa, competencia__lt=competencia_atual)
|
||||||
|
.order_by("-competencia")
|
||||||
|
.prefetch_related("contas", "linhas_dre")
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
ContabilSnapshotHistorico(
|
||||||
|
competencia=apuracao.competencia,
|
||||||
|
contas={conta.codigo: conta.saldo_atual for conta in apuracao.contas.all()},
|
||||||
|
linhas_dre={linha.descricao: linha.valor for linha in apuracao.linhas_dre.all()},
|
||||||
|
)
|
||||||
|
for apuracao in anteriores
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _contabil_arvore_contexto(
|
||||||
|
itens: list[Any], chave: str, nivel_fn: Callable[[Any], int], px_por_nivel: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Monta o contexto de uma árvore recolhível (Balancete/DRE) pro
|
||||||
|
relatório "Gerar Dashboard" — mesmo algoritmo de `dcContaNivel()`/
|
||||||
|
`renderContas()`/`renderDre()` em `dashboard-contabil.js` ("tem filhos" =
|
||||||
|
o próximo item tem nível maior), mas calculado aqui no servidor porque o
|
||||||
|
relatório é HTML estático renderizado uma vez, não uma tela que
|
||||||
|
re-renderiza a cada clique — o JS do relatório só alterna `hidden` nas
|
||||||
|
linhas já prontas (ver `dashboard-contabil-relatorio.html`)."""
|
||||||
|
niveis = [nivel_fn(item) for item in itens]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
chave: item,
|
||||||
|
"nivel": niveis[i],
|
||||||
|
"nivel_px": niveis[i] * px_por_nivel,
|
||||||
|
"tem_filhos": i + 1 < len(itens) and niveis[i + 1] > niveis[i],
|
||||||
|
}
|
||||||
|
for i, item in enumerate(itens)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||||
"""Dashboard Contábil (Relatórios > Contabilidade) — permissão de toggle
|
"""Dashboard Contábil (Relatórios > Contabilidade) — permissão de toggle
|
||||||
único, mesmo espírito de ImportacaoPlanoSaudeViewSet/IndicadorApuracaoViewSet.
|
único, mesmo espírito de ImportacaoPlanoSaudeViewSet/IndicadorApuracaoViewSet.
|
||||||
@ -3440,6 +3488,111 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
|||||||
apuracao.save(update_fields=["status", "concluida_em"])
|
apuracao.save(update_fields=["status", "concluida_em"])
|
||||||
return Response(ContabilApuracaoDetailSerializer(apuracao).data)
|
return Response(ContabilApuracaoDetailSerializer(apuracao).data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"])
|
||||||
|
def dashboard(self, request: Request, pk: str | None = None) -> HttpResponse:
|
||||||
|
"""Gera o relatório "Gerar Dashboard": um documento HTML autocontido,
|
||||||
|
com a marca do escritório (não a "P.I.D." do Portal — ver "Logos" no
|
||||||
|
CLAUDE.md raiz), pensado pra abrir em nova aba/imprimir/enviar ao
|
||||||
|
administrador da empresa. Só leitura — diferente de `concluir`, não
|
||||||
|
muda `status`, pode ser chamado a qualquer momento. **GET, não POST**
|
||||||
|
(diferente do padrão `/gerar/` de outras ferramentas, que mutam
|
||||||
|
estado ou recebem multipart): o frontend abre a URL direto numa nova
|
||||||
|
aba (`window.open`), navegação de verdade, não um blob — precisava
|
||||||
|
ser GET pra isso funcionar (ver `dashboard-contabil.js`)."""
|
||||||
|
apuracao = self.get_object()
|
||||||
|
contas = list(apuracao.contas.order_by("ordem"))
|
||||||
|
linhas_dre = list(apuracao.linhas_dre.order_by("ordem"))
|
||||||
|
achados = list(apuracao.achados.select_related("conta", "tratado_por").order_by("severidade", "id"))
|
||||||
|
|
||||||
|
contas_atuais = {conta.codigo: conta.saldo_atual for conta in contas}
|
||||||
|
dre_atual = {linha.descricao: linha.valor for linha in linhas_dre}
|
||||||
|
resultado_liquido = linhas_dre[-1].valor if linhas_dre else Decimal(0)
|
||||||
|
|
||||||
|
historico_completo = _contabil_monta_historico_completo(apuracao.codigo_empresa, apuracao.competencia)
|
||||||
|
indicadores = dashboard_contabil_indicadores.calcula_indicadores(
|
||||||
|
contas_atuais, dre_atual, resultado_liquido, historico_completo
|
||||||
|
)
|
||||||
|
|
||||||
|
evolucao = [
|
||||||
|
{
|
||||||
|
"competencia": snapshot.competencia.strftime("%m/%Y"),
|
||||||
|
"resultado_liquido": next(reversed(snapshot.linhas_dre.values()), Decimal(0)),
|
||||||
|
}
|
||||||
|
for snapshot in reversed(historico_completo)
|
||||||
|
]
|
||||||
|
evolucao.append({"competencia": apuracao.competencia.strftime("%m/%Y"), "resultado_liquido": resultado_liquido})
|
||||||
|
|
||||||
|
contexto = {
|
||||||
|
"apuracao": apuracao,
|
||||||
|
"indicadores": indicadores,
|
||||||
|
"evolucao": evolucao,
|
||||||
|
"contas": _contabil_arvore_contexto(contas, "conta", lambda c: c.codigo.count("."), 18),
|
||||||
|
"linhas_dre": _contabil_arvore_contexto(linhas_dre, "linha", lambda l: max(0, l.nivel), 16),
|
||||||
|
"observacoes_contas": [conta for conta in contas if conta.observacao],
|
||||||
|
"observacoes_dre": [linha for linha in linhas_dre if linha.observacao],
|
||||||
|
"achados_com_observacao": [achado for achado in achados if achado.observacao_contador],
|
||||||
|
"gerado_em": timezone.now(),
|
||||||
|
}
|
||||||
|
html = render_to_string("dashboard-contabil-relatorio.html", contexto)
|
||||||
|
return HttpResponse(html, content_type="text/html; charset=utf-8")
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"], url_path="exportar-xlsx")
|
||||||
|
def exportar_xlsx(self, request: Request, pk: str | None = None) -> HttpResponse:
|
||||||
|
"""Exporta Balancete ou DRE em XLSX a partir do relatório "Gerar
|
||||||
|
Dashboard" (`?parte=balancete` ou `?parte=dre`) — mesma lógica de
|
||||||
|
árvore/indentação do relatório HTML, mas gerada com
|
||||||
|
`dashboard_contabil.exportacao` (openpyxl), sem tocar no ORM lá
|
||||||
|
dentro (view resolve os dados, a função só monta a planilha)."""
|
||||||
|
apuracao = self.get_object()
|
||||||
|
parte = request.query_params.get("parte")
|
||||||
|
if parte not in ("balancete", "dre"):
|
||||||
|
raise ValidationError({"parte": "Informe 'balancete' ou 'dre'."})
|
||||||
|
|
||||||
|
if parte == "balancete":
|
||||||
|
contas = list(apuracao.contas.order_by("ordem"))
|
||||||
|
conteudo = dashboard_contabil_exportacao.gera_xlsx_balancete(
|
||||||
|
apuracao.nome_empresa,
|
||||||
|
apuracao.cnpj,
|
||||||
|
apuracao.competencia,
|
||||||
|
[
|
||||||
|
dashboard_contabil_exportacao.LinhaBalanceteXlsx(
|
||||||
|
codigo=conta.codigo,
|
||||||
|
descricao=conta.descricao,
|
||||||
|
tipo=conta.tipo,
|
||||||
|
nivel=conta.codigo.count("."),
|
||||||
|
saldo_anterior=conta.saldo_anterior,
|
||||||
|
debito=conta.debito,
|
||||||
|
credito=conta.credito,
|
||||||
|
saldo_atual=conta.saldo_atual,
|
||||||
|
)
|
||||||
|
for conta in contas
|
||||||
|
],
|
||||||
|
)
|
||||||
|
nome_arquivo = f"balancete_{apuracao.codigo_empresa}_{apuracao.competencia:%m-%Y}.xlsx"
|
||||||
|
else:
|
||||||
|
linhas_dre = list(apuracao.linhas_dre.order_by("ordem"))
|
||||||
|
conteudo = dashboard_contabil_exportacao.gera_xlsx_dre(
|
||||||
|
apuracao.nome_empresa,
|
||||||
|
apuracao.cnpj,
|
||||||
|
apuracao.competencia,
|
||||||
|
[
|
||||||
|
dashboard_contabil_exportacao.LinhaDreXlsx(
|
||||||
|
descricao=linha.descricao,
|
||||||
|
nivel=max(0, linha.nivel),
|
||||||
|
totalizador=linha.totalizador,
|
||||||
|
valor=linha.valor,
|
||||||
|
)
|
||||||
|
for linha in linhas_dre
|
||||||
|
],
|
||||||
|
)
|
||||||
|
nome_arquivo = f"dre_{apuracao.codigo_empresa}_{apuracao.competencia:%m-%Y}.xlsx"
|
||||||
|
|
||||||
|
response = HttpResponse(
|
||||||
|
conteudo, content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
)
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{nome_arquivo}"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _contabil_garante_em_revisao(apuracao: ContabilApuracao) -> None:
|
def _contabil_garante_em_revisao(apuracao: ContabilApuracao) -> None:
|
||||||
if apuracao.status == ContabilApuracao.STATUS_CONCLUIDA:
|
if apuracao.status == ContabilApuracao.STATUS_CONCLUIDA:
|
||||||
|
|||||||
@ -138,6 +138,216 @@
|
|||||||
|
|
||||||
/* Achados */
|
/* Achados */
|
||||||
|
|
||||||
|
/* Resumo do topo da aba Observações — donut por severidade (SVG puro, sem
|
||||||
|
Chart.js/dependência nova nesta tela interativa, diferente do relatório
|
||||||
|
estático "Gerar Dashboard" que já usa Chart.js via CDN) + um card por
|
||||||
|
categoria/regra de auditoria com a lista de contas/linhas apontadas, ou
|
||||||
|
"Nenhum apontamento encontrado" quando a regra não gerou achado nesta
|
||||||
|
apuração. Donut e cards são clicáveis e filtram a lista detalhada logo
|
||||||
|
abaixo (ver renderAchadosResumo/pidDcSelecionarSeveridade em
|
||||||
|
dashboard-contabil.js). Inspirado numa tela de auditoria de outro sistema
|
||||||
|
que o usuário trouxe como referência visual. */
|
||||||
|
.dc-achados-resumo {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-5);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
padding-bottom: var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-achados-resumo__chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-donut {
|
||||||
|
position: relative;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Técnica de donut com pathLength="100" — cada segmento é um <circle> com
|
||||||
|
stroke-dasharray "pct (100-pct)" e stroke-dashoffset acumulado, contado a
|
||||||
|
partir de 25 (um quarto de volta) pra começar às 12h em vez de às 3h.
|
||||||
|
pointer-events do SVG só considera pixel realmente pintado (stroke), então
|
||||||
|
cada segmento é clicável só na própria fatia, sem precisar de hit-test
|
||||||
|
manual por ângulo. */
|
||||||
|
.dc-donut-svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-donut-svg__bg {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--bg-surface-raised);
|
||||||
|
stroke-width: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-donut-svg__seg {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 4;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: stroke-width var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-donut-svg__seg:hover,
|
||||||
|
.dc-donut-svg__seg.is-active {
|
||||||
|
stroke-width: 5.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-donut__hole {
|
||||||
|
position: absolute;
|
||||||
|
inset: 20%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-donut__total {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-donut__label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-donut-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-donut-legend__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
margin: 0 calc(-1 * var(--space-2));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-donut-legend__item:hover {
|
||||||
|
background: var(--bg-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-donut-legend__item.is-active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-donut-legend__dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-achados-categorias {
|
||||||
|
flex: 1 1 480px;
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-categoria-card {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-categoria-card__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--dc-categoria-cor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-categoria-card__titulo {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-categoria-card__contagem {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1px var(--space-2);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--bg-surface-raised);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
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%;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-categoria-card__table td {
|
||||||
|
padding: var(--space-1) 0;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-categoria-card__table tr:first-child td {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-categoria-card__table td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-left: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.dc-achados-toolbar {
|
.dc-achados-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -147,6 +357,38 @@
|
|||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Aparece só quando um card de categoria está ativo (filtroRegra != null,
|
||||||
|
ver renderAchados) — os chips de severidade/status já têm seu próprio
|
||||||
|
estado visual (.is-active), mas o filtro por categoria não tem um chip
|
||||||
|
equivalente, então esta faixa avisa qual categoria está isolada e oferece
|
||||||
|
uma forma explícita de limpar (além de clicar de novo no mesmo card). */
|
||||||
|
.dc-regra-filtro-ativo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-surface-raised);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-regra-filtro-limpar {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-regra-filtro-limpar:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.dc-chip-row {
|
.dc-chip-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -239,6 +481,58 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Expande/recolhe a conta usada no apontamento (achado.conta) — só
|
||||||
|
presente em achados vinculados a uma conta específica; regras gerais
|
||||||
|
(ex. balanceamento Ativo x Passivo) não têm esse toggle. */
|
||||||
|
.dc-achado-card__toggle-conta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-achado-card__toggle-conta:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-achado-card__toggle-conta svg {
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-achado-card__toggle-conta.is-expanded svg {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-achado-card__conta {
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--bg-surface-raised);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: var(--space-2) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-achado-card__conta dt {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dc-achado-card__conta dd {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tabelas de Balancete/DRE — reaproveitam .pa-table, só ajustes de alinhamento
|
/* Tabelas de Balancete/DRE — reaproveitam .pa-table, só ajustes de alinhamento
|
||||||
numérico e da célula de observação (que embute um botão). */
|
numérico e da célula de observação (que embute um botão). */
|
||||||
|
|
||||||
@ -274,7 +568,8 @@
|
|||||||
/* Recolhe/expande os filhos de uma conta sintética (árvore construída a
|
/* Recolhe/expande os filhos de uma conta sintética (árvore construída a
|
||||||
partir do nível de indentação, ver dcContaNivel em dashboard-contabil.js)
|
partir do nível de indentação, ver dcContaNivel em dashboard-contabil.js)
|
||||||
— mesmo espírito de uma matriz do Power BI, pedido explícito do usuário
|
— mesmo espírito de uma matriz do Power BI, pedido explícito do usuário
|
||||||
pra facilitar a conferência de um balancete com muitas contas. */
|
pra facilitar a conferência de um balancete com muitas contas. Reaproveitado
|
||||||
|
também pela árvore da DRE (renderDre), sobre ContabilLinhaDre.nivel. */
|
||||||
.dc-conta-toggle {
|
.dc-conta-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -2,6 +2,44 @@ 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_SEVERIDADE_LABELS = { alta: "Alta", media: "Média", baixa: "Baixa" };
|
||||||
const PID_DC_ACHADO_STATUS_LABELS = { pendente: "Pendente", tratado: "Tratado", ignorado: "Ignorado" };
|
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) {
|
function pidDcFormatMoeda(valor) {
|
||||||
const numero = Number(valor || 0);
|
const numero = Number(valor || 0);
|
||||||
return numero.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
|
return numero.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
|
||||||
@ -44,6 +82,20 @@ async function pidConcluirApuracaoContabil(id) {
|
|||||||
return pidApiRequest(`/contabil-apuracoes/${id}/concluir/`, { method: "POST" });
|
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) {
|
async function pidAtualizarObservacaoContaContabil(id, observacao) {
|
||||||
return pidApiRequest(`/contabil-contas/${id}/`, { method: "PATCH", body: { observacao } });
|
return pidApiRequest(`/contabil-contas/${id}/`, { method: "PATCH", body: { observacao } });
|
||||||
}
|
}
|
||||||
@ -81,10 +133,23 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
let apuracaoAtual = null;
|
let apuracaoAtual = null;
|
||||||
let filtroSeveridade = "todas";
|
let filtroSeveridade = "todas";
|
||||||
let filtroStatusAchado = "pendente";
|
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;
|
let achadoEmEdicao = null;
|
||||||
// Ids de ContabilConta com filhos recolhidos na árvore do Balancete —
|
// Ids de ContabilConta com filhos recolhidos na árvore do Balancete —
|
||||||
// reiniciado toda vez que uma apuração é aberta/criada (ver renderRevisao).
|
// reiniciado toda vez que uma apuração é aberta/criada (ver renderRevisao).
|
||||||
let dcContasColapsadas = new Set();
|
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) {
|
function showView(view) {
|
||||||
listView.hidden = view !== "list";
|
listView.hidden = view !== "list";
|
||||||
@ -189,6 +254,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
apuracaoAtual = apuracao;
|
apuracaoAtual = apuracao;
|
||||||
filtroSeveridade = "todas";
|
filtroSeveridade = "todas";
|
||||||
filtroStatusAchado = "pendente";
|
filtroStatusAchado = "pendente";
|
||||||
|
filtroRegra = null;
|
||||||
renderRevisao();
|
renderRevisao();
|
||||||
showView("review");
|
showView("review");
|
||||||
await carregarLista();
|
await carregarLista();
|
||||||
@ -205,6 +271,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
apuracaoAtual = await pidFetchApuracaoContabil(id);
|
apuracaoAtual = await pidFetchApuracaoContabil(id);
|
||||||
filtroSeveridade = "todas";
|
filtroSeveridade = "todas";
|
||||||
filtroStatusAchado = "pendente";
|
filtroStatusAchado = "pendente";
|
||||||
|
filtroRegra = null;
|
||||||
renderRevisao();
|
renderRevisao();
|
||||||
showView("review");
|
showView("review");
|
||||||
}
|
}
|
||||||
@ -220,20 +287,151 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
document.getElementById("dc-concluir-btn").hidden = concluida;
|
document.getElementById("dc-concluir-btn").hidden = concluida;
|
||||||
|
|
||||||
dcContasColapsadas = new Set();
|
dcContasColapsadas = new Set();
|
||||||
|
dcDreColapsadas = new Set();
|
||||||
|
dcAchadosContaExpandida = new Set();
|
||||||
renderAchados();
|
renderAchados();
|
||||||
renderContas();
|
renderContas();
|
||||||
renderDre();
|
renderDre();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PID_DC_SEVERIDADE_ORDEM = { alta: 0, media: 1, baixa: 2 };
|
||||||
|
|
||||||
function achadosFiltrados() {
|
function achadosFiltrados() {
|
||||||
return apuracaoAtual.achados.filter((achado) => {
|
return apuracaoAtual.achados
|
||||||
if (filtroSeveridade !== "todas" && achado.severidade !== filtroSeveridade) return false;
|
.filter((achado) => {
|
||||||
if (filtroStatusAchado === "pendente" && achado.status !== "pendente") return false;
|
if (filtroSeveridade !== "todas" && achado.severidade !== filtroSeveridade) return false;
|
||||||
return true;
|
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() {
|
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 lista = document.getElementById("dc-achados-list");
|
||||||
const empty = document.getElementById("dc-achados-empty");
|
const empty = document.getElementById("dc-achados-empty");
|
||||||
const achados = achadosFiltrados();
|
const achados = achadosFiltrados();
|
||||||
@ -244,6 +442,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = `dc-achado-card dc-achado-card--${achado.severidade}`;
|
card.className = `dc-achado-card dc-achado-card--${achado.severidade}`;
|
||||||
const podeEditar = apuracaoAtual.status !== "concluida" && achado.status === "pendente";
|
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 = `
|
card.innerHTML = `
|
||||||
<div class="dc-achado-card__main">
|
<div class="dc-achado-card__main">
|
||||||
<div class="dc-achado-card__titulo">
|
<div class="dc-achado-card__titulo">
|
||||||
@ -252,6 +452,21 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
${pidDcEscapeHtml(achado.titulo)}
|
${pidDcEscapeHtml(achado.titulo)}
|
||||||
</div>
|
</div>
|
||||||
<div class="dc-achado-card__mensagem">${pidDcEscapeHtml(achado.mensagem)}</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>` : ""}
|
${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>
|
||||||
<div class="dc-achado-card__actions">
|
<div class="dc-achado-card__actions">
|
||||||
@ -263,17 +478,32 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("dc-achados-list").addEventListener("click", (event) => {
|
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]");
|
const btn = event.target.closest("[data-dc-achado-editar]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
abrirModalAchado(Number(btn.getAttribute("data-dc-achado-editar")));
|
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) => {
|
document.querySelectorAll("#dc-achados-severidade-chips [data-dc-severidade]").forEach((chip) => {
|
||||||
chip.addEventListener("click", () => {
|
chip.addEventListener("click", () => pidDcSelecionarSeveridade(chip.getAttribute("data-dc-severidade")));
|
||||||
filtroSeveridade = chip.getAttribute("data-dc-severidade");
|
|
||||||
document.querySelectorAll("#dc-achados-severidade-chips .dc-chip").forEach((c) => c.classList.toggle("is-active", c === chip));
|
|
||||||
renderAchados();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll("#dc-achados-status-chips [data-dc-status]").forEach((chip) => {
|
document.querySelectorAll("#dc-achados-status-chips [data-dc-status]").forEach((chip) => {
|
||||||
@ -284,6 +514,11 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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
|
// 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
|
// 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
|
// analíticas, ver portal_api/dashboard_contabil/CLAUDE.md) — o nível de
|
||||||
@ -360,15 +595,43 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
abrirModalObservacao("conta", Number(btn.getAttribute("data-dc-conta-observacao")));
|
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() {
|
function renderDre() {
|
||||||
const body = document.getElementById("dc-dre-body");
|
const body = document.getElementById("dc-dre-body");
|
||||||
body.innerHTML = "";
|
body.innerHTML = "";
|
||||||
apuracaoAtual.linhas_dre.forEach((linha) => {
|
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");
|
const tr = document.createElement("tr");
|
||||||
tr.className = linha.totalizador ? "dc-dre-row dc-dre-row--totalizador" : "dc-dre-row";
|
tr.className = linha.totalizador ? "dc-dre-row dc-dre-row--totalizador" : "dc-dre-row";
|
||||||
const indentacao = Math.max(0, linha.nivel) * 16;
|
const indentacao = nivel * 16;
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td style="padding-left: calc(var(--space-4) + ${indentacao}px)">${pidDcEscapeHtml(linha.descricao)}</td>
|
<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>${pidDcFormatMoeda(linha.valor)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="button" class="dc-conta-observacao-btn" data-dc-dre-observacao="${linha.id}" title="${pidDcEscapeHtml(linha.observacao || "Adicionar observação")}">
|
<button type="button" class="dc-conta-observacao-btn" data-dc-dre-observacao="${linha.id}" title="${pidDcEscapeHtml(linha.observacao || "Adicionar observação")}">
|
||||||
@ -381,6 +644,14 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("dc-dre-body").addEventListener("click", (event) => {
|
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]");
|
const btn = event.target.closest("[data-dc-dre-observacao]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
abrirModalObservacao("dre", Number(btn.getAttribute("data-dc-dre-observacao")));
|
abrirModalObservacao("dre", Number(btn.getAttribute("data-dc-dre-observacao")));
|
||||||
@ -512,6 +783,12 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
|
|
||||||
document.getElementById("dc-review-back-btn").addEventListener("click", () => showView("list"));
|
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();
|
await carregarLista();
|
||||||
showView("list");
|
showView("list");
|
||||||
});
|
});
|
||||||
|
|||||||
897
templates/dashboard-contabil-relatorio.html
Normal file
897
templates/dashboard-contabil-relatorio.html
Normal file
@ -0,0 +1,897 @@
|
|||||||
|
{% load static %}
|
||||||
|
{% load contabil_extras %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-br">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Dashboard Contábil — {{ apuracao.nome_empresa }} — {{ apuracao.competencia|competencia }}</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--roxo-escuro: #3d2178;
|
||||||
|
--roxo-mais-escuro: #281552;
|
||||||
|
--roxo-claro: #efe8ff;
|
||||||
|
--roxo-medio: #7a5bc0;
|
||||||
|
--dourado: #b4872a;
|
||||||
|
--dourado-escuro: #8f6a1f;
|
||||||
|
--dourado-claro: #f6ecd4;
|
||||||
|
--dourado-vivo: #d9ac48;
|
||||||
|
--texto: #211d2b;
|
||||||
|
--texto-muted: #6b6478;
|
||||||
|
--borda: #e6e1f0;
|
||||||
|
--bg-canvas: #f5f3fa;
|
||||||
|
--sucesso-bg: #dcf5e3;
|
||||||
|
--sucesso-texto: #1a7a3d;
|
||||||
|
--falha-bg: #fbe2e2;
|
||||||
|
--falha-texto: #b3261e;
|
||||||
|
--aviso-bg: #fdf0d8;
|
||||||
|
--aviso-texto: #9a6a0a;
|
||||||
|
--sombra: 0 1px 2px rgba(40, 21, 82, 0.04), 0 8px 24px -12px rgba(40, 21, 82, 0.18);
|
||||||
|
--sombra-hover: 0 4px 10px rgba(40, 21, 82, 0.06), 0 16px 36px -14px rgba(40, 21, 82, 0.26);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 15% -10%, rgba(122, 91, 192, 0.14), transparent 42%),
|
||||||
|
radial-gradient(circle at 100% 0%, rgba(180, 135, 42, 0.10), transparent 38%),
|
||||||
|
var(--bg-canvas);
|
||||||
|
color: var(--texto);
|
||||||
|
font-family: "Inter", "Segoe UI", Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
h1, h2, .dcr-card__value, .dcr-tab { font-family: "Manrope", "Inter", sans-serif; }
|
||||||
|
|
||||||
|
/* ---- Animações de entrada — nunca fixar opacity:0 fora de @keyframes,
|
||||||
|
só via `animation ... both`, pra que @media print (que zera toda
|
||||||
|
`animation`) volte sozinho ao estado normal, sem precisar de reset
|
||||||
|
extra. Mesmo espírito do "animation, não transition, pra reiniciar a
|
||||||
|
partir de display:none" já documentado no Portal (CLAUDE.md,
|
||||||
|
"Animações"), embora este documento seja autocontido. */
|
||||||
|
@keyframes dcrFadeSlideUp {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes dcrFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes dcrScaleIn {
|
||||||
|
from { opacity: 0; transform: scale(0.96); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes dcrGlow {
|
||||||
|
0%, 100% { opacity: 0.55; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcr-page { max-width: 1140px; margin: 0 auto; padding: 0 24px 56px; }
|
||||||
|
|
||||||
|
/* ---- Cabeçalho ---- */
|
||||||
|
.dcr-header {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, var(--roxo-escuro) 0%, var(--roxo-mais-escuro) 100%);
|
||||||
|
color: #fff;
|
||||||
|
padding: 30px 28px;
|
||||||
|
margin: 0 -24px 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 22px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-bottom: 3px solid var(--dourado);
|
||||||
|
animation: dcrFadeIn 500ms ease both;
|
||||||
|
}
|
||||||
|
.dcr-header::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(circle at 85% -20%, rgba(217, 172, 72, 0.35), transparent 55%);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: dcrGlow 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.dcr-header__brand { display: flex; align-items: center; gap: 18px; position: relative; z-index: 1; }
|
||||||
|
.dcr-header img { height: 52px; width: auto; filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.25)); }
|
||||||
|
.dcr-header h1 { margin: 0; font-size: 1.4rem; font-weight: 800; letter-spacing: -0.01em; }
|
||||||
|
.dcr-header p { margin: 4px 0 0; color: var(--dourado-claro); font-size: 0.86rem; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.dcr-status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
.dcr-status-pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
.dcr-status-pill--concluida { color: #7CE7A0; }
|
||||||
|
.dcr-status-pill--revisao { color: #F5D583; }
|
||||||
|
.dcr-print-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: linear-gradient(135deg, var(--dourado-vivo), var(--dourado));
|
||||||
|
color: var(--roxo-mais-escuro);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 11px 20px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
box-shadow: 0 6px 18px -6px rgba(217, 172, 72, 0.7);
|
||||||
|
transition: transform 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
.dcr-print-btn:hover { transform: translateY(-2px); box-shadow: 0 10px 24px -6px rgba(217, 172, 72, 0.85); }
|
||||||
|
.dcr-print-btn:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
/* Botão de exportar XLSX — dentro do próprio <h2> da seção (Balancete/DRE),
|
||||||
|
que já é flex, então margin-left:auto sozinho empurra pro fim da linha
|
||||||
|
(mesmo truque do .dcr-print-btn dentro do header). Mais discreto que o
|
||||||
|
botão de imprimir (outline, não preenchido) por ser uma ação
|
||||||
|
secundária por seção, não a ação principal do documento. */
|
||||||
|
.dcr-export-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1.5px solid var(--dourado);
|
||||||
|
color: var(--roxo-escuro);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
.dcr-export-btn:hover { background: var(--dourado-claro); transform: translateY(-1px); }
|
||||||
|
.dcr-export-btn:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
/* ---- Abas ---- */
|
||||||
|
.dcr-tabs {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 2px solid var(--borda);
|
||||||
|
margin-bottom: 26px;
|
||||||
|
animation: dcrFadeSlideUp 460ms ease 80ms both;
|
||||||
|
}
|
||||||
|
.dcr-tab {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--texto-muted);
|
||||||
|
transition: color 160ms ease;
|
||||||
|
}
|
||||||
|
.dcr-tab:hover { color: var(--roxo-escuro); }
|
||||||
|
.dcr-tab.is-active { color: var(--roxo-escuro); }
|
||||||
|
.dcr-tabs__indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
left: 0;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
background: linear-gradient(90deg, var(--dourado), var(--dourado-vivo));
|
||||||
|
transition: transform 280ms cubic-bezier(0.65, 0, 0.35, 1), width 280ms cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcr-tab-panel { animation: dcrFadeSlideUp 420ms ease 140ms both; }
|
||||||
|
|
||||||
|
.dcr-secao { margin-bottom: 34px; }
|
||||||
|
.dcr-secao h2 {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--roxo-escuro);
|
||||||
|
border-bottom: 2px solid var(--borda);
|
||||||
|
padding-bottom: 9px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.dcr-secao h2 svg { color: var(--dourado); flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ---- Cards de indicador ---- */
|
||||||
|
.dcr-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.dcr-card {
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
box-shadow: var(--sombra);
|
||||||
|
transition: transform 200ms ease, box-shadow 200ms ease, border-color 200ms ease;
|
||||||
|
animation: dcrFadeSlideUp 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
animation-delay: calc(var(--i, 0) * 55ms + 60ms);
|
||||||
|
}
|
||||||
|
.dcr-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--sombra-hover);
|
||||||
|
border-color: rgba(122, 91, 192, 0.35);
|
||||||
|
}
|
||||||
|
.dcr-card__icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: var(--roxo-claro);
|
||||||
|
color: var(--roxo-escuro);
|
||||||
|
}
|
||||||
|
.dcr-card--dourado .dcr-card__icon { background: var(--dourado-claro); color: var(--dourado-escuro); }
|
||||||
|
.dcr-card__label { font-size: 0.74rem; color: var(--texto-muted); text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600; }
|
||||||
|
.dcr-card__value {
|
||||||
|
font-size: 1.55rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--roxo-escuro);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.dcr-card__value.dcr-valor-positivo { color: var(--sucesso-texto); }
|
||||||
|
.dcr-card__value.dcr-valor-negativo { color: var(--falha-texto); }
|
||||||
|
.dcr-card__nota { font-size: 0.7rem; color: var(--texto-muted); margin-top: 6px; line-height: 1.35; }
|
||||||
|
|
||||||
|
/* ---- Gráfico ---- */
|
||||||
|
.dcr-chart-wrap {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: var(--sombra);
|
||||||
|
animation: dcrFadeSlideUp 460ms ease 120ms both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tabelas ---- */
|
||||||
|
.dcr-tabela-wrap {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--sombra);
|
||||||
|
animation: dcrScaleIn 380ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
table.dcr-tabela { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
|
table.dcr-tabela th, table.dcr-tabela td { padding: 9px 14px; border-bottom: 1px solid var(--borda); text-align: left; }
|
||||||
|
table.dcr-tabela th {
|
||||||
|
background: linear-gradient(180deg, var(--roxo-claro), #f4f0ff);
|
||||||
|
color: var(--roxo-escuro);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
table.dcr-tabela td.dcr-valor { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
|
table.dcr-tabela tbody tr { transition: background 120ms ease; }
|
||||||
|
table.dcr-tabela tbody tr:not(.dcr-linha-total):hover { background: #faf9ff; }
|
||||||
|
table.dcr-tabela tbody tr:nth-child(even):not(.dcr-linha-total) { background: rgba(122, 91, 192, 0.025); }
|
||||||
|
tr.dcr-linha-total td { font-weight: 700; background: var(--dourado-claro); }
|
||||||
|
.dcr-row-in { animation: dcrFadeIn 220ms ease; }
|
||||||
|
|
||||||
|
.dcr-desc-cell { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.dcr-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--texto-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.dcr-toggle:hover { color: var(--roxo-escuro); background: var(--roxo-claro); }
|
||||||
|
.dcr-toggle svg { transition: transform 160ms cubic-bezier(0.65, 0, 0.35, 1); }
|
||||||
|
.dcr-toggle.is-expanded svg { transform: rotate(90deg); }
|
||||||
|
.dcr-toggle-spacer { display: inline-block; width: 18px; height: 18px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ---- Observações ---- */
|
||||||
|
.dcr-obs-lista { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
||||||
|
.dcr-obs-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
box-shadow: var(--sombra);
|
||||||
|
animation: dcrFadeSlideUp 380ms ease both;
|
||||||
|
animation-delay: calc(var(--i, 0) * 45ms);
|
||||||
|
transition: box-shadow 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
.dcr-obs-item:hover { transform: translateX(2px); box-shadow: var(--sombra-hover); }
|
||||||
|
.dcr-obs-item__icone {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--dourado-claro);
|
||||||
|
color: var(--dourado-escuro);
|
||||||
|
}
|
||||||
|
.dcr-obs-item__titulo { font-weight: 700; font-size: 0.85rem; color: var(--roxo-escuro); }
|
||||||
|
.dcr-obs-item__texto { font-size: 0.85rem; margin-top: 3px; white-space: pre-wrap; color: var(--texto); }
|
||||||
|
.dcr-vazio { color: var(--texto-muted); font-size: 0.85rem; font-style: italic; }
|
||||||
|
.dcr-footer { color: var(--texto-muted); font-size: 0.76rem; text-align: center; margin-top: 28px; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.dcr-header { padding: 22px 20px; }
|
||||||
|
.dcr-tabs { overflow-x: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
* { animation: none !important; transition: none !important; }
|
||||||
|
body { background: #fff; }
|
||||||
|
.dcr-print-btn { display: none; }
|
||||||
|
.dcr-export-btn { display: none; }
|
||||||
|
.dcr-header { margin: 0 0 24px; }
|
||||||
|
.dcr-tabs { display: none; }
|
||||||
|
.dcr-tab-panel { display: block !important; }
|
||||||
|
.dcr-tab-panel:not(:last-child) { page-break-after: always; }
|
||||||
|
.dcr-tabela tbody tr[hidden] { display: table-row !important; }
|
||||||
|
.dcr-toggle { display: none; }
|
||||||
|
.dcr-card, .dcr-tabela-wrap, .dcr-obs-item { box-shadow: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="dcr-page">
|
||||||
|
<header class="dcr-header">
|
||||||
|
<div class="dcr-header__brand">
|
||||||
|
<img src="{% static 'img/logo-branco.png' %}" alt="De Paula Contadores">
|
||||||
|
<div>
|
||||||
|
<h1>Dashboard Contábil — {{ apuracao.nome_empresa }}</h1>
|
||||||
|
<p>
|
||||||
|
CNPJ {{ apuracao.cnpj|default:"não informado" }} · Competência {{ apuracao.competencia|competencia }}
|
||||||
|
<span class="dcr-status-pill dcr-status-pill--{{ apuracao.status }}">{{ apuracao.get_status_display }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="dcr-print-btn" onclick="window.print()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
|
||||||
|
Imprimir
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="dcr-tabs">
|
||||||
|
<span class="dcr-tabs__indicator" id="dcr-tabs-indicator"></span>
|
||||||
|
<button type="button" class="dcr-tab is-active" data-dcr-tab="balancete">Balancete</button>
|
||||||
|
<button type="button" class="dcr-tab" data-dcr-tab="dre">D.R.E.</button>
|
||||||
|
<button type="button" class="dcr-tab" data-dcr-tab="indicadores">Indicadores</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="dcr-tab-panel" data-dcr-panel="balancete">
|
||||||
|
<div class="dcr-secao">
|
||||||
|
<h2>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
|
||||||
|
Balancete
|
||||||
|
<a class="dcr-export-btn" href="/api/contabil-apuracoes/{{ apuracao.id }}/exportar-xlsx/?parte=balancete">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
|
Exportar XLSX
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<div class="dcr-tabela-wrap">
|
||||||
|
<table class="dcr-tabela">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Conta</th>
|
||||||
|
<th>Descrição</th>
|
||||||
|
<th class="dcr-valor">Saldo Anterior</th>
|
||||||
|
<th class="dcr-valor">Débito</th>
|
||||||
|
<th class="dcr-valor">Crédito</th>
|
||||||
|
<th class="dcr-valor">Saldo Atual</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="dcr-balancete-body">
|
||||||
|
{% for item in contas %}
|
||||||
|
<tr{% if item.conta.tipo == "S" %} class="dcr-linha-total"{% endif %} data-dcr-nivel="{{ item.nivel }}" data-dcr-id="conta-{{ item.conta.id }}" data-dcr-tem-filhos="{% if item.tem_filhos %}1{% else %}0{% endif %}">
|
||||||
|
<td>{{ item.conta.codigo }}</td>
|
||||||
|
<td style="padding-left: {{ item.nivel_px }}px">
|
||||||
|
<span class="dcr-desc-cell">
|
||||||
|
{% if item.tem_filhos %}
|
||||||
|
<button type="button" class="dcr-toggle is-expanded" data-dcr-toggle aria-label="Recolher/expandir">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="dcr-toggle-spacer"></span>
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ item.conta.descricao }}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="dcr-valor">{{ item.conta.saldo_anterior|moeda }}</td>
|
||||||
|
<td class="dcr-valor">{{ item.conta.debito|moeda }}</td>
|
||||||
|
<td class="dcr-valor">{{ item.conta.credito|moeda }}</td>
|
||||||
|
<td class="dcr-valor">{{ item.conta.saldo_atual|moeda }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dcr-secao">
|
||||||
|
<h2>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>
|
||||||
|
Observações do Balancete
|
||||||
|
</h2>
|
||||||
|
{% if observacoes_contas %}
|
||||||
|
<ul class="dcr-obs-lista">
|
||||||
|
{% for conta in observacoes_contas %}
|
||||||
|
<li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}">
|
||||||
|
<div class="dcr-obs-item__icone"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg></div>
|
||||||
|
<div>
|
||||||
|
<div class="dcr-obs-item__titulo">{{ conta.codigo }} — {{ conta.descricao }}</div>
|
||||||
|
<div class="dcr-obs-item__texto">{{ conta.observacao }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="dcr-vazio">Nenhuma observação registrada no Balancete.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dcr-tab-panel" data-dcr-panel="dre" hidden>
|
||||||
|
<div class="dcr-secao">
|
||||||
|
<h2>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V5a2 2 0 0 1 2-2h8l6 6v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/><path d="M14 3v6h6M9 13h6M9 17h6"/></svg>
|
||||||
|
D.R.E. — Demonstração do Resultado do Exercício
|
||||||
|
<a class="dcr-export-btn" href="/api/contabil-apuracoes/{{ apuracao.id }}/exportar-xlsx/?parte=dre">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
|
Exportar XLSX
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<div class="dcr-tabela-wrap">
|
||||||
|
<table class="dcr-tabela">
|
||||||
|
<thead><tr><th>Descrição</th><th class="dcr-valor">Valor</th></tr></thead>
|
||||||
|
<tbody id="dcr-dre-body">
|
||||||
|
{% for item in linhas_dre %}
|
||||||
|
<tr{% if item.linha.totalizador %} class="dcr-linha-total"{% endif %} data-dcr-nivel="{{ item.nivel }}" data-dcr-id="linha-{{ item.linha.id }}" data-dcr-tem-filhos="{% if item.tem_filhos %}1{% else %}0{% endif %}">
|
||||||
|
<td style="padding-left: {{ item.nivel_px|add:12 }}px">
|
||||||
|
<span class="dcr-desc-cell">
|
||||||
|
{% if item.tem_filhos %}
|
||||||
|
<button type="button" class="dcr-toggle is-expanded" data-dcr-toggle aria-label="Recolher/expandir">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="dcr-toggle-spacer"></span>
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ item.linha.descricao }}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="dcr-valor">{{ item.linha.valor|moeda }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dcr-secao">
|
||||||
|
<h2>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>
|
||||||
|
Observações da D.R.E.
|
||||||
|
</h2>
|
||||||
|
{% if observacoes_dre %}
|
||||||
|
<ul class="dcr-obs-lista">
|
||||||
|
{% for linha in observacoes_dre %}
|
||||||
|
<li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}">
|
||||||
|
<div class="dcr-obs-item__icone"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V5a2 2 0 0 1 2-2h8l6 6v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/><path d="M14 3v6h6"/></svg></div>
|
||||||
|
<div>
|
||||||
|
<div class="dcr-obs-item__titulo">{{ linha.descricao }}</div>
|
||||||
|
<div class="dcr-obs-item__texto">{{ linha.observacao }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="dcr-vazio">Nenhuma observação registrada na D.R.E.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dcr-tab-panel" data-dcr-panel="indicadores" hidden>
|
||||||
|
<div class="dcr-secao">
|
||||||
|
<h2>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>
|
||||||
|
Indicadores de Resultado
|
||||||
|
</h2>
|
||||||
|
<div class="dcr-cards">
|
||||||
|
<div class="dcr-card" style="--i:0">
|
||||||
|
<div class="dcr-card__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><rect x="7" y="12" width="3" height="6"/><rect x="12" y="8" width="3" height="10"/><rect x="17" y="5" width="3" height="13"/></svg></div>
|
||||||
|
<div class="dcr-card__label">ROA</div>
|
||||||
|
<div class="dcr-card__value" data-final="{{ indicadores.roa|percentual }}" data-count="{{ indicadores.roa|numero_bruto }}" data-format="pct" data-color-rule="sign">{{ indicadores.roa|percentual }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="dcr-card" style="--i:1">
|
||||||
|
<div class="dcr-card__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg></div>
|
||||||
|
<div class="dcr-card__label">ROE</div>
|
||||||
|
<div class="dcr-card__value" data-final="{{ indicadores.roe|percentual }}" data-count="{{ indicadores.roe|numero_bruto }}" data-format="pct" data-color-rule="sign">{{ indicadores.roe|percentual }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="dcr-card" style="--i:2">
|
||||||
|
<div class="dcr-card__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 12 20 4"/><path d="M12 2v4"/></svg></div>
|
||||||
|
<div class="dcr-card__label">Kanitz</div>
|
||||||
|
<div class="dcr-card__value" data-final="{{ indicadores.kanitz|indice }}" data-count="{{ indicadores.kanitz|numero_bruto }}" data-format="idx">{{ indicadores.kanitz|indice }}</div>
|
||||||
|
<div class="dcr-card__nota">Estimativa — fórmula padrão, não calibrada</div>
|
||||||
|
</div>
|
||||||
|
<div class="dcr-card dcr-card--dourado" style="--i:3">
|
||||||
|
<div class="dcr-card__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4M12 16.5v.01"/></svg></div>
|
||||||
|
<div class="dcr-card__label">EBIT</div>
|
||||||
|
<div class="dcr-card__value" data-final="{{ indicadores.ebit|moeda }}" data-count="{{ indicadores.ebit|numero_bruto }}" data-format="moeda" data-color-rule="sign">{{ indicadores.ebit|moeda }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="dcr-card dcr-card--dourado" style="--i:4">
|
||||||
|
<div class="dcr-card__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5"/><path d="M4 11v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/></svg></div>
|
||||||
|
<div class="dcr-card__label">EBITDA</div>
|
||||||
|
<div class="dcr-card__value" data-final="{{ indicadores.ebitda|moeda }}" data-count="{{ indicadores.ebitda|numero_bruto }}" data-format="moeda" data-color-rule="sign">{{ indicadores.ebitda|moeda }}</div>
|
||||||
|
{% if indicadores.ebitda is None %}<div class="dcr-card__nota">Sem apuração anterior desta empresa</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11Z"/></svg>
|
||||||
|
Indicadores de Liquidez e Endividamento
|
||||||
|
</h2>
|
||||||
|
<div class="dcr-cards">
|
||||||
|
<div class="dcr-card" style="--i:5">
|
||||||
|
<div class="dcr-card__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg></div>
|
||||||
|
<div class="dcr-card__label">Liquidez Corrente</div>
|
||||||
|
<div class="dcr-card__value" data-final="{{ indicadores.liquidez_corrente|indice }}" data-count="{{ indicadores.liquidez_corrente|numero_bruto }}" data-format="idx" data-color-rule="liquidez">{{ indicadores.liquidez_corrente|indice }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="dcr-card" style="--i:6">
|
||||||
|
<div class="dcr-card__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11Z"/><path d="M9 13a3 3 0 0 0 3 3"/></svg></div>
|
||||||
|
<div class="dcr-card__label">Liquidez Seca</div>
|
||||||
|
<div class="dcr-card__value" data-final="{{ indicadores.liquidez_seca|indice }}" data-count="{{ indicadores.liquidez_seca|numero_bruto }}" data-format="idx" data-color-rule="liquidez">{{ indicadores.liquidez_seca|indice }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="dcr-card" style="--i:7">
|
||||||
|
<div class="dcr-card__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg></div>
|
||||||
|
<div class="dcr-card__label">Liquidez Geral</div>
|
||||||
|
<div class="dcr-card__value" data-final="{{ indicadores.liquidez_geral|indice }}" data-count="{{ indicadores.liquidez_geral|numero_bruto }}" data-format="idx" data-color-rule="liquidez">{{ indicadores.liquidez_geral|indice }}</div>
|
||||||
|
<div class="dcr-card__nota">Aproximado — ver observação no relatório técnico</div>
|
||||||
|
</div>
|
||||||
|
<div class="dcr-card dcr-card--dourado" style="--i:8">
|
||||||
|
<div class="dcr-card__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v3"/><path d="M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M3 12h18"/></svg></div>
|
||||||
|
<div class="dcr-card__label">Composição do Endividamento</div>
|
||||||
|
<div class="dcr-card__value" data-final="{{ indicadores.composicao_endividamento|percentual }}" data-count="{{ indicadores.composicao_endividamento|numero_bruto }}" data-format="pct">{{ indicadores.composicao_endividamento|percentual }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="dcr-card dcr-card--dourado" style="--i:9">
|
||||||
|
<div class="dcr-card__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v.5"/><path d="M14 9V5a2 2 0 0 1 2-2h0a2 2 0 0 1 2 2v4"/><path d="M6 9h12l1 11H5Z"/></svg></div>
|
||||||
|
<div class="dcr-card__label">Grau de Endividamento</div>
|
||||||
|
<div class="dcr-card__value" data-final="{{ indicadores.grau_endividamento|percentual }}" data-count="{{ indicadores.grau_endividamento|numero_bruto }}" data-format="pct">{{ indicadores.grau_endividamento|percentual }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="dcr-card dcr-card--dourado" style="--i:10">
|
||||||
|
<div class="dcr-card__icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V9l9-6 9 6v12"/><path d="M9 21v-8h6v8M3 9h18"/></svg></div>
|
||||||
|
<div class="dcr-card__label">IPL</div>
|
||||||
|
<div class="dcr-card__value" data-final="{{ indicadores.ipl|percentual }}" data-count="{{ indicadores.ipl|numero_bruto }}" data-format="pct">{{ indicadores.ipl|percentual }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dcr-secao">
|
||||||
|
<h2>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>
|
||||||
|
Evolução do Resultado Líquido
|
||||||
|
</h2>
|
||||||
|
<div class="dcr-chart-wrap">
|
||||||
|
{% if evolucao|length > 1 %}
|
||||||
|
<canvas id="dcr-grafico-evolucao" height="80"></canvas>
|
||||||
|
{% else %}
|
||||||
|
<p class="dcr-vazio">Ainda não há histórico suficiente desta empresa no Portal para um gráfico de evolução.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dcr-secao">
|
||||||
|
<h2>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>
|
||||||
|
Todas as Observações da Análise
|
||||||
|
</h2>
|
||||||
|
{% if observacoes_contas or observacoes_dre or achados_com_observacao %}
|
||||||
|
<ul class="dcr-obs-lista">
|
||||||
|
{% for conta in observacoes_contas %}
|
||||||
|
<li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}">
|
||||||
|
<div class="dcr-obs-item__icone"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg></div>
|
||||||
|
<div>
|
||||||
|
<div class="dcr-obs-item__titulo">Balancete — {{ conta.codigo }} {{ conta.descricao }}</div>
|
||||||
|
<div class="dcr-obs-item__texto">{{ conta.observacao }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% for linha in observacoes_dre %}
|
||||||
|
<li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}">
|
||||||
|
<div class="dcr-obs-item__icone"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V5a2 2 0 0 1 2-2h8l6 6v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/><path d="M14 3v6h6"/></svg></div>
|
||||||
|
<div>
|
||||||
|
<div class="dcr-obs-item__titulo">D.R.E. — {{ linha.descricao }}</div>
|
||||||
|
<div class="dcr-obs-item__texto">{{ linha.observacao }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% for achado in achados_com_observacao %}
|
||||||
|
<li class="dcr-obs-item" style="--i:{{ forloop.counter0 }}">
|
||||||
|
<div class="dcr-obs-item__icone"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><path d="M12 9v4M12 17h.01"/></svg></div>
|
||||||
|
<div>
|
||||||
|
<div class="dcr-obs-item__titulo">Auditoria — {{ achado.titulo }}{% if achado.tratado_por %} — {{ achado.tratado_por.nome }}{% endif %}</div>
|
||||||
|
<div class="dcr-obs-item__texto">{{ achado.observacao_contador }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="dcr-vazio">Nenhuma observação registrada nesta análise.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="dcr-footer">Gerado pelo Portal Interno da De Paula em {{ gerado_em|date:"d/m/Y H:i" }}.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ evolucao|json_script:"dcr-evolucao-data" }}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// ---- Abas (Balancete / D.R.E. / Indicadores), com indicador deslizante ----
|
||||||
|
var tabs = Array.prototype.slice.call(document.querySelectorAll("[data-dcr-tab]"));
|
||||||
|
var paineis = document.querySelectorAll("[data-dcr-panel]");
|
||||||
|
var indicador = document.getElementById("dcr-tabs-indicator");
|
||||||
|
var graficoInicializado = false;
|
||||||
|
|
||||||
|
function posicionaIndicador(tab) {
|
||||||
|
if (!indicador || !tab) return;
|
||||||
|
indicador.style.width = tab.offsetWidth + "px";
|
||||||
|
indicador.style.transform = "translateX(" + tab.offsetLeft + "px)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ativaTab(tab) {
|
||||||
|
var alvo = tab.getAttribute("data-dcr-tab");
|
||||||
|
tabs.forEach(function (t) { t.classList.toggle("is-active", t === tab); });
|
||||||
|
paineis.forEach(function (p) { p.hidden = p.getAttribute("data-dcr-panel") !== alvo; });
|
||||||
|
posicionaIndicador(tab);
|
||||||
|
// O <canvas> do gráfico e os cards de indicador começam dentro de
|
||||||
|
// uma aba escondida (Balancete é a aba inicial) — Chart.js não
|
||||||
|
// desenha direito num canvas com largura/altura zero, e a contagem
|
||||||
|
// animada dos cards não faz sentido rodar enquanto está invisível,
|
||||||
|
// então os dois só disparam na primeira vez que a aba "Indicadores"
|
||||||
|
// é aberta de fato.
|
||||||
|
if (alvo === "indicadores" && !graficoInicializado) {
|
||||||
|
graficoInicializado = true;
|
||||||
|
inicializaGrafico();
|
||||||
|
pidDcrAnimaContadores();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.forEach(function (tab) {
|
||||||
|
tab.addEventListener("click", function () { ativaTab(tab); });
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", function () {
|
||||||
|
var ativa = tabs.filter(function (t) { return t.classList.contains("is-active"); })[0];
|
||||||
|
posicionaIndicador(ativa);
|
||||||
|
});
|
||||||
|
// Posição inicial só depois das fontes carregarem/layout assentar,
|
||||||
|
// senão `offsetLeft`/`offsetWidth` do primeiro paint saem errados.
|
||||||
|
window.addEventListener("load", function () { posicionaIndicador(tabs[0]); });
|
||||||
|
posicionaIndicador(tabs[0]);
|
||||||
|
|
||||||
|
// ---- Balancete/D.R.E.: árvore recolhível ----
|
||||||
|
// Mesmo algoritmo de renderContas()/renderDre() em dashboard-contabil.js
|
||||||
|
// (pilha de níveis recolhidos, "tem filhos" = próxima linha com nível
|
||||||
|
// maior) — mas aqui todas as linhas já estão prontas no HTML (o nível
|
||||||
|
// e "tem filhos" vieram calculados do servidor, ver
|
||||||
|
// _contabil_arvore_contexto em views.py), então alternar uma linha só
|
||||||
|
// precisa mostrar/esconder as `<tr>` já existentes, sem reconstruir
|
||||||
|
// nada. Linhas que passam de escondidas pra visíveis ganham um fade
|
||||||
|
// rápido (`.dcr-row-in`) — só elas, não a tabela inteira, pra não
|
||||||
|
// gerar flicker num clique que só afeta um grupo pequeno.
|
||||||
|
function pidDcrArvore(tbodyId) {
|
||||||
|
var tbody = document.getElementById(tbodyId);
|
||||||
|
if (!tbody) return;
|
||||||
|
var linhas = Array.prototype.slice.call(tbody.querySelectorAll("tr"));
|
||||||
|
var colapsadas = {};
|
||||||
|
|
||||||
|
function atualiza() {
|
||||||
|
var pilha = [];
|
||||||
|
linhas.forEach(function (tr) {
|
||||||
|
var eraOculta = tr.hidden;
|
||||||
|
var nivel = Number(tr.getAttribute("data-dcr-nivel"));
|
||||||
|
while (pilha.length && pilha[pilha.length - 1] >= nivel) pilha.pop();
|
||||||
|
var oculta = pilha.length > 0;
|
||||||
|
var id = tr.getAttribute("data-dcr-id");
|
||||||
|
var temFilhos = tr.getAttribute("data-dcr-tem-filhos") === "1";
|
||||||
|
if (temFilhos && colapsadas[id]) pilha.push(nivel);
|
||||||
|
tr.hidden = oculta;
|
||||||
|
if (eraOculta && !oculta) {
|
||||||
|
tr.classList.remove("dcr-row-in");
|
||||||
|
void tr.offsetWidth; // força reflow pra reiniciar a animação
|
||||||
|
tr.classList.add("dcr-row-in");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.addEventListener("click", function (event) {
|
||||||
|
var btn = event.target.closest("[data-dcr-toggle]");
|
||||||
|
if (!btn) return;
|
||||||
|
var tr = btn.closest("tr");
|
||||||
|
var id = tr.getAttribute("data-dcr-id");
|
||||||
|
colapsadas[id] = !colapsadas[id];
|
||||||
|
btn.classList.toggle("is-expanded", !colapsadas[id]);
|
||||||
|
atualiza();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pidDcrArvore("dcr-balancete-body");
|
||||||
|
pidDcrArvore("dcr-dre-body");
|
||||||
|
|
||||||
|
// ---- Cards: contagem animada + cor por sinal/liquidez ----
|
||||||
|
// O texto final exibido é sempre `data-final` (a string já formatada
|
||||||
|
// pelos filtros `moeda`/`percentual`/`indice` no servidor) — a
|
||||||
|
// animação só interpola os quadros intermediários com Intl nativo do
|
||||||
|
// navegador; nunca recalcula o valor de verdade, só decora a
|
||||||
|
// transição até ele. `finalizadores` guarda uma função por card pra
|
||||||
|
// pular direto pro valor final — usado no `beforeprint`, pra nunca
|
||||||
|
// imprimir um card a meio de uma animação.
|
||||||
|
var finalizadores = [];
|
||||||
|
|
||||||
|
function formataIntermediario(valor, formato) {
|
||||||
|
if (formato === "pct") {
|
||||||
|
return (valor * 100).toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + "%";
|
||||||
|
}
|
||||||
|
if (formato === "moeda") {
|
||||||
|
return valor.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
|
||||||
|
}
|
||||||
|
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function aplicaCor(el, valor) {
|
||||||
|
var regra = el.getAttribute("data-color-rule");
|
||||||
|
if (!regra || isNaN(valor)) return;
|
||||||
|
el.classList.remove("dcr-valor-positivo", "dcr-valor-negativo");
|
||||||
|
if (regra === "sign") {
|
||||||
|
if (valor > 0) el.classList.add("dcr-valor-positivo");
|
||||||
|
else if (valor < 0) el.classList.add("dcr-valor-negativo");
|
||||||
|
} else if (regra === "liquidez") {
|
||||||
|
el.classList.add(valor >= 1 ? "dcr-valor-positivo" : "dcr-valor-negativo");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `instantaneo` pula a animação e escreve o valor final direto — usado
|
||||||
|
// no fallback de impressão (ver `beforeprint` mais abaixo), quando a
|
||||||
|
// aba "Indicadores" nunca foi aberta na tela: sem isso, a impressão
|
||||||
|
// poderia capturar os cards no meio da contagem (RAF não termina a
|
||||||
|
// tempo do `window.print()` seguir em frente).
|
||||||
|
function pidDcrAnimaContadores(instantaneo) {
|
||||||
|
document.querySelectorAll(".dcr-card__value[data-count]").forEach(function (el) {
|
||||||
|
var bruto = el.getAttribute("data-count");
|
||||||
|
var final = el.getAttribute("data-final");
|
||||||
|
var formato = el.getAttribute("data-format");
|
||||||
|
if (bruto === "" || bruto === null) return; // indisponível ("—") — nada a animar
|
||||||
|
var alvo = parseFloat(bruto);
|
||||||
|
aplicaCor(el, alvo);
|
||||||
|
if (instantaneo) {
|
||||||
|
el.textContent = final;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var inicio = null;
|
||||||
|
var duracao = 800;
|
||||||
|
function passo(agora) {
|
||||||
|
if (inicio === null) inicio = agora;
|
||||||
|
var progresso = Math.min(1, (agora - inicio) / duracao);
|
||||||
|
var suavizado = 1 - Math.pow(1 - progresso, 3); // easeOutCubic
|
||||||
|
el.textContent = formataIntermediario(alvo * suavizado, formato);
|
||||||
|
if (progresso < 1) {
|
||||||
|
requestAnimationFrame(passo);
|
||||||
|
} else {
|
||||||
|
el.textContent = final;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalizadores.push(function () { el.textContent = final; });
|
||||||
|
requestAnimationFrame(passo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Gráfico de evolução do Resultado Líquido ----
|
||||||
|
function inicializaGrafico() {
|
||||||
|
var dados = JSON.parse(document.getElementById("dcr-evolucao-data").textContent);
|
||||||
|
var canvas = document.getElementById("dcr-grafico-evolucao");
|
||||||
|
if (!canvas || dados.length < 2 || typeof Chart === "undefined") return;
|
||||||
|
var ctx = canvas.getContext("2d");
|
||||||
|
var gradiente = ctx.createLinearGradient(0, 0, 0, canvas.height || 260);
|
||||||
|
gradiente.addColorStop(0, "rgba(61, 33, 120, 0.28)");
|
||||||
|
gradiente.addColorStop(1, "rgba(61, 33, 120, 0.02)");
|
||||||
|
Chart.defaults.font.family = "Inter, sans-serif";
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: dados.map(function (p) { return p.competencia; }),
|
||||||
|
datasets: [{
|
||||||
|
label: "Resultado Líquido (R$)",
|
||||||
|
data: dados.map(function (p) { return parseFloat(p.resultado_liquido); }),
|
||||||
|
borderColor: "#3d2178",
|
||||||
|
backgroundColor: gradiente,
|
||||||
|
pointBackgroundColor: "#b4872a",
|
||||||
|
pointBorderColor: "#fff",
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
borderWidth: 2.5,
|
||||||
|
tension: 0.35,
|
||||||
|
fill: true,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
animation: { duration: 900, easing: "easeOutCubic" },
|
||||||
|
interaction: { intersect: false, mode: "index" },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: "#3d2178",
|
||||||
|
titleFont: { weight: "700" },
|
||||||
|
padding: 10,
|
||||||
|
cornerRadius: 8,
|
||||||
|
callbacks: {
|
||||||
|
label: function (item) { return "R$ " + item.parsed.y.toLocaleString("pt-BR", { minimumFractionDigits: 2 }); },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } },
|
||||||
|
y: {
|
||||||
|
grid: { color: "rgba(228, 224, 236, 0.7)" },
|
||||||
|
ticks: { callback: function (v) { return "R$ " + v.toLocaleString("pt-BR"); } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Impressão mostra as 3 abas seguidas (ver @media print). Dois casos:
|
||||||
|
// (1) "Indicadores" nunca foi clicada na tela — gráfico e cards ainda
|
||||||
|
// não existem, então monta os dois agora, com os cards já no valor
|
||||||
|
// final (sem animação, ver `pidDcrAnimaContadores(true)` acima); (2)
|
||||||
|
// já foi clicada e a contagem pode estar em andamento — só finaliza
|
||||||
|
// os valores já em animação. Um único listener, pra nunca competir
|
||||||
|
// com o outro caso.
|
||||||
|
window.addEventListener("beforeprint", function () {
|
||||||
|
if (!graficoInicializado) {
|
||||||
|
graficoInicializado = true;
|
||||||
|
inicializaGrafico();
|
||||||
|
pidDcrAnimaContadores(true);
|
||||||
|
} else {
|
||||||
|
finalizadores.forEach(function (fn) { fn(); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -395,7 +395,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="dc-header-actions">
|
<div class="dc-header-actions">
|
||||||
<button type="button" class="btn-outline" id="dc-review-back-btn">Voltar</button>
|
<button type="button" class="btn-outline" id="dc-review-back-btn">Voltar</button>
|
||||||
<button type="button" class="btn-outline" id="dc-gerar-dashboard-btn" disabled title="Em desenvolvimento">Gerar Dashboard HTML</button>
|
<button type="button" class="btn-outline" id="dc-gerar-dashboard-btn">Gerar Dashboard</button>
|
||||||
<button type="button" class="btn-solid" id="dc-concluir-btn">Concluir Análise</button>
|
<button type="button" class="btn-solid" id="dc-concluir-btn">Concluir Análise</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -409,6 +409,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pa-tab-panel" data-dc-panel="achados">
|
<div class="pa-tab-panel" data-dc-panel="achados">
|
||||||
|
<div class="dc-achados-resumo">
|
||||||
|
<div class="dc-achados-resumo__chart">
|
||||||
|
<div class="dc-donut" id="dc-achados-donut">
|
||||||
|
<svg class="dc-donut-svg" id="dc-achados-donut-svg" viewBox="0 0 36 36"></svg>
|
||||||
|
<div class="dc-donut__hole">
|
||||||
|
<span class="dc-donut__total" id="dc-achados-donut-total">0</span>
|
||||||
|
<span class="dc-donut__label">observações</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="dc-donut-legend" id="dc-achados-donut-legend"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="dc-achados-categorias" id="dc-achados-categorias"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="dc-achados-toolbar">
|
<div class="dc-achados-toolbar">
|
||||||
<div class="dc-chip-row" id="dc-achados-severidade-chips">
|
<div class="dc-chip-row" id="dc-achados-severidade-chips">
|
||||||
<button type="button" class="dc-chip is-active" data-dc-severidade="todas">Todas</button>
|
<button type="button" class="dc-chip is-active" data-dc-severidade="todas">Todas</button>
|
||||||
@ -421,6 +435,10 @@
|
|||||||
<button type="button" class="dc-chip" data-dc-status="todos">Todos</button>
|
<button type="button" class="dc-chip" data-dc-status="todos">Todos</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dc-regra-filtro-ativo" id="dc-regra-filtro-ativo" hidden>
|
||||||
|
Filtrando por categoria: <strong id="dc-regra-filtro-ativo-label"></strong>
|
||||||
|
<button type="button" class="dc-regra-filtro-limpar" id="dc-regra-filtro-limpar-btn">Limpar</button>
|
||||||
|
</div>
|
||||||
<div class="dc-achados-list" id="dc-achados-list"></div>
|
<div class="dc-achados-list" id="dc-achados-list"></div>
|
||||||
<p class="dc-empty" id="dc-achados-empty" hidden>Nenhuma observação para este filtro.</p>
|
<p class="dc-empty" id="dc-achados-empty" hidden>Nenhuma observação para este filtro.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user