Inclusão da validação do Dashboard Contábil
This commit is contained in:
parent
16c656193b
commit
668f2eb26b
@ -63,3 +63,55 @@ Cada planilha nasce com cabeçalho (empresa/CNPJ/competência), cabeçalho de co
|
||||
Pedido: os 10 cards por regra do resumo (rodada 97), cada um com uma mini-tabela de conta+status por achado, ficaram "muito poluídos visualmente" na prática (captura de tela real anexada pelo usuário). Alinhado por `AskUserQuestion` (com preview) o agrupamento das 10 regras em 3 temas fixos: "Divergências de Saldo" (balanceamento Ativo x Passivo, débito x crédito, caixa negativo, sinal de saldo invertido), "Contas Atípicas" (contas transitórias, contas que deveriam zerar, descrição genérica) e "Variações e Indicadores" (variação atípica de saldo, variação atípica na DRE, percentual custo/receita).
|
||||
|
||||
`PID_DC_GRUPOS` (nova constante, `dashboard-contabil.js`) substitui a iteração antes feita direto sobre `PID_DC_REGRAS` na grade de cards — agora 1 card por grupo (cabeçalho com o total do grupo) contendo uma linha por regra (label + contagem, sem mini-tabela de conta/status). A mini-tabela de conta+status por achado saiu do resumo (era a maior fonte de poluição visual), mas continua disponível na lista completa logo abaixo, ao clicar numa linha de regra — o clique por regra individual (não por grupo) foi preservado, mesmo comportamento de filtro da rodada 98. Puramente frontend (JS + CSS), nenhuma mudança de backend/model. Detalhe completo no `CLAUDE.md` desta pasta.
|
||||
|
||||
### 101. Aba "Dashboard" na tela de revisão — ocultar cards/observações antes de gerar o relatório
|
||||
|
||||
Pedido: depois da aba DRE, uma aba "Dashboard" que mostra os indicadores e observações que vão pro relatório "Gerar Dashboard" **antes** de gerá-lo, onde o contador pode ocultar um card de indicador ou uma observação — o que estiver oculto não entra no relatório gerado.
|
||||
|
||||
Modelos novos: `ContabilConta.oculta_no_relatorio`/`ContabilLinhaDre.oculta_no_relatorio`/`ContabilAchado.oculto_no_relatorio` (booleanos, só afetam a seção "Observações" do relatório, a linha em si continua normal no Balancete/DRE/Observações da revisão) e `ContabilApuracao.indicadores_ocultos` (lista de chaves de `IndicadoresFinanceiros`, ex. `["kanitz"]`) — migração `0059`. Endpoints novos: `GET /api/contabil-apuracoes/{id}/indicadores/` (mesmo cálculo do relatório, extraído pro helper `_contabil_calcula_indicadores`), `POST .../indicadores-ocultos/` (substitui a lista inteira) e `POST /api/contabil-achados/{id}/alternar-oculto/` (separado do fluxo de tratar/ignorar, que sempre exige status+justificativa); `oculta_no_relatorio` de conta/linha da DRE já entra pelo `PATCH` genérico que essas duas telas já tinham. `dashboard()` filtra as 3 listas de observações por esses campos e envolve cada um dos 11 cards do relatório num `{% if "chave" not in indicadores_ocultos %}` (mais 2 booleanos de grupo, pra não sobrar um `<h2>` de seção sem nenhum card embaixo).
|
||||
|
||||
Nova aba `data-dc-tab="dashboard"` em `dashboard-contabil.html`, carregada sob demanda (`renderDashboardTab()`, só na primeira vez que é aberta) pra não pagar o custo de recalcular os indicadores em toda apuração aberta. Cada card/observação ganha um botão de olho (ícone SVG inline, reaproveitando `.icon-btn`) que alterna o oculto e atualiza só o item local, sem recarregar a apuração inteira; desabilitado quando a apuração já está concluída. Detalhe completo no `CLAUDE.md` desta pasta.
|
||||
|
||||
### 102. Banco de indicadores personalizados — fórmulas, componentes e "Ver fórmula"
|
||||
|
||||
Pedido: cada card de indicador devia ser "um indicador efetivo calculado a partir das contas contábeis" — o contador poder ver a fórmula de qualquer card (inclusive os 11 de sistema) e criar indicador **novo**, escolhendo contas do Balancete/linhas da DRE/variação entre apurações/outros indicadores já existentes como componentes de uma fórmula de verdade. Escopo alinhado por `AskUserQuestion` antes de implementar: os 11 de sistema continuam com o cálculo Python fixo de sempre (zero risco de mudar um valor já calibrado), só ganharam metadados de exibição; a fórmula personalizada é avaliada por um interpretador restrito (`ast`, nunca `eval()`); "selecionar conta ou grupo" é marcar uma ou mais contas específicas via checklist, não digitar um prefixo de código.
|
||||
|
||||
Modelos novos `IndicadorContabilDefinicao`/`IndicadorContabilComponente` (migração `0060`) — componente guardado por **código de conta**/**descrição de linha da DRE** (nunca FK a uma linha de uma apuração específica), reaplicado em qualquer apuração/empresa que o relatório for gerado. Novo módulo `dashboard_contabil/formula.py` (`avalia_formula()`/`valida_formula()`) — expressão aritmética (+, -, *, /, parênteses) sobre as chaves dos componentes, com `None` propagando como "indisponível" (nunca vira 0) e divisão por zero também virando `None`. Resolução iterativa em `views.py` (`_contabil_calcula_indicadores_personalizados()`) cobre encadeamento entre indicadores personalizados e referência a indicador de sistema, sem quebrar em caso de ciclo (fica `None`) — validado manualmente contra a produção com uma transação revertida (rollback), cobrindo os três casos.
|
||||
|
||||
CRUD novo `IndicadorContabilDefinicaoViewSet` (`/api/contabil-indicadores-definicoes/`) — mesma permissão de toggle único do resto da ferramenta (qualquer contador com acesso pode criar, não é restrito ao perfil "Inovação"); sempre substitui o indicador **inteiro** (nome/descrição/fórmula/formato/componentes) a cada criação/edição, nunca um diff incremental; bloqueia exclusão se outro indicador o referencia pela fórmula. `GET /contabil-apuracoes/{id}/indicadores/` ganhou `metadados` (nome/grupo/formato/descrição/fórmula em texto, uniforme pra indicador de sistema ou personalizado) — única fonte de verdade que o frontend usa agora pra agrupar/formatar cada card, substituindo as constantes hardcoded da rodada 101.
|
||||
|
||||
Metadados dos 11 de sistema (`indicadores.METADADOS_CARDS`) vieram do glossário de KPI que o usuário forneceu, com 2 ajustes pra bater com o que o código realmente calcula: Grau de Endividamento/IPL (o texto original tinha um "×100" que já é só a exibição em %, não uma conta a mais) e EBIT (o texto original é a definição-livro-texto "de cima pra baixo", o código calcula "de baixo pra cima" a partir do Lucro Líquido — os dois tendem a convergir mas não foram provados equivalentes).
|
||||
|
||||
Frontend: card agora é clicável (fora dos botões) e abre um modal "Ver fórmula"; indicador personalizado ganha um botão de editar (lápis) além do de ocultar. Modal "Novo/Editar Indicador" com um construtor de componentes dinâmico, reaproveitando `.checklist-box`/`.checklist-item`/`.checklist-search` (mesmo padrão dos checklists de Perfis/Departamento) pros pickers de conta/linha da DRE — necessário porque uma apuração real tem ~100-150 contas/linhas. Bug real pego antes do usuário testar: editar um indicador configurado a partir de **outra** apuração apagaria silenciosamente qualquer código/descrição que não existisse na apuração atualmente aberta (não aparecia no checklist pra continuar marcado); corrigido mostrando esses como item extra "não encontrado nesta apuração, mantido" no topo da lista. Detalhe completo no `CLAUDE.md` desta pasta.
|
||||
|
||||
### 103. Indicador padrão vs. não padrão + "Gerenciar Indicadores" + modal com scroll
|
||||
|
||||
Pedido: (a) o modal "Novo/Editar Indicador" da rodada 102 não tinha scroll — com vários componentes, só dava pra alcançar "Salvar" dando zoom out no navegador; (b) precisava de um jeito de ver/editar qualquer indicador já criado, não só os que já estão aparecendo na apuração aberta; (c) indicador personalizado devia poder ser "padrão" (aparece automaticamente em toda apuração, comportamento que já existia) ou "não padrão" (fica salvo/editável mas só aparece numa apuração quando selecionado ali).
|
||||
|
||||
`.modal-card` (`components.css`) ganhou `max-height`/`overflow-y:auto` — correção global, sem efeito em modal que já cabia na tela; a lista de componentes também rola sozinha (`.dc-ind-componentes-lista`, 320px), pra não precisar rolar duas vezes. Modelo `IndicadorContabilDefinicao.padrao` (default `True`, sem regressão pros já criados) e `ContabilApuracao.indicadores_selecionados` (migração `0061`). `_contabil_calcula_indicadores_personalizados()` passou a filtrar por `padrao=True` ou chave selecionada nesta apuração antes de calcular qualquer coisa — um indicador não padrão não selecionado não aparece nem no relatório nem na aba "Dashboard", mas continua existindo/editável. Endpoint novo `POST /api/contabil-apuracoes/{id}/indicadores-selecionados/` (mesmo padrão de `indicadores_ocultos/`).
|
||||
|
||||
Frontend: o botão "Novo Indicador" virou "Gerenciar Indicadores", abrindo um modal-hub com a lista **completa** (padrão e não padrão) — cada linha com nome clicável ("Ver fórmula"), editar, e (só não padrão) um checkbox "ativo nesta apuração". O form de criar/editar agora abre empilhado por cima do hub (`.modal-overlay--top`, mesmo mecanismo do modal de confirmação genérico) e ganhou um checkbox "Indicador padrão". Detalhe completo no `CLAUDE.md` desta pasta.
|
||||
|
||||
### 104. Migração dos 11 indicadores de sistema pro banco de indicadores
|
||||
|
||||
Pedido: reversão da decisão de escopo da rodada 102 (que tinha deixado os 11 indicadores "de sistema" — ROA/ROE/Kanitz/EBIT/EBITDA/Liquidez Corrente/Liquidez Seca/Liquidez Geral/Composição do Endividamento/Grau de Endividamento/IPL — fora do banco, calculados em Python fixo, pelo risco de mapear uma conta errado). Confirmado por `AskUserQuestion` (risco explicado antes) que o usuário queria a fórmula de verdade editável, não só o texto de exibição.
|
||||
|
||||
Os 11 viraram `IndicadorContabilDefinicao` de verdade, com as mesmas chaves de antes (preserva `indicadores_ocultos` já salvo) — não são mais um caso especial em lugar nenhum do código. Novo tipo de componente `resultado_liquido` (sem nenhum campo de referência, resolve sempre pra última linha da DRE por **posição**) — necessário porque essa linha muda de rótulo ("LUCRO" vs "PREJUÍZO") conforme o sinal do resultado, confirmado contra balancete real; um componente comum de linha da DRE (que casa por texto exato) quebraria assim que o resultado de uma empresa mudasse de sinal entre apurações. Novo campo `IndicadorContabilDefinicao.grupo` (não exposto no formulário — só a migração de dados setou os 2 grupos originais pros 11 migrados; indicador novo nasce em "Indicadores Personalizados").
|
||||
|
||||
**Verificação antes de produção**: dry-run revertido (transação com rollback forçado) criou os 11 e comparou o motor novo contra o cálculo Python antigo pra uma apuração real — bateram exatos até a 20ª casa decimal, inclusive o `None` do EBITDA sem apuração anterior. Só depois disso o script rodou de verdade. Testado em seguida contra a API/relatório reais (incluindo editar um componente `resultado_liquido` via PATCH).
|
||||
|
||||
`dashboard_contabil/indicadores.py` teve `CHAVES_CARDS`/`METADADOS_CARDS` removidos (zero consumidor); `calcula_indicadores()` e companhia foram **mantidos deliberadamente**, mesmo sem chamador em produção, como referência/auditoria pra recalcular e comparar se algum valor um dia parecer suspeito — única exceção neste projeto à convenção de apagar código sem uso, justificada pelo risco financeiro. O relatório "Gerar Dashboard" perdeu os 11 `.dcr-card` hardcoded, substituídos por um `{% for %}` genérico único (mesmo caminho que já servia indicador personalizado) — perda real e aceita conscientemente: cada um dos 11 tinha um ícone SVG próprio, agora todo indicador usa o mesmo ícone genérico. Detalhe completo no `CLAUDE.md` desta pasta.
|
||||
|
||||
### 105. Scroll aninhado no construtor de componentes + "Calcular com esta apuração"
|
||||
|
||||
Dois pedidos na sequência, testando a migração da rodada 104: (a) captura de tela mostrando que a lista de componentes tinha um scroll (320px) por cima do scroll do checklist de contas (160px) — dois scrolls aninhados deixavam a área minúscula, nem um componente inteiro cabia sem rolar duas vezes; (b) além da fórmula em texto, mostrar os valores exatos que cada componente busca e o resultado calculado, pro contador conferir se a fórmula está certa.
|
||||
|
||||
`.dc-ind-componentes-lista` perdeu o `max-height`/`overflow-y` (volta a crescer no fluxo normal do modal, que já rola inteiro); `.checklist-box` de cada componente aumentou de 160px pra 260px — sobra só um scroll aninhado (o checklist em si, necessário pelas ~100-150 contas/linhas de uma apuração real).
|
||||
|
||||
Novo botão "Calcular com esta apuração" no modal de indicador, entre "Fórmula" e as ações — chama `POST /api/contabil-apuracoes/{id}/pre-visualizar-indicador/` (`ContabilApuracaoViewSet.pre_visualizar_indicador()`) com o formulário como está na tela (ainda não salvo), calcula contra a apuração aberta e devolve o valor de cada componente + o resultado final, sem persistir nada. Reaproveita a mesma validação (`IndicadorContabilDefinicaoInputSerializer`) e o mesmo motor de cálculo (`_contabil_resolve_componente_personalizado()`/`avalia_formula()`) de criar/editar de verdade, só que sobre componentes construídos em memória, nunca salvos. Detalhe completo no `CLAUDE.md` desta pasta.
|
||||
|
||||
### 106. Bug real — exclusão de indicador não padrão travava o hub inteiro
|
||||
|
||||
Usuário reportou o modal de aviso "Chave(s) de indicador não padrão inexistente(s): diferença." ao tentar alternar um indicador no hub "Gerenciar Indicadores". Causa: um indicador não padrão de chave `diferença` tinha sido excluído em algum momento, mas `IndicadorContabilDefinicaoViewSet.perform_destroy()` só bloqueava/limpava referência pela **fórmula** (`indicador_referenciado`), nunca pelas referências em `ContabilApuracao.indicadores_selecionados`/`indicadores_ocultos` — a chave ficou órfã na apuração que a tinha selecionada. Como o frontend sempre reenvia a lista **inteira** a cada alternância de checkbox (`pidDcRenderHubIndicadores`/evento `change` em `dashboard-contabil.js`), e `ContabilIndicadoresSelecionadosSerializer`/`ContabilIndicadoresOcultosSerializer` rejeitavam a lista inteira se qualquer chave nela não existisse mais, o usuário ficava travado sem conseguir alternar **nenhum** indicador na apuração afetada, não só o excluído.
|
||||
|
||||
Dois ajustes, um pro sintoma já existente e outro pra causa raiz: (a) as duas validações passaram a **descartar silenciosamente** chave inexistente/órfã em vez de rejeitar a lista inteira — é só estado de exibição (quais cards aparecem/estão ocultos), não dado auditado, então autocorrigir é seguro e resolve o travamento já em produção sem precisar de um script de correção manual; (b) `perform_destroy()` agora também limpa a chave excluída de toda `ContabilApuracao` que a referenciava em `indicadores_selecionados`/`indicadores_ocultos`, pra não deixar mais nenhuma referência órfã nova daqui pra frente.
|
||||
|
||||
@ -142,6 +142,95 @@ Cada planilha nasce com cabeçalho (título/empresa/CNPJ/competência, linhas 1-
|
||||
|
||||
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.
|
||||
|
||||
### Aba "Dashboard" na tela de revisão — ocultar cards/observações do relatório antes de gerar
|
||||
|
||||
Pedido explícito do usuário: uma 4ª aba na tela de revisão (`dashboard-contabil.html`, depois de Observações/Balancete/DRE, `data-dc-tab="dashboard"`) mostrando **antes de gerar o relatório** os mesmos 11 cards de indicador e a mesma lista de observações (contas + linhas de DRE + achados com `observacao_contador`) que vão pro relatório "Gerar Dashboard", com um botão de olho em cada item pra escondê-lo do relatório final — sem apagar o dado em si (a conta/linha/achado continua normal no Balancete/DRE/Observações da revisão, só não entra na versão que vai pro administrador da empresa).
|
||||
|
||||
**Modelos** (`portal_api/models.py`): `ContabilConta.oculta_no_relatorio`/`ContabilLinhaDre.oculta_no_relatorio`/`ContabilAchado.oculto_no_relatorio` (`BooleanField`, default `False` — afeta só a seção "Observações" do relatório, nunca a linha em si no Balancete/DRE/Observações da revisão) e `ContabilApuracao.indicadores_ocultos` (`JSONField`, default `list` — lista de chaves de `IndicadoresFinanceiros`, ex. `["kanitz", "ipl"]`). Migração `0059_contabilachado_oculto_no_relatorio_and_more`.
|
||||
|
||||
**Chaves válidas de indicador** (`dashboard_contabil/indicadores.py`, `CHAVES_CARDS`/`CHAVE_CARDS_RESULTADO`/`CHAVE_CARDS_LIQUIDEZ`): as 11 chaves que viram card no relatório (`roa`/`roe`/`kanitz`/`ebit`/`ebitda`/`liquidez_corrente`/`liquidez_seca`/`liquidez_geral`/`composicao_endividamento`/`grau_endividamento`/`ipl`) — subconjunto dos ~20 campos de `IndicadoresFinanceiros` (os demais, ex. `ativo_total`/`estoques`, só alimentam fórmulas, nunca tiveram card próprio). Usada tanto pra validar o corpo de `indicadores_ocultos()` (`ContabilIndicadoresOcultosSerializer`, `ChoiceField` por chave) quanto pelos dois grupos do relatório.
|
||||
|
||||
**Endpoints novos** (`views.py`):
|
||||
- `GET /api/contabil-apuracoes/{id}/indicadores/` (`ContabilApuracaoViewSet.indicadores()`) — `{indicadores: {...}, indicadores_ocultos: [...]}`; `indicadores` é `dataclasses.asdict(IndicadoresFinanceiros)` puro (não passa por um `Serializer` — os `Decimal`/`None` já chegam certos no JSON porque o `JSONRenderer` do DRF aplica seu `JSONEncoder` recursivamente em qualquer estrutura de resposta, não só em campo de `Serializer`). Reaproveita o cálculo de `dashboard()` via o helper novo `_contabil_calcula_indicadores(apuracao)` (extraído do que antes estava só dentro de `dashboard()`) — mesma fórmula, duas telas (pré-visualização + relatório final).
|
||||
- `POST /api/contabil-apuracoes/{id}/indicadores-ocultos/` (`.indicadores_ocultos()`) — substitui a lista **inteira** de chaves ocultas de uma vez (`ContabilIndicadoresOcultosSerializer`, corpo `{indicadores_ocultos: [...]}`) — o frontend já tem a lista atual (via GET acima), só alterna uma chave e reenvia tudo; mais simples que um endpoint de toggle por chave. Gate `_contabil_garante_em_revisao()`, mesmo de qualquer edição de observação/achado.
|
||||
- `POST /api/contabil-achados/{id}/alternar-oculto/` (`ContabilAchadoViewSet.alternar_oculto()`) — **não** reaproveita `update()`/`ContabilAchadoAjusteSerializer` (que exige `status` + `observacao_contador` não-vazia pra "tratar"/"ignorar"): ocultar do relatório é uma decisão independente de tratativa, um achado pode continuar "Pendente" e mesmo assim ter sua observação escondida caso um dia venha a ser preenchida sem mudar o status. Serializer próprio (`ContabilAchadoOcultoSerializer`, só `{oculto_no_relatorio: bool}`), gate igual. `http_method_names` do viewset ganhou `"post"` só por causa desta action (`GET`/`PATCH` continuam cobrindo o resto).
|
||||
- `ContabilConta`/`ContabilLinhaDre` **não** precisaram de action nova — `oculta_no_relatorio` só entrou nos `fields` de `ContabilContaSerializer`/`ContabilLinhaDreSerializer` (ao lado de `observacao`, já gravável) e o `PATCH` genérico que essas duas telas já expõem (`ContabilContaViewSet`/`ContabilLinhaDreViewSet`, sem "Ajuste" nenhum no meio) aceita o campo isolado, sem exigir os demais.
|
||||
|
||||
**`dashboard()` filtra pelo que está oculto** (`views.py`): `observacoes_contas`/`observacoes_dre`/`achados_com_observacao` no contexto do relatório ganharam `and not X.oculta_no_relatorio`/`oculto_no_relatorio`; `indicadores_ocultos` (a lista crua) e dois booleanos por grupo (`indicadores_grupo_resultado_visivel`/`indicadores_grupo_liquidez_visivel`, `any(chave not in ocultos ...)`) entram no contexto pro template. `dashboard-contabil-relatorio.html` envolve cada um dos 11 `.dcr-card` num `{% if "chave" not in indicadores_ocultos %}` (o operador `in`/`not in` do Django Template Language já faz teste de pertencimento numa lista direto, sem precisar de filtro customizado novo) e cada um dos 2 `<h2>+.dcr-cards` de grupo num `{% if indicadores_grupo_*_visivel %}` — evita um cabeçalho de seção "Indicadores de Resultado" sobrando sozinho, sem nenhum card embaixo, se o contador ocultar os 5 de uma vez. O gráfico de evolução do Resultado Líquido e a exportação XLSX de Balancete/DRE **não** são afetados — não são "cards de indicador" nem "observações", ficaram fora do escopo desta rodada.
|
||||
|
||||
**Frontend** (`dashboard-contabil.js`): `dcIndicadoresAtual` (`{indicadores, indicadores_ocultos, metadados}`, ver "Banco de indicadores personalizados" abaixo pro terceiro campo — ou `null`) é buscado sob demanda só na primeira vez que a aba "Dashboard" é aberta depois de abrir/criar a apuração (`renderDashboardTab()`, chamado pelo handler de `#dc-tabs`; resetado pra `null` em `renderRevisao()` e após qualquer criação/edição/exclusão de indicador personalizado) — evita um cálculo/consulta extra ao histórico completo da empresa (`_contabil_monta_historico_completo`) toda vez que uma apuração é aberta, já que boa parte das revisões não chega a abrir essa aba. A lista de observações **não** tem fetch próprio — é derivada direto de `apuracaoAtual.contas`/`.linhas_dre`/`.achados`, já carregados no payload principal da apuração. Cada card/linha tem um botão de olho (`.dc-dash-toggle-btn`, reaproveita `.icon-btn` de `components.css` + ícone SVG de olho aberto/fechado inline, sem depender de `pid-icone-escuro.svg`) que chama a action correspondente e atualiza só o item local (sem re-buscar a apuração inteira); desabilitado (`disabled`) quando a apuração já está `concluida`, mesmo espírito de "não pode mais editar observações/achados" já aplicado ao textarea/botões dos outros dois modais desta ferramenta. `pidDcFormatIndicadorMoeda`/`Percentual`/`Indice` espelham os filtros `moeda`/`percentual`/`indice` de `contabil_extras.py` só que em JS — **`null`/`undefined` sempre vira "—", nunca "R$ 0,00"** (mesmo cuidado do relatório: em `IndicadoresFinanceiros`, `None` é "indisponível", ex. EBITDA sem apuração anterior, não zero) — não reaproveita `pidDcFormatMoeda()` já existente no arquivo, que trata `null` como `0` de propósito (usado só pra valores de conta/DRE, que nunca são `None`).
|
||||
|
||||
### Banco de indicadores personalizados — fórmulas, componentes e a aba "Dashboard"
|
||||
|
||||
Pedido explícito do usuário, rodada seguinte à aba "Dashboard" acima: cada card de indicador devia ser "um indicador efetivo calculado a partir das contas contábeis" — o contador poder ver a fórmula de cada um (inclusive os 11 de sistema) e criar indicador **novo**, escolhendo contas do Balancete/linhas da DRE/variação entre apurações/outros indicadores já existentes como componentes da fórmula. Escopo alinhado por `AskUserQuestion` antes de implementar: (a) os 11 indicadores de sistema **não** foram migrados pra este banco nesta rodada — continuam com o cálculo Python fixo de sempre em `calcula_indicadores()`, risco zero de mudar silenciosamente um valor já calibrado contra balancete real; só ganharam metadados de exibição (descrição/fórmula em texto) pro botão "Ver fórmula"; (b) a fórmula de um indicador personalizado é uma expressão de verdade (não só "A ÷ B"), avaliada por um interpretador restrito, não um `eval()`; (c) "selecionar conta ou grupo de contas" significa marcar uma ou mais contas específicas do Balancete (checklist), não digitar um prefixo de código.
|
||||
|
||||
**Modelos** (`portal_api/models.py`, migração `0060_indicadorcontabildefinicao_and_more`):
|
||||
- `IndicadorContabilDefinicao` — `chave` (`SlugField` único, sempre **derivada do `nome`** na criação, nunca aceita do cliente — mesmo espírito de `RegraCusteioPlanoSaude.nome`, ver `_gera_chave_indicador_contabil()` em `views.py`; imutável depois, já que pode estar referenciada em `ContabilApuracao.indicadores_ocultos` ou na fórmula de outro indicador), `nome`, `descricao` (texto simples, sem rich-text/imagens como `AjudaAplicacao` — não pedido, indicador é um card curto), `formula` (`CharField`, a expressão), `formato` (`moeda`/`percentual`/`indice`, mesmos 3 formatos dos indicadores de sistema), `criado_por`/`criado_em`/`atualizado_em`.
|
||||
- `IndicadorContabilComponente` (`related_name="componentes"`) — uma peça da fórmula, `chave` (identificador Python válido — `^[a-z][a-z0-9_]*$`, `RegexValidator`, **não** um `SlugField` comum porque vira nome de variável dentro da árvore `ast` do avaliador; diferente da `chave` da própria `Definicao`, que pode ter hífen à vontade) + `tipo` (`contas`/`linha_dre`/`variacao_conta`/`indicador`) + os 3 campos de referência, só um preenchido por vez conforme o `tipo`: `contas_codigos` (lista de `ContabilConta.codigo`, usado por `contas`/`variacao_conta`), `linhas_dre_descricoes` (lista de `ContabilLinhaDre.descricao` exata, usado por `linha_dre`), `indicador_referenciado` (chave de outro indicador — de sistema ou personalizado, usado por `indicador`). **Guardado por código/descrição, nunca por FK a uma linha de uma apuração específica** — a definição é genérica, reaplicada em qualquer apuração/empresa que o relatório for gerado; funciona quando a empresa usa o mesmo plano de contas, sai errado silenciosamente se não (mesmo risco já aceito pelos códigos fixos de `calcula_indicadores()`).
|
||||
|
||||
**Motor de fórmula** (`dashboard_contabil/formula.py`, função pura, mesmo espírito de `regras.py`): `avalia_formula(expressao, valores)` faz `ast.parse(expressao, mode="eval")` e anda pela árvore com um **allowlist** restrito — só `BinOp` (`+ - * /`), `UnaryOp` (`+ -`), número literal e `Name` (resolvido contra o dict `valores`, chave → `Decimal | None`); qualquer outro nó (chamada de função, atributo, `import`, comparação...) levanta `FormulaInvalidaError` antes de qualquer coisa ser executada — nunca `eval()`/`compile()` puro sobre um texto digitado pelo contador. Se qualquer nome referenciado valer `None` em qualquer ponto da árvore, ou a fórmula dividir por zero, o resultado inteiro é `None` — "indisponível" nunca vira 0, mesma disciplina de `IndicadoresFinanceiros`. `valida_formula(expressao, chaves_disponiveis)` roda a mesma árvore com valores fictícios (`1`) só pra validar sintaxe/nomes na hora de salvar a definição, sem se importar com o resultado numérico.
|
||||
|
||||
**Resolução de um indicador personalizado contra uma apuração** (`views.py`):
|
||||
- `_ContabilDadosIndicadores` (dataclass) + `_contabil_coleta_dados_indicadores(apuracao)` — junta `contas_atuais`/`dre_atual`/`resultado_liquido`/`historico_completo` (mesma query que já existia) e deriva `contas_anteriores` (`historico_completo[0].contas` se houver — só a apuração anterior imediata, mesma convenção de "Depreciação/Amortização do mês" em `calcula_indicadores()`). Reaproveitada tanto por `_contabil_calcula_indicadores()` (os 11 de sistema, sem mudança de comportamento, só recebe o dataclass em vez de recalcular tudo) quanto pelos personalizados abaixo — evita duas idas ao banco pelos mesmos dados quando `dashboard()`/`indicadores()` (que pedem os dois cálculos juntos) rodam.
|
||||
- `_contabil_resolve_componente_personalizado(componente, dados, valores)` — resolve **um** componente pro valor que entra na fórmula. `contas`/`variacao_conta` somam em **valor absoluto** (mesma convenção de `indicadores._saldo()`: Passivo/PL vêm negativos no relatório); `linha_dre` soma com o sinal já impresso (a DRE não segue essa convenção); `variacao_conta` é `soma_atual − soma_anterior` (ambas em valor absoluto), `None` se não houver apuração anterior; `indicador` só faz `valores.get(chave_referenciada)`.
|
||||
- `_contabil_calcula_indicadores_personalizados(dados, valores_sistema)` — resolve **todas** as definições cadastradas de uma vez. `valores_sistema` já chega só com as 11 chaves de `CHAVES_CARDS` (o que um componente `tipo="indicador"` pode referenciar de sistema — ver validação no serializer). **Iterativo, não uma ordem topológica "de verdade"**: a cada rodada, calcula todo indicador cujos componentes `tipo="indicador"` já têm valor conhecido (de sistema, ou personalizado já resolvido numa rodada anterior), repete até não sobrar progresso — cobre encadeamento entre indicadores personalizados (um referenciando o outro) sem precisar ordenar por dependência explicitamente. Um indicador cuja dependência nunca resolve (referência quebrada, ou **ciclo** entre dois personalizados — ex. A referencia B e B referencia A) fica com valor `None` pra sempre, sem lançar erro — uma fórmula mal configurada não pode derrubar o cálculo do relatório inteiro. Validado com um teste manual (rollback, sem persistir nada) cobrindo encadeamento de 2 níveis, referência a indicador de sistema e um ciclo de 2 — os três se comportam como descrito.
|
||||
- `_contabil_formata_indicador(valor, formato)` — só pros personalizados: o Django Template Language não permite escolher um filtro (`moeda`/`percentual`/`indice`) por nome vindo de uma variável, então o valor de um indicador personalizado chega **já formatado como texto** no contexto do relatório (`dashboard()`), reaproveitando as mesmas 3 funções de `contabil_extras.py` chamadas direto (um filtro de template continua sendo uma função Python comum, só registrada).
|
||||
|
||||
**Endpoints novos** (`views.py`/`urls.py`, `IndicadorContabilDefinicaoViewSet`, `router.register("contabil-indicadores-definicoes", ...)`) — mesma permissão de toggle único do resto da ferramenta (`PermissaoApp("relatorios", "dashboard-contabil")`): **qualquer contador com acesso já pode criar/editar/excluir indicador personalizado, não é uma tela administrativa restrita ao perfil "Inovação"** (diferente do padrão de "Mais informações"/`AjudaAplicacao`, decisão deliberada — quem cria os indicadores aqui é o próprio contador usando a ferramenta, não a Integração e Inovação).
|
||||
- `POST`/`PATCH /api/contabil-indicadores-definicoes/` (`create()`/`partial_update()`) — corpo sempre o indicador **inteiro** (`IndicadorContabilDefinicaoInputSerializer`: nome/descrição/formula/formato/`componentes[]`), mesmo em PATCH — um indicador personalizado é pequeno o bastante (poucos componentes) pra não valer a pena editar incrementalmente; `_salva_componentes()` sempre **substitui todos** os componentes de uma vez (`delete()` + `bulk_create()`), nunca faz diff. Validação em duas camadas: `validate_componentes()` confere chave única por componente + campo de referência preenchido conforme o `tipo` + `indicador_referenciado` existente (`CHAVES_CARDS` ∪ chaves de outras definições, excluindo a própria ao editar); `validate()` chama `formula.valida_formula()` contra o conjunto de chaves dos componentes.
|
||||
- `DELETE /api/contabil-indicadores-definicoes/{id}/` (`perform_destroy()`) — bloqueia (`ValidationError`) se outra definição referencia esta pela fórmula (`tipo="indicador"`), listando os nomes dependentes — evitar deixar uma fórmula alheia quebrada silenciosamente. Depois de excluir, também limpa a chave de toda `ContabilApuracao.indicadores_selecionados`/`indicadores_ocultos` que a referenciava (bug real, rodada 106: sem essa limpeza a chave ficava órfã, e como `ContabilIndicadoresSelecionadosSerializer`/`ContabilIndicadoresOcultosSerializer` reenviam/validam a lista **inteira** a cada alternância de checkbox no hub, o usuário ficava travado sem conseguir alternar nenhum indicador na apuração afetada — não só o excluído). As duas validações também passaram a descartar chave inexistente silenciosamente em vez de rejeitar a lista inteira (rede de segurança pra referência órfã que já existia antes desta limpeza, já que é só estado de exibição, não dado auditado).
|
||||
- `GET /api/contabil-apuracoes/{id}/indicadores/` (`ContabilApuracaoViewSet.indicadores()`, estendido) — `indicadores` agora é `{**valores_sistema_cards, **valores_personalizados}` (as 11 chaves de sistema mais toda chave personalizada); ganhou `metadados` — dict chave → `{nome, grupo, formato, descricao, formula_texto, personalizado, definicao_id}`, uniforme pra indicador de sistema (`grupo`/`formato`/`descricao`/`formula_texto` vêm de `METADADOS_CARDS`, `personalizado=False`, `definicao_id=None`) ou personalizado (`grupo` sempre `"Indicadores Personalizados"`, resto vem da própria `IndicadorContabilDefinicao`, `personalizado=True`) — é o que alimenta o botão "Ver fórmula" e a decisão de que grupo cada card cai no frontend, sem precisar de uma segunda fonte de verdade lá.
|
||||
- `dashboard()` (relatório) ganhou `indicadores_personalizados` no contexto — lista pronta (`{chave, nome, valor_formatado}`, já filtrando `indicadores_ocultos` e já com o valor formatado via `_contabil_formata_indicador`) pro `{% for %}` genérico do template (ver abaixo), diferente dos 11 de sistema que continuam com um `.dcr-card` hardcoded cada.
|
||||
|
||||
**Metadados dos 11 indicadores de sistema** (`dashboard_contabil/indicadores.py`, `MetaIndicador`/`METADADOS_CARDS`) — textos-base fornecidos pelo usuário (glossário de KPI já em uso pelo escritório), **ajustados em dois pontos** pra bater com o que o código realmente calcula, não copiados ao pé da letra:
|
||||
- **Grau de Endividamento e IPL**: o texto original dizia "÷ (Patrimônio Líquido × 100)" — alinhado por pergunta que isso é só a forma de dizer "o resultado vira %", não uma divisão a mais; `calcula_indicadores()` já fazia (e continua fazendo) só `A ÷ B`, exibido com o filtro `percentual` (que multiplica por 100 pra exibição). `formula_texto` desses dois reflete o cálculo real (`... ÷ Patrimônio Líquido, exibido em %`), não o texto literal do usuário — mostrar "÷100" enganaria o contador que olhar o resultado e não bater a conta.
|
||||
- **EBIT**: o texto original ("Receita Líquida − Custos − Despesas Operacionais", a definição-livro-texto "de cima pra baixo") não é como o código calcula (`resultado_liquido − despesas_receitas_financeiras`, "de baixo pra cima", partindo do Lucro Líquido já apurado) — `formula_texto` documenta o que o código realmente faz, não a definição conceitual. Os dois caminhos tendem a convergir num DRE bem formado, mas não foram provados matematicamente equivalentes; se um dia esse EBIT for migrado pro banco de indicadores (fora de escopo desta rodada, ver acima), vale revisitar qual das duas fórmulas usar.
|
||||
- Os demais 9 batem com o texto do usuário sem ajuste.
|
||||
|
||||
**Frontend** (`dashboard-contabil.js`/`.html`/`.css`) — `PID_DC_DASH_GRUPOS_INDICADORES`/`PID_DC_DASH_INDICADOR_INFO` (constantes hardcoded da rodada anterior) foram **removidas**: `renderDashboardIndicadores()` agora agrupa as chaves de `dcIndicadoresAtual.metadados` pelo campo `.grupo` de cada uma (vem do servidor), só a ORDEM de exibição dos grupos continua fixa no frontend (`PID_DC_DASH_ORDEM_GRUPOS`, grupo desconhecido cai no fim) — único jeito de "Indicadores Personalizados" aparecer sem precisar hardcodar nada de novo aqui a cada indicador criado.
|
||||
- **Card é clicável** (fora dos botões) → abre `#dc-indicador-formula-modal` (`pidDcAbrirFormulaModal()`) mostrando nome/descrição/`formula_texto` de `metadados[chave]` — funciona igual pra indicador de sistema ou personalizado, já que os dois têm a mesma forma de metadado.
|
||||
- **Card de indicador personalizado ganha um botão extra** (lápis, `data-dc-dash-indicador-editar`) ao lado do olho — busca a definição completa (`GET /contabil-indicadores-definicoes/{id}/`, precisa dos `componentes` que `metadados` não carrega) e abre `#dc-indicador-modal` prefiltrada pra edição; indicador de sistema não tem esse botão (`meta.personalizado === false`).
|
||||
- **Modal "Novo/Editar Indicador"** (`#dc-indicador-modal`, `.modal-card--wide`): nome/descrição/formato/fórmula + uma lista dinâmica de componentes (`#dc-indicador-componentes`, `pidDcCriaLinhaComponente()`/`pidDcRenderComponentePicker()`). Cada linha tem chave+tipo (sempre presentes) e um "picker" que muda conforme o `tipo` escolhido: `contas`/`variacao_conta` e `linha_dre` reaproveitam `.checklist-box`/`.checklist-item`/`.checklist-search` de `components.css` (mesmo componente visual já usado nos checklists de Perfis/Departamento em `usuarios.html`) — necessário porque uma apuração real tem ~100-150 contas/linhas de DRE, sem busca a lista seria inutilizável; `indicador` vira um `<select>` das chaves de `dcIndicadoresAtual.metadados` (exclui a própria chave, se estiver editando). O picker de cada linha é reconstruído (`pidDcRenderComponentePicker`) só quando o `<select>` de tipo muda (não a cada tecla digitada na busca, que só filtra via `hidden` nos itens já renderizados) — troca de tipo descarta a seleção anterior daquele componente, mesmo espírito de "começar do zero" ao mudar de tipo.
|
||||
- **Componente "órfão" ao editar de uma apuração diferente da original**: os pickers de `contas`/`linha_dre` são montados a partir de `apuracaoAtual` (a que está sendo revisada agora), mas um componente pode ter sido configurado a partir de **outra** apuração (outra empresa/competência) — se um código/descrição salvo não existe na apuração atual, ele não apareceria na lista pra ser marcado, e salvar sem tocar naquele componente **apagaria silenciosamente** essa referência (bug real, pego antes do usuário testar). Corrigido: todo código/descrição selecionado que não existe na apuração atual entra como um item extra no topo do checklist, já marcado e com uma nota "não encontrada nesta apuração, mantida" — continua salvo de volta do jeito que estava, a menos que o contador desmarque de propósito.
|
||||
- **Ao salvar, sempre reenvia `componentes` inteiro** (não um diff) — reflete o "substitui tudo" do backend (`_salva_componentes()`); ao concluir (criar/editar/excluir), `dcIndicadoresAtual = null` força `renderDashboardTab()` buscar tudo de novo (valores recalculados, metadados atualizados).
|
||||
- **Botão "Excluir" só aparece editando** (`#dc-indicador-modal-excluir-btn`, `hidden` na criação) — usa `pidConfirm({perigoso: true})`, nunca `window.confirm` (ver `[[feedback_popups_no_padrao_do_portal]]` na memória).
|
||||
|
||||
### Indicador padrão vs. não padrão + "Gerenciar Indicadores" + modal com scroll
|
||||
|
||||
Pedido explícito do usuário, rodada seguinte: (a) o modal "Novo/Editar Indicador" não tinha scroll — com vários componentes, o conteúdo crescia pra fora da tela e só dava pra alcançar "Salvar" dando zoom out no navegador; (b) precisava de um jeito de **ver e editar** qualquer indicador já criado, não só os que já estão aparecendo nesta apuração; (c) indicador personalizado precisava poder ser **padrão** (aparece automaticamente em toda apuração, comportamento que já existia) ou **não padrão** (fica salvo/editável, mas só aparece numa apuração específica quando o contador seleciona ali).
|
||||
|
||||
**Modal com scroll** (`components.css`): `.modal-card` ganhou `max-height: calc(100vh - var(--space-5) * 2)` + `overflow-y: auto` — mudança global (toda tela que usa `.modal-card`), sem efeito em modal que já cabia na tela, e a rede de segurança que faltava pra qualquer modal futuro com conteúdo dinâmico. Adicionalmente, só a lista de componentes (`#dc-indicador-componentes`, classe `.dc-ind-componentes-lista`, `dashboard-contabil.css`) tem seu próprio `max-height:320px; overflow-y:auto` — rola só ela, não o modal inteiro, então nome/descrição/formato (cabeçalho) e fórmula/ações (rodapé) continuam alcançáveis sem precisar rolar duas vezes.
|
||||
|
||||
**Modelos**: `IndicadorContabilDefinicao.padrao` (`BooleanField`, default `True` — indicador já existente antes desta rodada continua aparecendo em toda apuração, sem regressão) e `ContabilApuracao.indicadores_selecionados` (`JSONField`, default `list` — chaves de indicador **não padrão** ativadas especificamente nesta apuração; um indicador padrão nunca precisa aparecer aqui). Migração `0061_contabilapuracao_indicadores_selecionados_and_more`.
|
||||
|
||||
**Cálculo** (`views.py`, `_contabil_calcula_indicadores_personalizados()`): ganhou o parâmetro `indicadores_selecionados` — filtra `IndicadorContabilDefinicao.objects.all()` logo no início pra só os que têm `padrao=True` **ou** `chave in indicadores_selecionados`; o resto do algoritmo (resolução iterativa, ciclo vira `None`) não mudou. `dashboard()`/`indicadores()` passam `apuracao.indicadores_selecionados` adiante e também filtram por esse mesmo critério ao montar `indicadores_personalizados_cards`/`metadados` — um indicador não padrão não selecionado **não aparece em lugar nenhum** desta apuração (nem no relatório, nem na aba "Dashboard"), mas continua existindo/editável via a listagem completa (`GET /api/contabil-indicadores-definicoes/`, usada pelo hub — nunca filtrada por apuração, sempre lista tudo).
|
||||
|
||||
**Endpoint novo**: `POST /api/contabil-apuracoes/{id}/indicadores-selecionados/` (`ContabilApuracaoViewSet.indicadores_selecionados()`) — mesmo padrão de `indicadores_ocultos()` (substitui a lista inteira de uma vez), `ContabilIndicadoresSelecionadosSerializer` valida que toda chave enviada corresponde a uma definição com `padrao=False` (selecionar uma chave padrão não faz sentido, ela já aparece sempre — rejeitado com 400). `IndicadorContabilDefinicaoInputSerializer` ganhou `padrao` (opcional, default `True`) — `create()`/`partial_update()` gravam o campo.
|
||||
|
||||
**Frontend — "Gerenciar Indicadores"** (`dashboard-contabil.js`/`.html`): o botão que antes abria "Novo Indicador" direto virou `#dc-indicadores-gerenciar-btn`, abrindo um modal-hub novo (`#dc-indicadores-hub-modal`) com duas listas — "Padrão" e "Não padrão" (`GET /contabil-indicadores-definicoes/`, sempre a lista **completa**, sem recorte por apuração). Cada linha (`pidDcCriaLinhaHub()`): nome (clicável, abre "Ver fórmula"), botão de editar (lápis, abre `#dc-indicador-modal` — o mesmo form de sempre, agora empilhado por cima do hub via `.modal-overlay--top`, reaproveitando o mecanismo já usado por `confirm-modal.js`) e, só nas linhas "Não padrão", um checkbox "ativo nesta apuração" (`data-dc-hub-toggle`, chama `indicadores-selecionados/`). O botão "+ Novo Indicador" fica dentro do hub agora, não solto na aba — reflete o pedido do usuário ("selecionados... quando acessar o botão de novo indicador").
|
||||
- `pidDcAbrirFormulaModal()` deixou de receber uma chave (só resolvia contra `dcIndicadoresAtual.metadados`, que só lista indicador **ativo nesta apuração**) e passou a receber um objeto `{nome, descricao, formula_texto}` direto — necessário pro hub poder mostrar a fórmula de um indicador não padrão ainda não selecionado (que não está em `metadados`), buscando a definição completa (`GET .../{id}/`) na hora do clique.
|
||||
- Depois de criar/editar/excluir um indicador (`pidDcAposSalvarOuExcluirIndicador()`), além de recarregar os cards da aba "Dashboard" (`dcIndicadoresAtual = null` + `renderDashboardTab()`), também re-renderiza o hub se ele estiver aberto por trás — sem isso a lista do hub ficaria desatualizada até fechar e abrir de novo.
|
||||
|
||||
### Migração dos 11 indicadores de sistema pro banco (rodada seguinte)
|
||||
|
||||
Pedido explícito do usuário — reversão deliberada da decisão de escopo da rodada anterior ("Banco de indicadores personalizados"), que tinha deixado os 11 indicadores "de sistema" de fora do banco de propósito, pelo risco de mapear uma conta errado e mudar silenciosamente um valor já calibrado. Confirmado por `AskUserQuestion` (com o risco explicado antes) que o usuário queria a fórmula **de verdade** editável, não só o texto de exibição — então os 11 (`roa`/`roe`/`kanitz`/`ebit`/`ebitda`/`liquidez_corrente`/`liquidez_seca`/`liquidez_geral`/`composicao_endividamento`/`grau_endividamento`/`ipl`) viraram `IndicadorContabilDefinicao` de verdade, com as mesmas chaves de antes (preserva qualquer `indicadores_ocultos` já salvo). Não são mais um caso especial em lugar nenhum do código — CRUD, cálculo, exibição, tudo passa pelo mesmo caminho de qualquer indicador personalizado.
|
||||
|
||||
**Novo tipo de componente: `resultado_liquido`** (`IndicadorContabilComponente.TIPO_RESULTADO_LIQUIDO`, sem nenhum campo de referência — nem `contas_codigos`, nem `linhas_dre_descricoes`, nem `indicador_referenciado`) — resolve sempre pra `dados.resultado_liquido` (a última linha da DRE, por **posição**, não por texto). Necessário porque ROA/ROE/EBIT precisam do resultado líquido do período, e a última linha da DRE **muda de rótulo** conforme o sinal do resultado — confirmado contra um balancete real (`792 - balancete 072026.pdf`... a mesma apuração de referência já usada em `regras.py`): uma empresa com prejuízo termina em "(=) PREJUÍZO LÍQUIDO DO EXERCÍCIO", uma com lucro terminaria em "(=) LUCRO LÍQUIDO DO EXERCÍCIO" — um componente `linha_dre` comum (que casa por texto exato) quebraria assim que o resultado de uma empresa virasse de sinal entre uma apuração e outra. Resolvido em `_contabil_resolve_componente_personalizado()` (`views.py`) com um `if` a mais; no frontend, `pidDcRenderComponentePicker()` mostra só um texto explicativo pra esse tipo (nada pra selecionar).
|
||||
|
||||
**Novo campo `IndicadorContabilDefinicao.grupo`** (`CharField`, default `"Indicadores Personalizados"`) — livre, só pra agrupar visualmente (mesmo conceito que já existia fixo no relatório antes da migração: "Indicadores de Resultado"/"Indicadores de Liquidez e Endividamento"). **Não exposto no formulário de criar/editar** (`IndicadorContabilDefinicaoInputSerializer` não aceita `grupo`) — só a migração de dados setou os dois grupos originais pros 11 migrados; um indicador criado pela tela sempre nasce em "Indicadores Personalizados" e não tem como mudar de grupo pela UI (poderia virar um campo editável numa rodada futura, se pedido). Migração `0062_indicadorcontabildefinicao_grupo_and_more` (schema) — os 11 registros em si foram inseridos por um script Python ad-hoc rodado uma única vez direto no banco de produção (não uma management command, não fica no repositório), não pelo endpoint da API.
|
||||
|
||||
**Verificação antes de ir pra produção** (rigor extra por lidar com valor financeiro já exibido a cliente): antes de gravar qualquer coisa, um dry-run (mesmo script, dentro de uma `transaction.atomic()` com rollback forçado no final) criou os 11 registros, calculou os valores via `_contabil_calcula_indicadores_personalizados()` (motor novo) e comparou contra `dashboard_contabil.indicadores.calcula_indicadores()` (cálculo Python antigo) pra mesma apuração real (`id=4`, a única em produção nesta rodada) — **os 11 bateram exatos até a vigésima casa decimal** (inclusive o `None` do EBITDA, que não tem apuração anterior desta empresa). Só depois dessa conferência o script rodou de verdade (sem rollback). Testado de ponta a ponta em seguida contra a API/relatório reais (`GET /indicadores/`, `GET /dashboard/`, `PATCH` de edição incluindo um componente `resultado_liquido`) — tudo consistente.
|
||||
|
||||
**`dashboard_contabil/indicadores.py` foi esvaziado, mas não apagado**: `CHAVES_CARDS`/`CHAVE_CARDS_RESULTADO`/`CHAVE_CARDS_LIQUIDEZ`/`MetaIndicador`/`METADADOS_CARDS` foram removidos (zero consumidor depois da migração — mantê-los seria texto duplicado e defasável em relação à `descricao`/`formula` reais do banco). `calcula_indicadores()`/`IndicadoresFinanceiros`/`_saldo()`/`_divide()`/`_busca_linha_por_trecho()`/`CODIGO_*` foram **deliberadamente mantidos**, mesmo sem nenhum chamador em produção (`ContabilApuracaoViewSet.dashboard()`/`.indicadores()` não chamam mais `_contabil_calcula_indicadores()` — a função wrapper em `views.py` continua existindo, só não é mais invocada) — é a única exceção neste projeto à convenção de apagar código sem uso, justificada pelo risco real de indicador financeiro: se um valor um dia parecer suspeito, dá pra recalcular pelo caminho antigo e comparar contra o banco sem precisar reconstruir a fórmula de cabeça. Revisar se ainda vale a pena manter numa rodada futura, depois que a migração provar estabilidade em produção por um tempo.
|
||||
|
||||
**Relatório "Gerar Dashboard" perdeu os 11 `.dcr-card` hardcoded** — `dashboard-contabil-relatorio.html` agora tem um único `{% for grupo in indicadores_grupos %}` genérico (mesmo conceito do `{% for indicador in indicadores_personalizados %}` que já existia pra indicador personalizado antes desta rodada, agora é o **único** caminho de renderização, sem duplicação). `_contabil_monta_cards_indicadores()`/`_contabil_agrupa_indicadores_cards()` (`views.py`) montam a lista já com tudo pronto: valor formatado, valor cru (`data-count`), formato pro contador animado (`data-format`), regra de cor e destaque dourado só pros 11 migrados (`_CONTABIL_INDICADOR_COR_REGRA`/`_CONTABIL_INDICADOR_DOURADO`/`_CONTABIL_INDICADOR_NOTA`, dicts chave-fixa em `views.py` — réplica visual exata do que esses 11 já tinham hardcoded no template antes; indicador personalizado criado depois não entra em nenhum desses dicts, nasce sem cor/destaque/nota, mesmo como já era). **Perda real, aceita conscientemente**: os 11 cards tinham um ícone SVG próprio cada (gráfico de barras pro ROA, gota pra Liquidez Seca, etc.) — o loop genérico usa um ícone único pra todo indicador agora, não haveria como manter 11 ícones distintos sem mais uma tabela chave→SVG; mencionado ao usuário, não pedido de volta ainda.
|
||||
|
||||
### Dois ajustes de acabamento no construtor de fórmula (mesma rodada da migração)
|
||||
|
||||
**Scroll aninhado no construtor de componentes** (pedido explícito do usuário, com captura de tela mostrando a área minúscula): `.dc-ind-componentes-lista` tinha seu próprio `max-height:320px; overflow-y:auto` (ver "Indicador padrão vs. não padrão" acima) **por cima** do `.checklist-box` de cada componente (`max-height:160px`, já rolável) — dois scrolls aninhados deixavam a área útil tão pequena que nem um componente inteiro cabia sem rolar duas vezes. Removido o scroll de `.dc-ind-componentes-lista` (volta a crescer no fluxo normal do modal, que já rola inteiro via `.modal-card` de `components.css`) e aumentado `.dc-ind-comp-picker .checklist-box` de 160px pra 260px — sobra só **um** scroll aninhado (o checklist em si, genuinamente necessário pelas ~100-150 contas/linhas de uma apuração real), não mais dois.
|
||||
|
||||
**"Calcular com esta apuração"** (pedido explícito do usuário: conferir os valores buscados, não só ler a fórmula em texto) — novo botão no modal "Novo/Editar Indicador", entre "Fórmula" e as ações do rodapé, que chama `POST /api/contabil-apuracoes/{id}/pre-visualizar-indicador/` (`ContabilApuracaoViewSet.pre_visualizar_indicador()`) com o formulário **ainda não salvo** (nome/formato/fórmula/componentes tal como estão na tela) e mostra o valor de cada componente + o resultado final, calculados contra a apuração que está aberta na revisão — sem persistir nada, funciona tanto criando quanto editando. Reaproveita `IndicadorContabilDefinicaoInputSerializer` inteiro pra validar o corpo (inclusive a checagem de fórmula/componentes que criar/editar já fazem — `nome` é exigido pelo serializer mas não usado pra nada aqui, o frontend manda o que já estiver no campo, ou `"Pré-visualização"` se estiver vazio) e `_contabil_resolve_componente_personalizado()`/`avalia_formula()` (as mesmas funções do cálculo de verdade) sobre instâncias de `IndicadorContabilComponente` **nunca salvas** (só construídas em memória). Cada valor de componente é formatado com o filtro `indice` (número BR simples, sem R$/%, já que um componente pode ser qualquer grandeza — moeda, ratio de outro indicador, etc., não dá pra adivinhar o formato certo por componente); o resultado final usa o `formato` escolhido no formulário. Frontend: `pidDcColetaComponentesForm()` (extraída do handler de "Salvar", reaproveitada pelos dois) monta o payload; resposta renderizada em `#dc-indicador-preview` (`.dc-ind-preview`, `dashboard-contabil.css`), escondida sempre que o modal abre de novo (não carrega sozinho, só depois de clicar "Calcular" — evita mostrar um resultado desatualizado de uma edição anterior).
|
||||
|
||||
### 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).
|
||||
|
||||
104
portal_api/dashboard_contabil/formula.py
Normal file
104
portal_api/dashboard_contabil/formula.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""Interpretador restrito de fórmula de indicador personalizado (Dashboard
|
||||
Contábil, ver `IndicadorContabilDefinicao.formula` em models.py) — função
|
||||
pura, mesmo espírito de `regras.py`/`indicadores.py`: não toca no ORM,
|
||||
recebe os valores já resolvidos.
|
||||
|
||||
Nunca usa `eval()`/`compile()` puro (a fórmula é digitada pelo contador,
|
||||
input de usuário) — `ast.parse(..., mode="eval")` gera uma árvore e
|
||||
`_avalia()` só anda por um allowlist restrito de nós (`BinOp` com
|
||||
+-*/, `UnaryOp` com +-, número literal, nome de componente). Qualquer outro
|
||||
nó (chamada de função, atributo, subscrição, comparação, etc.) levanta
|
||||
`FormulaInvalidaError` antes de qualquer coisa ser executada."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import operator
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
_OPERADORES_BINARIOS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
}
|
||||
_OPERADORES_UNARIOS = {
|
||||
ast.UAdd: operator.pos,
|
||||
ast.USub: operator.neg,
|
||||
}
|
||||
|
||||
|
||||
class FormulaInvalidaError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _avalia_no(node: ast.AST, valores: dict[str, Decimal | None]) -> Decimal | None:
|
||||
if isinstance(node, ast.Expression):
|
||||
return _avalia_no(node.body, valores)
|
||||
|
||||
if isinstance(node, ast.BinOp):
|
||||
operador = _OPERADORES_BINARIOS.get(type(node.op))
|
||||
if operador is None:
|
||||
raise FormulaInvalidaError("Operador não permitido na fórmula (só +, -, * e / são aceitos).")
|
||||
esquerda = _avalia_no(node.left, valores)
|
||||
direita = _avalia_no(node.right, valores)
|
||||
if esquerda is None or direita is None:
|
||||
return None
|
||||
if isinstance(node.op, ast.Div) and direita == 0:
|
||||
return None
|
||||
return operador(esquerda, direita)
|
||||
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
operador = _OPERADORES_UNARIOS.get(type(node.op))
|
||||
if operador is None:
|
||||
raise FormulaInvalidaError("Operador não permitido na fórmula (só +, -, * e / são aceitos).")
|
||||
valor = _avalia_no(node.operand, valores)
|
||||
return None if valor is None else operador(valor)
|
||||
|
||||
if isinstance(node, ast.Constant):
|
||||
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
|
||||
raise FormulaInvalidaError("A fórmula só pode conter números, não pode ter texto.")
|
||||
try:
|
||||
return Decimal(str(node.value))
|
||||
except InvalidOperation as exc:
|
||||
raise FormulaInvalidaError("Número inválido na fórmula.") from exc
|
||||
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id not in valores:
|
||||
raise FormulaInvalidaError(f'Componente "{node.id}" não existe nesta fórmula.')
|
||||
return valores[node.id]
|
||||
|
||||
raise FormulaInvalidaError("A fórmula contém uma operação não permitida (só +, -, *, / e parênteses).")
|
||||
|
||||
|
||||
def avalia_formula(expressao: str, valores: dict[str, Decimal | None]) -> Decimal | None:
|
||||
"""Avalia `expressao` (ex. "(ativo_circulante - estoques) / passivo_circulante")
|
||||
contra os valores já resolvidos dos componentes (`valores`, chave ->
|
||||
Decimal ou None). Se qualquer nome referenciado tiver valor `None`
|
||||
("indisponível") em qualquer ponto da árvore, ou a fórmula fizer uma
|
||||
divisão por zero, o resultado inteiro é `None` — nunca uma exceção nem
|
||||
um 0 mascarando indisponibilidade (mesmo espírito de `IndicadoresFinanceiros`,
|
||||
ver CLAUDE.md do pacote). Levanta `FormulaInvalidaError` só quando a
|
||||
fórmula em si é inválida (sintaxe, operador não permitido, componente
|
||||
inexistente) — isso sempre deve ser pego na hora de salvar a definição,
|
||||
não durante o cálculo de uma apuração."""
|
||||
expressao = (expressao or "").strip()
|
||||
if not expressao:
|
||||
raise FormulaInvalidaError("Informe a fórmula do indicador.")
|
||||
try:
|
||||
arvore = ast.parse(expressao, mode="eval")
|
||||
except SyntaxError as exc:
|
||||
raise FormulaInvalidaError(f"Fórmula inválida: {exc.msg}.") from exc
|
||||
return _avalia_no(arvore, valores)
|
||||
|
||||
|
||||
def valida_formula(expressao: str, chaves_disponiveis: set[str]) -> None:
|
||||
"""Validação "a seco" na hora de salvar a definição — roda a mesma
|
||||
árvore contra valores fictícios (todo componente = 1) só pra garantir
|
||||
que a fórmula é sintaticamente válida e só referencia nomes
|
||||
conhecidos, sem se importar com o resultado numérico em si (`None`
|
||||
também seria um resultado "válido" durante o cálculo real, ex. divisão
|
||||
por zero, então não dá pra usar o valor de retorno aqui pra decidir
|
||||
nada)."""
|
||||
valores_ficticios = {chave: Decimal(1) for chave in chaves_disponiveis}
|
||||
avalia_formula(expressao, valores_ficticios)
|
||||
@ -1,17 +1,25 @@
|
||||
"""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.
|
||||
"""Cálculo de referência dos antigos 11 indicadores financeiros "de
|
||||
sistema" do relatório "Gerar Dashboard" — **não é mais chamado pela
|
||||
aplicação em produção**: os 11 (ROA/ROE/Kanitz/EBIT/EBITDA/Liquidez
|
||||
Corrente/Liquidez Seca/Liquidez Geral/Composição do Endividamento/Grau de
|
||||
Endividamento/IPL) foram migrados pra `IndicadorContabilDefinicao` de
|
||||
verdade (registros no banco, com componentes e fórmula reais avaliadas por
|
||||
`dashboard_contabil.formula`), a pedido explícito do usuário. Este módulo
|
||||
foi **mantido de propósito** (não apagado) como referência/auditoria — se
|
||||
algum dia um valor exibido parecer suspeito, dá pra recalcular aqui e
|
||||
comparar contra o que o banco de indicadores está devolvendo, já que os
|
||||
dois deveriam bater sempre (a migração foi conferida, indicador por
|
||||
indicador, contra uma apuração real antes de entrar em produção — ver
|
||||
CLAUDE.md do pacote, "Migração dos 11 indicadores de sistema"). Funções
|
||||
puras, mesmo espírito de `regras.py`: recebem os dados já extraídos (nunca
|
||||
tocam no ORM).
|
||||
|
||||
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)."""
|
||||
Caixa)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0.7 on 2026-09-09 17:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('portal_api', '0058_merge_20260909_1335'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contabilachado',
|
||||
name='oculto_no_relatorio',
|
||||
field=models.BooleanField(default=False, verbose_name='Observação oculta no relatório'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contabilapuracao',
|
||||
name='indicadores_ocultos',
|
||||
field=models.JSONField(blank=True, default=list, verbose_name='Indicadores ocultos no relatório'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contabilconta',
|
||||
name='oculta_no_relatorio',
|
||||
field=models.BooleanField(default=False, verbose_name='Observação oculta no relatório'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contabillinhadre',
|
||||
name='oculta_no_relatorio',
|
||||
field=models.BooleanField(default=False, verbose_name='Observação oculta no relatório'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,53 @@
|
||||
# Generated by Django 6.0.7 on 2026-09-09 18:03
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('portal_api', '0059_contabilachado_oculto_no_relatorio_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='IndicadorContabilDefinicao',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('chave', models.SlugField(max_length=60, unique=True, verbose_name='Chave')),
|
||||
('nome', models.CharField(max_length=120, verbose_name='Nome')),
|
||||
('descricao', models.TextField(blank=True, verbose_name='Descrição')),
|
||||
('formula', models.CharField(max_length=500, verbose_name='Fórmula')),
|
||||
('formato', models.CharField(choices=[('moeda', 'Moeda (R$)'), ('percentual', 'Percentual (%)'), ('indice', 'Índice')], default='indice', max_length=12, verbose_name='Formato de exibição')),
|
||||
('criado_em', models.DateTimeField(auto_now_add=True)),
|
||||
('atualizado_em', models.DateTimeField(auto_now=True)),
|
||||
('criado_por', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='indicadores_contabeis_criados', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Indicador do Dashboard Contábil (personalizado)',
|
||||
'verbose_name_plural': 'Indicadores do Dashboard Contábil (personalizados)',
|
||||
'ordering': ['nome'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IndicadorContabilComponente',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('chave', models.CharField(max_length=40, validators=[django.core.validators.RegexValidator('^[a-z][a-z0-9_]*$', "Use só letras minúsculas, números e '_', começando por uma letra (é usada na fórmula).")], verbose_name='Chave')),
|
||||
('tipo', models.CharField(choices=[('contas', 'Soma de contas do Balancete'), ('linha_dre', 'Soma de linhas da DRE'), ('variacao_conta', 'Variação de contas entre apurações (atual − anterior)'), ('indicador', 'Referência a outro indicador')], max_length=20, verbose_name='Tipo')),
|
||||
('contas_codigos', models.JSONField(blank=True, default=list, verbose_name='Códigos de conta')),
|
||||
('linhas_dre_descricoes', models.JSONField(blank=True, default=list, verbose_name='Descrições de linha da DRE')),
|
||||
('indicador_referenciado', models.CharField(blank=True, max_length=60, verbose_name='Indicador referenciado')),
|
||||
('definicao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='componentes', to='portal_api.indicadorcontabildefinicao')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Componente de indicador (Dashboard Contábil)',
|
||||
'verbose_name_plural': 'Componentes de indicador (Dashboard Contábil)',
|
||||
'ordering': ['id'],
|
||||
'constraints': [models.UniqueConstraint(fields=('definicao', 'chave'), name='contabil_componente_chave_unica')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.7 on 2026-09-09 18:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('portal_api', '0060_indicadorcontabildefinicao_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contabilapuracao',
|
||||
name='indicadores_selecionados',
|
||||
field=models.JSONField(blank=True, default=list, verbose_name='Indicadores não padrão selecionados'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='indicadorcontabildefinicao',
|
||||
name='padrao',
|
||||
field=models.BooleanField(default=True, verbose_name='Indicador padrão (aparece em todas as apurações)'),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.7 on 2026-09-09 19:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('portal_api', '0061_contabilapuracao_indicadores_selecionados_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='indicadorcontabildefinicao',
|
||||
name='grupo',
|
||||
field=models.CharField(default='Indicadores Personalizados', max_length=120, verbose_name='Grupo de exibição'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='indicadorcontabilcomponente',
|
||||
name='tipo',
|
||||
field=models.CharField(choices=[('contas', 'Soma de contas do Balancete'), ('linha_dre', 'Soma de linhas da DRE'), ('variacao_conta', 'Variação de contas entre apurações (atual − anterior)'), ('indicador', 'Referência a outro indicador'), ('resultado_liquido', 'Resultado Líquido da DRE (última linha)')], max_length=20, verbose_name='Tipo'),
|
||||
),
|
||||
]
|
||||
@ -5,6 +5,7 @@ from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import File
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models.functions import Length
|
||||
from django.utils import timezone
|
||||
@ -1986,6 +1987,16 @@ class ContabilApuracao(models.Model):
|
||||
criado_por = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, related_name="apuracoes_contabeis")
|
||||
criado_em = models.DateTimeField(auto_now_add=True)
|
||||
concluida_em = models.DateTimeField("Concluída em", null=True, blank=True)
|
||||
# Chaves de IndicadoresFinanceiros (dashboard_contabil/indicadores.py,
|
||||
# ex. "roa"/"kanitz") que o contador optou por não incluir no relatório
|
||||
# "Gerar Dashboard" — ver aba "Dashboard" da tela de revisão.
|
||||
indicadores_ocultos = models.JSONField("Indicadores ocultos no relatório", default=list, blank=True)
|
||||
# Chaves de IndicadorContabilDefinicao com padrao=False que o contador
|
||||
# ativou especificamente pra esta apuração — um indicador não padrão
|
||||
# não aparece em nenhuma apuração até ser selecionado assim numa delas
|
||||
# (ver "Gerenciar Indicadores" na aba "Dashboard"). Indicador padrao=True
|
||||
# não precisa (nem deveria) aparecer aqui, já entra sempre.
|
||||
indicadores_selecionados = models.JSONField("Indicadores não padrão selecionados", default=list, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Apuração do Dashboard Contábil"
|
||||
@ -2024,6 +2035,10 @@ class ContabilConta(models.Model):
|
||||
credito = models.DecimalField(max_digits=16, decimal_places=2)
|
||||
saldo_atual = models.DecimalField(max_digits=16, decimal_places=2)
|
||||
observacao = models.TextField("Observação", blank=True)
|
||||
# Não afeta a linha do Balancete em si (continua listada normalmente) —
|
||||
# só remove a observação da seção "Observações" do relatório "Gerar
|
||||
# Dashboard" (ver aba "Dashboard" da tela de revisão).
|
||||
oculta_no_relatorio = models.BooleanField("Observação oculta no relatório", default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Conta do Balancete (Dashboard Contábil)"
|
||||
@ -2046,6 +2061,9 @@ class ContabilLinhaDre(models.Model):
|
||||
valor = models.DecimalField(max_digits=16, decimal_places=2)
|
||||
totalizador = models.BooleanField("Linha totalizadora", default=False)
|
||||
observacao = models.TextField("Observação", blank=True)
|
||||
# Mesmo espírito de ContabilConta.oculta_no_relatorio — só afeta a
|
||||
# seção "Observações" do relatório "Gerar Dashboard".
|
||||
oculta_no_relatorio = models.BooleanField("Observação oculta no relatório", default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Linha da DRE (Dashboard Contábil)"
|
||||
@ -2097,6 +2115,11 @@ class ContabilAchado(models.Model):
|
||||
Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="achados_contabeis_tratados"
|
||||
)
|
||||
tratado_em = models.DateTimeField("Tratado em", null=True, blank=True)
|
||||
# Mesmo espírito de ContabilConta.oculta_no_relatorio — só afeta a
|
||||
# seção "Observações" do relatório "Gerar Dashboard" (relevante só pra
|
||||
# achados com observacao_contador preenchida, que são os que aparecem
|
||||
# lá); não afeta o status/tratativa do achado em si.
|
||||
oculto_no_relatorio = models.BooleanField("Observação oculta no relatório", default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Achado do Dashboard Contábil"
|
||||
@ -2105,3 +2128,136 @@ class ContabilAchado(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.titulo} ({self.get_status_display()})"
|
||||
|
||||
|
||||
class IndicadorContabilDefinicao(models.Model):
|
||||
"""Um indicador financeiro do Dashboard Contábil — todo indicador é um
|
||||
registro aqui agora, inclusive os antigos 11 "de sistema" (ROA/ROE/
|
||||
Kanitz/EBIT/EBITDA/Liquidez.../Composição.../Grau.../IPL), migrados pra
|
||||
cá numa rodada posterior à criação deste modelo (a pedido explícito do
|
||||
usuário, depois de conferir cada um contra uma apuração real — ver
|
||||
CLAUDE.md do pacote, "Migração dos 11 indicadores de sistema"). O
|
||||
cálculo Python fixo que existia antes (`dashboard_contabil.indicadores.
|
||||
calcula_indicadores`) foi mantido no código só como referência/
|
||||
auditoria, não é mais chamado pela aplicação.
|
||||
|
||||
`formula` é uma expressão aritmética (+ - * / e parênteses) sobre as
|
||||
`chave`s dos `componentes` abaixo e/ou de outro indicador (por chave) —
|
||||
avaliada por `dashboard_contabil.formula.avalia_formula` (interpretador
|
||||
restrito via `ast`, nunca `eval()` puro). Se qualquer componente
|
||||
referenciado ficar indisponível (ex.: variação sem apuração anterior)
|
||||
ou a fórmula dividir por zero, o resultado é `None` ("indisponível"),
|
||||
nunca 0."""
|
||||
|
||||
FORMATO_MOEDA = "moeda"
|
||||
FORMATO_PERCENTUAL = "percentual"
|
||||
FORMATO_INDICE = "indice"
|
||||
FORMATO_CHOICES = [
|
||||
(FORMATO_MOEDA, "Moeda (R$)"),
|
||||
(FORMATO_PERCENTUAL, "Percentual (%)"),
|
||||
(FORMATO_INDICE, "Índice"),
|
||||
]
|
||||
|
||||
chave = models.SlugField("Chave", max_length=60, unique=True)
|
||||
nome = models.CharField("Nome", max_length=120)
|
||||
descricao = models.TextField("Descrição", blank=True)
|
||||
formula = models.CharField("Fórmula", max_length=500)
|
||||
formato = models.CharField("Formato de exibição", max_length=12, choices=FORMATO_CHOICES, default=FORMATO_INDICE)
|
||||
# Livre, só pra agrupar visualmente no relatório/aba "Dashboard" (ver
|
||||
# `indicadores_personalizados`/`renderDashboardIndicadores()`) — não
|
||||
# exposto no formulário de criar/editar (sempre "Indicadores
|
||||
# Personalizados" pra quem é criado pela tela); só os indicadores
|
||||
# migrados dos antigos "de sistema" (ROA/ROE/Kanitz/...) têm um grupo
|
||||
# diferente, setado uma vez na migração de dados, nunca reeditável pela
|
||||
# tela.
|
||||
grupo = models.CharField("Grupo de exibição", max_length=120, default="Indicadores Personalizados")
|
||||
# True (default) = aparece automaticamente em toda apuração. False =
|
||||
# fica salvo/editável normalmente, mas só entra numa apuração
|
||||
# específica se o contador selecionar ali (ver
|
||||
# ContabilApuracao.indicadores_selecionados) — pra indicador que só faz
|
||||
# sentido pra um cliente/situação específica, não pra todo mundo.
|
||||
padrao = models.BooleanField("Indicador padrão (aparece em todas as apurações)", default=True)
|
||||
criado_por = models.ForeignKey(
|
||||
Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="indicadores_contabeis_criados"
|
||||
)
|
||||
criado_em = models.DateTimeField(auto_now_add=True)
|
||||
atualizado_em = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Indicador do Dashboard Contábil (personalizado)"
|
||||
verbose_name_plural = "Indicadores do Dashboard Contábil (personalizados)"
|
||||
ordering = ["nome"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.nome
|
||||
|
||||
|
||||
class IndicadorContabilComponente(models.Model):
|
||||
"""Uma peça usada na fórmula de um `IndicadorContabilDefinicao` — seu
|
||||
`chave` é o nome usado dentro da expressão (`formula`). Contas/linhas da
|
||||
DRE são guardadas por **código de classificação**/**descrição**, não por
|
||||
FK a uma linha de uma apuração específica — a definição é genérica,
|
||||
reaplicada a cada apuração/empresa que o relatório for gerado, e
|
||||
apurações diferentes têm suas próprias linhas com os mesmos códigos/
|
||||
descrições (quando a empresa usa o mesmo plano de contas)."""
|
||||
|
||||
TIPO_CONTAS = "contas"
|
||||
TIPO_LINHA_DRE = "linha_dre"
|
||||
TIPO_VARIACAO_CONTA = "variacao_conta"
|
||||
TIPO_INDICADOR = "indicador"
|
||||
TIPO_RESULTADO_LIQUIDO = "resultado_liquido"
|
||||
TIPO_CHOICES = [
|
||||
(TIPO_CONTAS, "Soma de contas do Balancete"),
|
||||
(TIPO_LINHA_DRE, "Soma de linhas da DRE"),
|
||||
(TIPO_VARIACAO_CONTA, "Variação de contas entre apurações (atual − anterior)"),
|
||||
(TIPO_INDICADOR, "Referência a outro indicador"),
|
||||
(TIPO_RESULTADO_LIQUIDO, "Resultado Líquido da DRE (última linha)"),
|
||||
]
|
||||
|
||||
definicao = models.ForeignKey(IndicadorContabilDefinicao, on_delete=models.CASCADE, related_name="componentes")
|
||||
# Precisa ser um identificador Python válido (letras minúsculas, dígitos
|
||||
# e "_", nunca começando por dígito) — é usado como nome de variável na
|
||||
# árvore `ast` de `formula.avalia_formula()`, não um slug qualquer (que
|
||||
# aceitaria hífen, inválido como nome de variável).
|
||||
chave = models.CharField(
|
||||
"Chave",
|
||||
max_length=40,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
r"^[a-z][a-z0-9_]*$",
|
||||
"Use só letras minúsculas, números e '_', começando por uma letra (é usada na fórmula).",
|
||||
)
|
||||
],
|
||||
)
|
||||
tipo = models.CharField("Tipo", max_length=20, choices=TIPO_CHOICES)
|
||||
# Usados só quando tipo é "contas"/"variacao_conta": lista de
|
||||
# ContabilConta.codigo (ex. ["1.01.08", "1.01.08.001"]) — somados em
|
||||
# valor absoluto, mesma convenção de `indicadores._saldo()` (Passivo/PL
|
||||
# vêm negativos no relatório).
|
||||
contas_codigos = models.JSONField("Códigos de conta", default=list, blank=True)
|
||||
# Usado só quando tipo é "linha_dre": lista de ContabilLinhaDre.descricao
|
||||
# (texto exato) — somadas com o sinal já impresso no relatório (DRE não
|
||||
# segue a convenção de valor absoluto do Balancete).
|
||||
linhas_dre_descricoes = models.JSONField("Descrições de linha da DRE", default=list, blank=True)
|
||||
# Usado só quando tipo é "indicador": chave de outro
|
||||
# IndicadorContabilDefinicao já cadastrado.
|
||||
indicador_referenciado = models.CharField("Indicador referenciado", max_length=60, blank=True)
|
||||
# tipo="resultado_liquido" não usa nenhum dos 3 campos acima — resolve
|
||||
# sempre pra `linhas_dre[-1].valor` (posição, não texto). Necessário
|
||||
# porque a última linha da DRE muda de rótulo conforme o resultado é
|
||||
# positivo ou negativo ("(=) LUCRO LÍQUIDO DO EXERCÍCIO" vs "(=)
|
||||
# PREJUÍZO LÍQUIDO DO EXERCÍCIO", confirmado contra balancete real) —
|
||||
# um componente tipo="linha_dre" (que casa por texto exato) quebraria
|
||||
# assim que o resultado da empresa virasse de sinal entre uma apuração
|
||||
# e outra.
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Componente de indicador (Dashboard Contábil)"
|
||||
verbose_name_plural = "Componentes de indicador (Dashboard Contábil)"
|
||||
ordering = ["id"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["definicao", "chave"], name="contabil_componente_chave_unica")
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.chave} ({self.get_tipo_display()})"
|
||||
|
||||
@ -36,6 +36,8 @@ from .models import (
|
||||
IndicadorApuracaoColaborador,
|
||||
IndicadorApuracaoEmpresa,
|
||||
IndicadorApuracaoResposta,
|
||||
IndicadorContabilComponente,
|
||||
IndicadorContabilDefinicao,
|
||||
IndicadorCriterio,
|
||||
IndicadorDepartamento,
|
||||
IndicadorDepartamentoGerente,
|
||||
@ -58,6 +60,7 @@ from .models import (
|
||||
WidgetUsuario,
|
||||
)
|
||||
from .custo_contratacao.calculo import EntradaSimulacaoEmpregado
|
||||
from .dashboard_contabil import formula as dashboard_contabil_formula
|
||||
from .planos_saude.leiaute_sistema import formata_valor_br, parse_valor_br
|
||||
from .planos_saude.pipeline import CUSTEIOS_VALIDOS, OPERADORAS, TIPOS_LANCAMENTO_VALIDOS, TIPOS_PESSOA_VALIDOS
|
||||
from .planos_saude.regras_empresa import REGRAS_EMPRESA
|
||||
@ -2243,6 +2246,7 @@ class ContabilContaSerializer(serializers.ModelSerializer):
|
||||
"credito",
|
||||
"saldo_atual",
|
||||
"observacao",
|
||||
"oculta_no_relatorio",
|
||||
]
|
||||
read_only_fields = ["id", "conta_numero", "codigo", "descricao", "tipo", "saldo_anterior", "debito", "credito", "saldo_atual"]
|
||||
|
||||
@ -2250,7 +2254,7 @@ class ContabilContaSerializer(serializers.ModelSerializer):
|
||||
class ContabilLinhaDreSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ContabilLinhaDre
|
||||
fields = ["id", "ordem", "descricao", "nivel", "valor", "totalizador", "observacao"]
|
||||
fields = ["id", "ordem", "descricao", "nivel", "valor", "totalizador", "observacao", "oculta_no_relatorio"]
|
||||
read_only_fields = ["id", "ordem", "descricao", "nivel", "valor", "totalizador"]
|
||||
|
||||
|
||||
@ -2275,6 +2279,7 @@ class ContabilAchadoSerializer(serializers.ModelSerializer):
|
||||
"observacao_contador",
|
||||
"tratado_por_nome",
|
||||
"tratado_em",
|
||||
"oculto_no_relatorio",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
@ -2288,6 +2293,7 @@ class ContabilAchadoSerializer(serializers.ModelSerializer):
|
||||
"valor_referencia",
|
||||
"tratado_por_nome",
|
||||
"tratado_em",
|
||||
"oculto_no_relatorio",
|
||||
]
|
||||
|
||||
|
||||
@ -2299,6 +2305,186 @@ class ContabilAchadoAjusteSerializer(serializers.Serializer):
|
||||
observacao_contador = serializers.CharField(allow_blank=False)
|
||||
|
||||
|
||||
class ContabilAchadoOcultoSerializer(serializers.Serializer):
|
||||
"""Corpo de `ContabilAchadoViewSet.alternar_oculto()` — separado de
|
||||
`ContabilAchadoAjusteSerializer` porque ocultar do relatório não deve
|
||||
exigir status/justificativa (mesmo achado pode continuar "Pendente" e
|
||||
ainda assim ter sua observação escondida do relatório, ver aba
|
||||
"Dashboard" da tela de revisão)."""
|
||||
|
||||
oculto_no_relatorio = serializers.BooleanField()
|
||||
|
||||
|
||||
class ContabilIndicadoresOcultosSerializer(serializers.Serializer):
|
||||
"""Corpo de `ContabilApuracaoViewSet.indicadores_ocultos()` — substitui
|
||||
a lista inteira de cards escondidos do relatório de uma vez (o
|
||||
frontend já tem a lista atual, só alterna uma chave e reenvia). Aceita
|
||||
a chave de qualquer `IndicadorContabilDefinicao` já cadastrada
|
||||
(inclusive os antigos 11 "de sistema", migrados pra cá) — por isso não
|
||||
dá pra usar um `ChoiceField` com uma lista fixa de opções, a validação
|
||||
roda contra o banco em `validate_indicadores_ocultos`.
|
||||
|
||||
Chave que não existe mais (indicador excluído depois de ter sido
|
||||
ocultado nesta apuração) é descartada silenciosamente em vez de
|
||||
rejeitar a lista inteira — é só estado de exibição, não dado
|
||||
auditado, e o frontend sempre reenvia a lista completa a cada
|
||||
alternância; rejeitar travaria até alternar qualquer outra chave
|
||||
(ver bug real, rodada 106: `perform_destroy` de
|
||||
`IndicadorContabilDefinicaoViewSet` já limpa essa referência na
|
||||
exclusão, isto aqui é a rede de segurança para o que ficou órfão
|
||||
antes dessa limpeza existir)."""
|
||||
|
||||
indicadores_ocultos = serializers.ListField(child=serializers.CharField(), allow_empty=True)
|
||||
|
||||
def validate_indicadores_ocultos(self, chaves: list[str]) -> list[str]:
|
||||
chaves_validas = set(IndicadorContabilDefinicao.objects.values_list("chave", flat=True))
|
||||
return [chave for chave in chaves if chave in chaves_validas]
|
||||
|
||||
|
||||
class ContabilIndicadoresSelecionadosSerializer(serializers.Serializer):
|
||||
"""Corpo de `ContabilApuracaoViewSet.indicadores_selecionados()` —
|
||||
substitui a lista inteira de indicadores **não padrão** ativados nesta
|
||||
apuração de uma vez (mesmo espírito de `ContabilIndicadoresOcultosSerializer`).
|
||||
Só aceita chave de indicador com `padrao=False`: um indicador padrão já
|
||||
aparece sempre, não faz sentido "selecioná-lo" aqui.
|
||||
|
||||
Chave que não existe mais, ou que deixou de ser não padrão, é
|
||||
descartada silenciosamente em vez de rejeitar a lista inteira — mesmo
|
||||
raciocínio de `ContabilIndicadoresOcultosSerializer.validate_indicadores_ocultos`
|
||||
acima."""
|
||||
|
||||
indicadores_selecionados = serializers.ListField(child=serializers.CharField(), allow_empty=True)
|
||||
|
||||
def validate_indicadores_selecionados(self, chaves: list[str]) -> list[str]:
|
||||
chaves_nao_padrao = set(
|
||||
IndicadorContabilDefinicao.objects.filter(padrao=False).values_list("chave", flat=True)
|
||||
)
|
||||
return [chave for chave in chaves if chave in chaves_nao_padrao]
|
||||
|
||||
|
||||
class IndicadorContabilComponenteSerializer(serializers.ModelSerializer):
|
||||
"""Só leitura — usada aninhada em `IndicadorContabilDefinicaoSerializer`
|
||||
pra mostrar os componentes já salvos de um indicador personalizado.
|
||||
Escrita passa por `IndicadorContabilComponenteInputSerializer` abaixo,
|
||||
processada manualmente em `IndicadorContabilDefinicaoViewSet` (troca
|
||||
todos os componentes de uma vez a cada criação/edição — mais simples e
|
||||
seguro do que tentar diferenciar quais mudaram)."""
|
||||
|
||||
tipo_label = serializers.CharField(source="get_tipo_display", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IndicadorContabilComponente
|
||||
fields = [
|
||||
"id",
|
||||
"chave",
|
||||
"tipo",
|
||||
"tipo_label",
|
||||
"contas_codigos",
|
||||
"linhas_dre_descricoes",
|
||||
"indicador_referenciado",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IndicadorContabilDefinicaoSerializer(serializers.ModelSerializer):
|
||||
"""Só leitura — resposta de GET/POST/PATCH de
|
||||
`IndicadorContabilDefinicaoViewSet` (a escrita em si é validada por
|
||||
`IndicadorContabilDefinicaoInputSerializer`, ver abaixo)."""
|
||||
|
||||
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
|
||||
componentes = IndicadorContabilComponenteSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = IndicadorContabilDefinicao
|
||||
fields = [
|
||||
"id",
|
||||
"chave",
|
||||
"nome",
|
||||
"descricao",
|
||||
"formula",
|
||||
"formato",
|
||||
"grupo",
|
||||
"padrao",
|
||||
"criado_por_nome",
|
||||
"criado_em",
|
||||
"atualizado_em",
|
||||
"componentes",
|
||||
]
|
||||
|
||||
|
||||
class IndicadorContabilComponenteInputSerializer(serializers.Serializer):
|
||||
chave = serializers.RegexField(
|
||||
r"^[a-z][a-z0-9_]*$",
|
||||
max_length=40,
|
||||
error_messages={"invalid": "Use só letras minúsculas, números e '_', começando por uma letra."},
|
||||
)
|
||||
tipo = serializers.ChoiceField(choices=[t[0] for t in IndicadorContabilComponente.TIPO_CHOICES])
|
||||
contas_codigos = serializers.ListField(child=serializers.CharField(), required=False, default=list)
|
||||
linhas_dre_descricoes = serializers.ListField(child=serializers.CharField(), required=False, default=list)
|
||||
indicador_referenciado = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
|
||||
|
||||
class IndicadorContabilDefinicaoInputSerializer(serializers.Serializer):
|
||||
"""Corpo de POST/PATCH em `IndicadorContabilDefinicaoViewSet` — sempre o
|
||||
indicador **inteiro** (nome/descrição/fórmula/formato + a lista completa
|
||||
de componentes), mesmo em PATCH: um indicador personalizado é pequeno
|
||||
o bastante (poucos componentes) pra não valer a pena um endpoint de
|
||||
edição parcial por componente, e evita ter que decidir "o que muda,
|
||||
o que fica" numa edição incremental. `chave` nunca é aceita aqui — é
|
||||
sempre derivada do `nome` na criação (ver `IndicadorContabilDefinicaoViewSet`),
|
||||
imutável depois (mudar a chave quebraria qualquer `indicadores_ocultos`/
|
||||
referência de outro indicador já salva)."""
|
||||
|
||||
nome = serializers.CharField(max_length=120)
|
||||
descricao = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
formula = serializers.CharField(max_length=500)
|
||||
formato = serializers.ChoiceField(choices=[f[0] for f in IndicadorContabilDefinicao.FORMATO_CHOICES])
|
||||
# True (default) = aparece automaticamente em toda apuração, mesmo
|
||||
# comportamento de sempre; False = fica salvo, só aparece nas apurações
|
||||
# em que o contador selecionar (ver "Gerenciar Indicadores").
|
||||
padrao = serializers.BooleanField(required=False, default=True)
|
||||
componentes = IndicadorContabilComponenteInputSerializer(many=True)
|
||||
|
||||
def validate_componentes(self, componentes: list[dict]) -> list[dict]:
|
||||
if not componentes:
|
||||
raise serializers.ValidationError("Adicione pelo menos um componente.")
|
||||
chaves = [c["chave"] for c in componentes]
|
||||
if len(chaves) != len(set(chaves)):
|
||||
raise serializers.ValidationError("Cada componente precisa de uma chave única dentro do indicador.")
|
||||
|
||||
chaves_indicadores_existentes = set(
|
||||
IndicadorContabilDefinicao.objects.exclude(
|
||||
pk=self.instance.pk if self.instance else None
|
||||
).values_list("chave", flat=True)
|
||||
)
|
||||
for componente in componentes:
|
||||
chave = componente["chave"]
|
||||
tipo = componente["tipo"]
|
||||
if tipo in (IndicadorContabilComponente.TIPO_CONTAS, IndicadorContabilComponente.TIPO_VARIACAO_CONTA):
|
||||
if not componente.get("contas_codigos"):
|
||||
raise serializers.ValidationError(f'Componente "{chave}": selecione ao menos uma conta.')
|
||||
elif tipo == IndicadorContabilComponente.TIPO_LINHA_DRE:
|
||||
if not componente.get("linhas_dre_descricoes"):
|
||||
raise serializers.ValidationError(f'Componente "{chave}": selecione ao menos uma linha da DRE.')
|
||||
elif tipo == IndicadorContabilComponente.TIPO_INDICADOR:
|
||||
referenciado = componente.get("indicador_referenciado")
|
||||
if not referenciado:
|
||||
raise serializers.ValidationError(f'Componente "{chave}": selecione o indicador referenciado.')
|
||||
if referenciado not in chaves_indicadores_existentes:
|
||||
raise serializers.ValidationError(
|
||||
f'Componente "{chave}": o indicador "{referenciado}" não existe.'
|
||||
)
|
||||
return componentes
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
chaves_componentes = {c["chave"] for c in attrs["componentes"]}
|
||||
try:
|
||||
dashboard_contabil_formula.valida_formula(attrs["formula"], chaves_componentes)
|
||||
except dashboard_contabil_formula.FormulaInvalidaError as exc:
|
||||
raise serializers.ValidationError({"formula": str(exc)}) from exc
|
||||
return attrs
|
||||
|
||||
|
||||
class ContabilApuracaoListSerializer(serializers.ModelSerializer):
|
||||
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
|
||||
total_achados_pendentes = serializers.SerializerMethodField()
|
||||
|
||||
@ -99,6 +99,11 @@ router.register("contabil-apuracoes", views.ContabilApuracaoViewSet, basename="c
|
||||
router.register("contabil-contas", views.ContabilContaViewSet, basename="contabil-conta")
|
||||
router.register("contabil-linhas-dre", views.ContabilLinhaDreViewSet, basename="contabil-linha-dre")
|
||||
router.register("contabil-achados", views.ContabilAchadoViewSet, basename="contabil-achado")
|
||||
router.register(
|
||||
"contabil-indicadores-definicoes",
|
||||
views.IndicadorContabilDefinicaoViewSet,
|
||||
basename="contabil-indicador-definicao",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("auth/csrf/", views.csrf_view, name="auth-csrf"),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import calendar
|
||||
import csv
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
@ -22,6 +23,7 @@ from django.shortcuts import get_object_or_404
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils.text import slugify
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
@ -35,8 +37,10 @@ from . import catalogo
|
||||
from .custo_contratacao.calculo import calcula_custo_empregado
|
||||
from .custo_contratacao.pdf import gera_pdf_simulacao
|
||||
from .dashboard_contabil import exportacao as dashboard_contabil_exportacao
|
||||
from .dashboard_contabil import formula as dashboard_contabil_formula
|
||||
from .dashboard_contabil import indicadores as dashboard_contabil_indicadores
|
||||
from .dashboard_contabil import pipeline as dashboard_contabil_pipeline
|
||||
from .templatetags import contabil_extras as dashboard_contabil_extras
|
||||
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
|
||||
from .dashboard_contabil.modelos import SnapshotHistorico as ContabilSnapshotHistorico
|
||||
from .dashboard_contabil.parser import ExtracaoInvalidaError as ContabilExtracaoInvalidaError
|
||||
@ -67,6 +71,8 @@ from .models import (
|
||||
IndicadorApuracaoColaborador,
|
||||
IndicadorApuracaoEmpresa,
|
||||
IndicadorApuracaoResposta,
|
||||
IndicadorContabilComponente,
|
||||
IndicadorContabilDefinicao,
|
||||
IndicadorCriterio,
|
||||
IndicadorDepartamento,
|
||||
IndicadorDepartamentoGerente,
|
||||
@ -116,11 +122,14 @@ from .serializers import (
|
||||
CategoriaEventoSerializer,
|
||||
CompromissoAgendaSerializer,
|
||||
ContabilAchadoAjusteSerializer,
|
||||
ContabilAchadoOcultoSerializer,
|
||||
ContabilAchadoSerializer,
|
||||
ContabilApuracaoCreateSerializer,
|
||||
ContabilApuracaoDetailSerializer,
|
||||
ContabilApuracaoListSerializer,
|
||||
ContabilContaSerializer,
|
||||
ContabilIndicadoresOcultosSerializer,
|
||||
ContabilIndicadoresSelecionadosSerializer,
|
||||
ContabilLinhaDreSerializer,
|
||||
DepartamentoSerializer,
|
||||
FavoritoSerializer,
|
||||
@ -155,6 +164,8 @@ from .serializers import (
|
||||
IndicadorApuracaoRecalcularGrupoSerializer,
|
||||
IndicadorApuracaoRespostaLoteSerializer,
|
||||
IndicadorApuracaoRespostaSerializer,
|
||||
IndicadorContabilDefinicaoInputSerializer,
|
||||
IndicadorContabilDefinicaoSerializer,
|
||||
IndicadorCriterioSerializer,
|
||||
IndicadorDepartamentoGerenteSerializer,
|
||||
IndicadorDepartamentoSerializer,
|
||||
@ -3364,6 +3375,257 @@ def _contabil_monta_historico_completo(codigo_empresa: str, competencia_atual: d
|
||||
]
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _ContabilDadosIndicadores:
|
||||
"""Dados brutos de uma apuração já resolvidos do banco, compartilhados
|
||||
pelo cálculo dos indicadores de sistema (`_contabil_calcula_indicadores`)
|
||||
e dos personalizados (`_contabil_calcula_indicadores_personalizados`) —
|
||||
evita duas idas ao banco pelos mesmos dados quando as duas telas que
|
||||
calculam indicador (`dashboard()`/`indicadores()`) pedem os dois
|
||||
cálculos juntos."""
|
||||
|
||||
contas_atuais: dict[str, Decimal]
|
||||
contas_anteriores: dict[str, Decimal] | None
|
||||
dre_atual: dict[str, Decimal]
|
||||
resultado_liquido: Decimal
|
||||
historico_completo: list[ContabilSnapshotHistorico]
|
||||
|
||||
|
||||
def _contabil_coleta_dados_indicadores(apuracao: ContabilApuracao) -> _ContabilDadosIndicadores:
|
||||
contas = list(apuracao.contas.order_by("ordem"))
|
||||
linhas_dre = list(apuracao.linhas_dre.order_by("ordem"))
|
||||
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)
|
||||
# `variacao_conta` (componente de indicador personalizado, ver abaixo)
|
||||
# só compara contra a apuração anterior imediata — mesma convenção já
|
||||
# usada pra Depreciação/Amortização em `indicadores.calcula_indicadores`
|
||||
# (`historico[0]`).
|
||||
contas_anteriores = historico_completo[0].contas if historico_completo else None
|
||||
return _ContabilDadosIndicadores(contas_atuais, contas_anteriores, dre_atual, resultado_liquido, historico_completo)
|
||||
|
||||
|
||||
def _contabil_calcula_indicadores(
|
||||
dados: _ContabilDadosIndicadores,
|
||||
) -> dashboard_contabil_indicadores.IndicadoresFinanceiros:
|
||||
"""**Não é mais chamada por `ContabilApuracaoViewSet.dashboard()`/
|
||||
`.indicadores()`** desde que os 11 indicadores "de sistema" foram
|
||||
migrados pra `IndicadorContabilDefinicao` de verdade — mantida só como
|
||||
referência/auditoria (ver docstring do módulo `dashboard_contabil.
|
||||
indicadores`), útil pra recalcular e conferir manualmente se algum
|
||||
valor exibido um dia parecer suspeito."""
|
||||
return dashboard_contabil_indicadores.calcula_indicadores(
|
||||
dados.contas_atuais, dados.dre_atual, dados.resultado_liquido, dados.historico_completo
|
||||
)
|
||||
|
||||
|
||||
def _contabil_resolve_componente_personalizado(
|
||||
componente: IndicadorContabilComponente,
|
||||
dados: _ContabilDadosIndicadores,
|
||||
valores: dict[str, Decimal | None],
|
||||
) -> Decimal | None:
|
||||
"""Um componente de indicador personalizado (`IndicadorContabilComponente`),
|
||||
resolvido pro valor que entra na fórmula (ver `dashboard_contabil.formula`).
|
||||
Contas somadas em valor absoluto — mesma convenção de
|
||||
`dashboard_contabil.indicadores._saldo()` (Passivo/Patrimônio Líquido
|
||||
vêm negativos no relatório); linhas da DRE somadas com o sinal já
|
||||
impresso (a DRE não segue essa convenção de valor absoluto)."""
|
||||
if componente.tipo == IndicadorContabilComponente.TIPO_CONTAS:
|
||||
if not componente.contas_codigos:
|
||||
return None
|
||||
return sum(
|
||||
(abs(dados.contas_atuais.get(codigo, Decimal(0))) for codigo in componente.contas_codigos), Decimal(0)
|
||||
)
|
||||
if componente.tipo == IndicadorContabilComponente.TIPO_LINHA_DRE:
|
||||
if not componente.linhas_dre_descricoes:
|
||||
return None
|
||||
return sum(
|
||||
(dados.dre_atual.get(descricao, Decimal(0)) for descricao in componente.linhas_dre_descricoes),
|
||||
Decimal(0),
|
||||
)
|
||||
if componente.tipo == IndicadorContabilComponente.TIPO_VARIACAO_CONTA:
|
||||
if dados.contas_anteriores is None or not componente.contas_codigos:
|
||||
return None
|
||||
atual = sum(
|
||||
(abs(dados.contas_atuais.get(codigo, Decimal(0))) for codigo in componente.contas_codigos), Decimal(0)
|
||||
)
|
||||
anterior = sum(
|
||||
(abs(dados.contas_anteriores.get(codigo, Decimal(0))) for codigo in componente.contas_codigos),
|
||||
Decimal(0),
|
||||
)
|
||||
return atual - anterior
|
||||
if componente.tipo == IndicadorContabilComponente.TIPO_INDICADOR:
|
||||
return valores.get(componente.indicador_referenciado)
|
||||
if componente.tipo == IndicadorContabilComponente.TIPO_RESULTADO_LIQUIDO:
|
||||
return dados.resultado_liquido
|
||||
return None
|
||||
|
||||
|
||||
def _contabil_calcula_indicadores_personalizados(
|
||||
dados: _ContabilDadosIndicadores, indicadores_selecionados: list[str]
|
||||
) -> dict[str, Decimal | None]:
|
||||
"""Resolve todo `IndicadorContabilDefinicao` **aplicável a esta
|
||||
apuração** contra os dados dela — `padrao=True` sempre entra;
|
||||
`padrao=False` só entra se sua chave estiver em `indicadores_selecionados`
|
||||
(`ContabilApuracao.indicadores_selecionados`, ver "Gerenciar Indicadores"
|
||||
na aba "Dashboard").
|
||||
|
||||
Iterativo, não uma ordem topológica "de verdade": a cada rodada calcula
|
||||
todo indicador cujos componentes tipo="indicador" já têm valor
|
||||
conhecido (já resolvido numa rodada anterior), repete até não sobrar
|
||||
progresso — cobre encadeamento entre indicadores (um referenciando o
|
||||
outro, inclusive um dos antigos 11 "de sistema", hoje registros normais
|
||||
aqui). Um indicador cuja dependência nunca resolve (referência quebrada,
|
||||
referência a um indicador não padrão não selecionado nesta apuração, ou
|
||||
ciclo entre dois indicadores) fica com valor `None` — nunca levanta erro
|
||||
pro chamador, uma fórmula mal configurada não pode derrubar o cálculo do
|
||||
relatório inteiro."""
|
||||
definicoes = {
|
||||
d.chave: d
|
||||
for d in IndicadorContabilDefinicao.objects.prefetch_related("componentes").all()
|
||||
if d.padrao or d.chave in indicadores_selecionados
|
||||
}
|
||||
valores: dict[str, Decimal | None] = {}
|
||||
pendentes = dict(definicoes)
|
||||
progrediu = True
|
||||
while pendentes and progrediu:
|
||||
progrediu = False
|
||||
for chave in list(pendentes):
|
||||
definicao = pendentes[chave]
|
||||
componentes = list(definicao.componentes.all())
|
||||
dependencias = [
|
||||
c.indicador_referenciado for c in componentes if c.tipo == IndicadorContabilComponente.TIPO_INDICADOR
|
||||
]
|
||||
if any(dependencia not in valores for dependencia in dependencias):
|
||||
continue
|
||||
valores_componentes = {
|
||||
componente.chave: _contabil_resolve_componente_personalizado(componente, dados, valores)
|
||||
for componente in componentes
|
||||
}
|
||||
try:
|
||||
valores[chave] = dashboard_contabil_formula.avalia_formula(definicao.formula, valores_componentes)
|
||||
except dashboard_contabil_formula.FormulaInvalidaError:
|
||||
valores[chave] = None
|
||||
del pendentes[chave]
|
||||
progrediu = True
|
||||
for chave in pendentes:
|
||||
valores[chave] = None
|
||||
return {chave: valores[chave] for chave in definicoes}
|
||||
|
||||
|
||||
def _contabil_formata_indicador(valor: Decimal | None, formato: str) -> str:
|
||||
"""Formata o valor de um indicador do relatório "Gerar Dashboard" —
|
||||
todo indicador (inclusive os 11 antigos "de sistema", migrados pra
|
||||
`IndicadorContabilDefinicao` na rodada em que este comentário foi
|
||||
escrito) vem de uma definição com `formato` livre, e o Django Template
|
||||
Language não permite escolher um filtro (`moeda`/`percentual`/`indice`
|
||||
de `contabil_extras.py`) por nome vindo de uma variável — por isso todo
|
||||
valor chega no contexto já como texto pronto. Reaproveita as mesmas 3
|
||||
funções (um filtro de template continua sendo uma função Python comum,
|
||||
só registrada — chamável direto)."""
|
||||
if formato == IndicadorContabilDefinicao.FORMATO_MOEDA:
|
||||
return dashboard_contabil_extras.moeda(valor)
|
||||
if formato == IndicadorContabilDefinicao.FORMATO_PERCENTUAL:
|
||||
return dashboard_contabil_extras.percentual(valor)
|
||||
return dashboard_contabil_extras.indice(valor)
|
||||
|
||||
|
||||
def _contabil_numero_bruto(valor: Decimal | None) -> str:
|
||||
"""Espelha o filtro `numero_bruto` de `contabil_extras.py` — valor cru
|
||||
(sem formatação BR), só pra alimentar `data-count` da animação de
|
||||
contagem dos cards (ver `pidDcrAnimaContadores()` no template)."""
|
||||
return "" if valor is None else str(float(valor))
|
||||
|
||||
|
||||
_CONTABIL_INDICADOR_DATA_FORMAT = {
|
||||
IndicadorContabilDefinicao.FORMATO_MOEDA: "moeda",
|
||||
IndicadorContabilDefinicao.FORMATO_PERCENTUAL: "pct",
|
||||
IndicadorContabilDefinicao.FORMATO_INDICE: "idx",
|
||||
}
|
||||
|
||||
# Réplica visual do que os 11 indicadores "de sistema" tinham antes de
|
||||
# virarem `IndicadorContabilDefinicao` de verdade (migração da mesma
|
||||
# rodada) — cor por sinal/threshold só onde já era seguro sem inventar
|
||||
# limiar novo (ver CLAUDE.md do pacote, "Fórmulas"), destaque dourado
|
||||
# alternado, e as 2 notas fixas (a de EBITDA é dinâmica, tratada à parte
|
||||
# onde os cards são montados). Indicador personalizado criado depois disso
|
||||
# não entra em nenhum desses dicts — nasce sem cor/destaque/nota, mesmo
|
||||
# espírito de antes.
|
||||
_CONTABIL_INDICADOR_COR_REGRA = {
|
||||
"roa": "sign",
|
||||
"roe": "sign",
|
||||
"ebit": "sign",
|
||||
"ebitda": "sign",
|
||||
"liquidez_corrente": "liquidez",
|
||||
"liquidez_seca": "liquidez",
|
||||
"liquidez_geral": "liquidez",
|
||||
}
|
||||
_CONTABIL_INDICADOR_DOURADO = {"ebit", "ebitda", "composicao_endividamento", "grau_endividamento", "ipl"}
|
||||
_CONTABIL_INDICADOR_NOTA = {
|
||||
"kanitz": "Estimativa — fórmula padrão, não calibrada",
|
||||
"liquidez_geral": "Aproximado — ver observação no relatório técnico",
|
||||
}
|
||||
_CONTABIL_ORDEM_GRUPOS_INDICADORES = ["Indicadores de Resultado", "Indicadores de Liquidez e Endividamento"]
|
||||
|
||||
|
||||
def _contabil_monta_cards_indicadores(
|
||||
apuracao: ContabilApuracao, valores: dict[str, Decimal | None]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Monta a lista plana de cards de indicador (de qualquer
|
||||
`IndicadorContabilDefinicao` ativa nesta apuração — padrão, ou não
|
||||
padrão selecionada — e não escondida) pro relatório "Gerar Dashboard",
|
||||
já com valor formatado/cru/regra de cor/nota prontos pro template (ver
|
||||
`_contabil_agrupa_indicadores_cards()` logo abaixo pra o passo
|
||||
seguinte, agrupar por `grupo`)."""
|
||||
ocultos = apuracao.indicadores_ocultos
|
||||
selecionados = apuracao.indicadores_selecionados
|
||||
cards = []
|
||||
for definicao in IndicadorContabilDefinicao.objects.order_by("nome"):
|
||||
if not (definicao.padrao or definicao.chave in selecionados):
|
||||
continue
|
||||
if definicao.chave in ocultos:
|
||||
continue
|
||||
valor = valores.get(definicao.chave)
|
||||
nota = _CONTABIL_INDICADOR_NOTA.get(definicao.chave, "")
|
||||
if definicao.chave == "ebitda" and valor is None:
|
||||
nota = "Sem apuração anterior desta empresa"
|
||||
cards.append(
|
||||
{
|
||||
"chave": definicao.chave,
|
||||
"nome": definicao.nome,
|
||||
"grupo": definicao.grupo,
|
||||
"valor_formatado": _contabil_formata_indicador(valor, definicao.formato),
|
||||
"valor_bruto": _contabil_numero_bruto(valor),
|
||||
"data_format": _CONTABIL_INDICADOR_DATA_FORMAT.get(definicao.formato, "idx"),
|
||||
"cor_regra": _CONTABIL_INDICADOR_COR_REGRA.get(definicao.chave, ""),
|
||||
"dourado": definicao.chave in _CONTABIL_INDICADOR_DOURADO,
|
||||
"nota": nota,
|
||||
}
|
||||
)
|
||||
for indice, card in enumerate(cards):
|
||||
card["indice_animacao"] = indice
|
||||
return cards
|
||||
|
||||
|
||||
def _contabil_agrupa_indicadores_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Agrupa os cards já montados por `grupo` — "Indicadores de Resultado"
|
||||
e "Indicadores de Liquidez e Endividamento" (os 2 grupos que os 11
|
||||
migrados usam) sempre primeiro, nessa ordem; qualquer outro grupo
|
||||
(ex. "Indicadores Personalizados", ou um nome livre que um indicador
|
||||
novo venha a usar no futuro) entra depois, em ordem alfabética."""
|
||||
grupos: dict[str, list[dict[str, Any]]] = {}
|
||||
for card in cards:
|
||||
grupos.setdefault(card["grupo"], []).append(card)
|
||||
|
||||
def ordenacao(nome_grupo: str) -> tuple[int, str]:
|
||||
if nome_grupo in _CONTABIL_ORDEM_GRUPOS_INDICADORES:
|
||||
return (_CONTABIL_ORDEM_GRUPOS_INDICADORES.index(nome_grupo), nome_grupo)
|
||||
return (len(_CONTABIL_ORDEM_GRUPOS_INDICADORES), nome_grupo)
|
||||
|
||||
return [{"nome": nome, "cards": grupos[nome]} for nome in sorted(grupos, key=ordenacao)]
|
||||
|
||||
|
||||
def _contabil_arvore_contexto(
|
||||
itens: list[Any], chave: str, nivel_fn: Callable[[Any], int], px_por_nivel: int
|
||||
) -> list[dict[str, Any]]:
|
||||
@ -3543,38 +3805,170 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
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
|
||||
)
|
||||
dados = _contabil_coleta_dados_indicadores(apuracao)
|
||||
valores_indicadores = _contabil_calcula_indicadores_personalizados(dados, apuracao.indicadores_selecionados)
|
||||
|
||||
evolucao = [
|
||||
{
|
||||
"competencia": snapshot.competencia.strftime("%m/%Y"),
|
||||
"resultado_liquido": next(reversed(snapshot.linhas_dre.values()), Decimal(0)),
|
||||
}
|
||||
for snapshot in reversed(historico_completo)
|
||||
for snapshot in reversed(dados.historico_completo)
|
||||
]
|
||||
evolucao.append({"competencia": apuracao.competencia.strftime("%m/%Y"), "resultado_liquido": resultado_liquido})
|
||||
evolucao.append(
|
||||
{"competencia": apuracao.competencia.strftime("%m/%Y"), "resultado_liquido": dados.resultado_liquido}
|
||||
)
|
||||
|
||||
# Cards/observações que o contador escondeu na aba "Dashboard" da
|
||||
# tela de revisão (ver `indicadores_ocultos()`/`ContabilConta.
|
||||
# oculta_no_relatorio`/etc.) não entram no relatório gerado — a
|
||||
# linha/conta/achado em si continua listada normal no Balancete/
|
||||
# DRE/Observações da revisão, só a seção de observações/os cards de
|
||||
# indicador deste relatório final é que os omitem.
|
||||
cards = _contabil_monta_cards_indicadores(apuracao, valores_indicadores)
|
||||
contexto = {
|
||||
"apuracao": apuracao,
|
||||
"indicadores": indicadores,
|
||||
"indicadores_grupos": _contabil_agrupa_indicadores_cards(cards),
|
||||
"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],
|
||||
"observacoes_contas": [conta for conta in contas if conta.observacao and not conta.oculta_no_relatorio],
|
||||
"observacoes_dre": [linha for linha in linhas_dre if linha.observacao and not linha.oculta_no_relatorio],
|
||||
"achados_com_observacao": [
|
||||
achado for achado in achados if achado.observacao_contador and not achado.oculto_no_relatorio
|
||||
],
|
||||
"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"])
|
||||
def indicadores(self, request: Request, pk: str | None = None) -> Response:
|
||||
"""Valores de todo `IndicadorContabilDefinicao` ativo nesta apuração
|
||||
(padrão, ou não padrão selecionado — ver "Gerenciar Indicadores"),
|
||||
pra alimentar a aba "Dashboard" da tela de revisão — pré-visualização
|
||||
de quais cards vão pro relatório antes de gerá-lo de fato, junto da
|
||||
lista do que já está escondido (`indicadores_ocultos`, ver
|
||||
`indicadores_ocultos()` abaixo) e dos metadados de exibição de cada
|
||||
um (nome/grupo/formato/descrição/fórmula em texto — usados pelo
|
||||
botão "Ver fórmula"). Os antigos 11 indicadores "de sistema"
|
||||
(ROA/ROE/Kanitz/...) foram migrados pra `IndicadorContabilDefinicao`
|
||||
de verdade — não têm mais tratamento especial aqui, ver CLAUDE.md do
|
||||
pacote."""
|
||||
apuracao = self.get_object()
|
||||
dados = _contabil_coleta_dados_indicadores(apuracao)
|
||||
selecionados = apuracao.indicadores_selecionados
|
||||
valores = _contabil_calcula_indicadores_personalizados(dados, selecionados)
|
||||
|
||||
# Só os indicadores **ativos nesta apuração** entram aqui — a lista
|
||||
# completa (padrão + não padrão) fica na listagem própria de
|
||||
# definições (`IndicadorContabilDefinicaoViewSet`, usada pelo hub
|
||||
# "Gerenciar Indicadores").
|
||||
metadados = {}
|
||||
for definicao in IndicadorContabilDefinicao.objects.filter(
|
||||
Q(padrao=True) | Q(chave__in=selecionados)
|
||||
).order_by("nome"):
|
||||
metadados[definicao.chave] = {
|
||||
"nome": definicao.nome,
|
||||
"grupo": definicao.grupo,
|
||||
"formato": definicao.formato,
|
||||
"descricao": definicao.descricao,
|
||||
"formula_texto": definicao.formula,
|
||||
"definicao_id": definicao.id,
|
||||
}
|
||||
|
||||
return Response(
|
||||
{
|
||||
"indicadores": valores,
|
||||
"indicadores_ocultos": apuracao.indicadores_ocultos,
|
||||
"indicadores_selecionados": selecionados,
|
||||
"metadados": metadados,
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="indicadores-ocultos")
|
||||
def indicadores_ocultos(self, request: Request, pk: str | None = None) -> Response:
|
||||
"""Substitui a lista inteira de cards de indicador escondidos do
|
||||
relatório "Gerar Dashboard" (aba "Dashboard" da tela de revisão) —
|
||||
o frontend já tem a lista atual (via `indicadores()` acima), só
|
||||
alterna uma chave e reenvia a lista inteira, mesmo espírito de
|
||||
`ContabilAchadoViewSet.alternar_oculto()` pras observações."""
|
||||
apuracao = self.get_object()
|
||||
_contabil_garante_em_revisao(apuracao)
|
||||
entrada = ContabilIndicadoresOcultosSerializer(data=request.data)
|
||||
entrada.is_valid(raise_exception=True)
|
||||
apuracao.indicadores_ocultos = entrada.validated_data["indicadores_ocultos"]
|
||||
apuracao.save(update_fields=["indicadores_ocultos"])
|
||||
return Response({"indicadores_ocultos": apuracao.indicadores_ocultos})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="indicadores-selecionados")
|
||||
def indicadores_selecionados(self, request: Request, pk: str | None = None) -> Response:
|
||||
"""Substitui a lista inteira de indicadores **não padrão** ativados
|
||||
nesta apuração de uma vez (mesmo espírito de `indicadores_ocultos()`
|
||||
acima) — indicador com `padrao=True` não precisa passar por aqui,
|
||||
já entra sempre. Ver "Gerenciar Indicadores" na aba "Dashboard"."""
|
||||
apuracao = self.get_object()
|
||||
_contabil_garante_em_revisao(apuracao)
|
||||
entrada = ContabilIndicadoresSelecionadosSerializer(data=request.data)
|
||||
entrada.is_valid(raise_exception=True)
|
||||
apuracao.indicadores_selecionados = entrada.validated_data["indicadores_selecionados"]
|
||||
apuracao.save(update_fields=["indicadores_selecionados"])
|
||||
return Response({"indicadores_selecionados": apuracao.indicadores_selecionados})
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="pre-visualizar-indicador")
|
||||
def pre_visualizar_indicador(self, request: Request, pk: str | None = None) -> Response:
|
||||
"""Calcula, contra **esta** apuração, o valor de cada componente e o
|
||||
resultado final de uma fórmula de indicador ainda em edição — pro
|
||||
contador conferir se pegou a conta/linha certa antes de salvar de
|
||||
vez (pedido explícito do usuário: ver os valores buscados, não só a
|
||||
fórmula em texto). Não persiste nada, nem exige que o indicador já
|
||||
exista — mesma validação de `IndicadorContabilDefinicaoInputSerializer`
|
||||
usada por criar/editar, só que o resultado não é salvo em lugar
|
||||
nenhum."""
|
||||
apuracao = self.get_object()
|
||||
entrada = IndicadorContabilDefinicaoInputSerializer(data=request.data)
|
||||
entrada.is_valid(raise_exception=True)
|
||||
dados_formulario = entrada.validated_data
|
||||
|
||||
dados = _contabil_coleta_dados_indicadores(apuracao)
|
||||
valores_existentes = _contabil_calcula_indicadores_personalizados(dados, apuracao.indicadores_selecionados)
|
||||
|
||||
componentes = [
|
||||
IndicadorContabilComponente(
|
||||
chave=c["chave"],
|
||||
tipo=c["tipo"],
|
||||
contas_codigos=c.get("contas_codigos") or [],
|
||||
linhas_dre_descricoes=c.get("linhas_dre_descricoes") or [],
|
||||
indicador_referenciado=c.get("indicador_referenciado") or "",
|
||||
)
|
||||
for c in dados_formulario["componentes"]
|
||||
]
|
||||
valores_componentes = {
|
||||
componente.chave: _contabil_resolve_componente_personalizado(componente, dados, valores_existentes)
|
||||
for componente in componentes
|
||||
}
|
||||
|
||||
erro = None
|
||||
try:
|
||||
resultado = dashboard_contabil_formula.avalia_formula(dados_formulario["formula"], valores_componentes)
|
||||
except dashboard_contabil_formula.FormulaInvalidaError as exc:
|
||||
resultado = None
|
||||
erro = str(exc)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"componentes": [
|
||||
{
|
||||
"chave": componente.chave,
|
||||
"valor_formatado": dashboard_contabil_extras.indice(valores_componentes[componente.chave]),
|
||||
}
|
||||
for componente in componentes
|
||||
],
|
||||
"resultado_formatado": _contabil_formata_indicador(resultado, dados_formulario["formato"]),
|
||||
"erro": erro,
|
||||
}
|
||||
)
|
||||
|
||||
@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
|
||||
@ -3676,9 +4070,11 @@ class ContabilLinhaDreViewSet(viewsets.ModelViewSet):
|
||||
|
||||
class ContabilAchadoViewSet(viewsets.ModelViewSet):
|
||||
"""Achados de auditoria gerados automaticamente na criação da apuração —
|
||||
só GET/PATCH (marcar como tratado/ignorado, sempre com justificativa)."""
|
||||
GET/PATCH (marcar como tratado/ignorado, sempre com justificativa) +
|
||||
POST em `alternar_oculto()` (ocultar a observação do relatório, sem
|
||||
mexer no status)."""
|
||||
|
||||
http_method_names = ["get", "patch", "head", "options"]
|
||||
http_method_names = ["get", "patch", "post", "head", "options"]
|
||||
queryset = ContabilAchado.objects.all()
|
||||
|
||||
def get_permissions(self) -> list[BasePermission]:
|
||||
@ -3702,3 +4098,154 @@ class ContabilAchadoViewSet(viewsets.ModelViewSet):
|
||||
super().update(request, *args, **kwargs)
|
||||
achado = self.get_object()
|
||||
return Response(ContabilAchadoSerializer(achado).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="alternar-oculto")
|
||||
def alternar_oculto(self, request: Request, pk: str | None = None) -> Response:
|
||||
"""Mostra/esconde a observação deste achado na seção "Observações"
|
||||
do relatório "Gerar Dashboard" (aba "Dashboard" da tela de revisão)
|
||||
— separado de `update()`/`ContabilAchadoAjusteSerializer` porque
|
||||
isso não deve exigir mudar status nem justificativa: um achado
|
||||
continua "Pendente" e ainda assim pode ter a observação escondida,
|
||||
se ela um dia for preenchida sem mudar o status."""
|
||||
achado = self.get_object()
|
||||
_contabil_garante_em_revisao(achado.apuracao)
|
||||
entrada = ContabilAchadoOcultoSerializer(data=request.data)
|
||||
entrada.is_valid(raise_exception=True)
|
||||
achado.oculto_no_relatorio = entrada.validated_data["oculto_no_relatorio"]
|
||||
achado.save(update_fields=["oculto_no_relatorio"])
|
||||
return Response(ContabilAchadoSerializer(achado).data)
|
||||
|
||||
|
||||
def _gera_chave_indicador_contabil(nome: str) -> str:
|
||||
"""Deriva a `chave` de um `IndicadorContabilDefinicao` a partir do
|
||||
`nome` (nunca aceita do cliente — ver `IndicadorContabilDefinicaoInputSerializer`,
|
||||
mesmo espírito de `RegraCusteioPlanoSaude.nome` sempre derivado, ver
|
||||
CLAUDE.md raiz). `slugify()` já normaliza acentos/maiúsculas e troca
|
||||
espaços por hífen; troca hífen por "_" pra ficar num padrão snake_case
|
||||
(ex. "roa", "liquidez_corrente" — os antigos 11 indicadores "de
|
||||
sistema" foram migrados com esse mesmo padrão de chave) e ser um
|
||||
identificador Python válido, já que a chave da definição pode também
|
||||
ser usada como `indicador_referenciado` dentro de uma fórmula.
|
||||
Desempata com sufixo numérico se já existir uma definição com a mesma
|
||||
chave."""
|
||||
base = slugify(nome).replace("-", "_") or "indicador"
|
||||
if base[0].isdigit():
|
||||
base = f"indicador_{base}"
|
||||
chave = base
|
||||
sufixo = 2
|
||||
while IndicadorContabilDefinicao.objects.filter(chave=chave).exists():
|
||||
chave = f"{base}_{sufixo}"
|
||||
sufixo += 1
|
||||
return chave
|
||||
|
||||
|
||||
class IndicadorContabilDefinicaoViewSet(viewsets.ModelViewSet):
|
||||
"""CRUD dos indicadores do Dashboard Contábil (aba "Dashboard" da tela
|
||||
de revisão, hub "Gerenciar Indicadores") — inclusive os antigos 11
|
||||
indicadores "de sistema" (ROA/ROE/Kanitz/...), migrados pra registros
|
||||
normais aqui (ver CLAUDE.md do pacote, "Migração dos 11 indicadores de
|
||||
sistema"). Mesma permissão de toggle único do resto da ferramenta —
|
||||
qualquer contador com acesso ao Dashboard Contábil pode criar/editar/
|
||||
excluir qualquer indicador, não é uma tela administrativa restrita ao
|
||||
perfil "Inovação"."""
|
||||
|
||||
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
|
||||
queryset = IndicadorContabilDefinicao.objects.prefetch_related("componentes").all()
|
||||
|
||||
def get_permissions(self) -> list[BasePermission]:
|
||||
return [PermissaoApp("relatorios", "dashboard-contabil")]
|
||||
|
||||
def get_serializer_class(self) -> type[ModelSerializer]:
|
||||
if self.action in ("create", "update", "partial_update"):
|
||||
return IndicadorContabilDefinicaoInputSerializer
|
||||
return IndicadorContabilDefinicaoSerializer
|
||||
|
||||
def _salva_componentes(self, definicao: IndicadorContabilDefinicao, componentes: list[dict]) -> None:
|
||||
# Substitui **todos** os componentes de uma vez a cada
|
||||
# criação/edição (nunca um PATCH incremental por componente) — ver
|
||||
# docstring de `IndicadorContabilDefinicaoInputSerializer`.
|
||||
definicao.componentes.all().delete()
|
||||
IndicadorContabilComponente.objects.bulk_create(
|
||||
[
|
||||
IndicadorContabilComponente(
|
||||
definicao=definicao,
|
||||
chave=componente["chave"],
|
||||
tipo=componente["tipo"],
|
||||
contas_codigos=componente.get("contas_codigos") or [],
|
||||
linhas_dre_descricoes=componente.get("linhas_dre_descricoes") or [],
|
||||
indicador_referenciado=componente.get("indicador_referenciado") or "",
|
||||
)
|
||||
for componente in componentes
|
||||
]
|
||||
)
|
||||
|
||||
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||
entrada = IndicadorContabilDefinicaoInputSerializer(data=request.data)
|
||||
entrada.is_valid(raise_exception=True)
|
||||
dados = entrada.validated_data
|
||||
with transaction.atomic():
|
||||
definicao = IndicadorContabilDefinicao.objects.create(
|
||||
chave=_gera_chave_indicador_contabil(dados["nome"]),
|
||||
nome=dados["nome"],
|
||||
descricao=dados.get("descricao", ""),
|
||||
formula=dados["formula"],
|
||||
formato=dados["formato"],
|
||||
padrao=dados.get("padrao", True),
|
||||
criado_por=request.user,
|
||||
)
|
||||
self._salva_componentes(definicao, dados["componentes"])
|
||||
return Response(IndicadorContabilDefinicaoSerializer(definicao).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||
definicao = self.get_object()
|
||||
entrada = IndicadorContabilDefinicaoInputSerializer(data=request.data, instance=definicao)
|
||||
entrada.is_valid(raise_exception=True)
|
||||
dados = entrada.validated_data
|
||||
with transaction.atomic():
|
||||
definicao.nome = dados["nome"]
|
||||
definicao.descricao = dados.get("descricao", "")
|
||||
definicao.formula = dados["formula"]
|
||||
definicao.formato = dados["formato"]
|
||||
definicao.padrao = dados.get("padrao", True)
|
||||
definicao.save(update_fields=["nome", "descricao", "formula", "formato", "padrao", "atualizado_em"])
|
||||
self._salva_componentes(definicao, dados["componentes"])
|
||||
return Response(IndicadorContabilDefinicaoSerializer(definicao).data)
|
||||
|
||||
def perform_destroy(self, instance: IndicadorContabilDefinicao) -> None:
|
||||
# Bloqueia exclusão se outro indicador (personalizado) referencia
|
||||
# este pela fórmula — evitar deixar uma fórmula alheia quebrada
|
||||
# silenciosamente (resultado viraria None sem nenhum aviso).
|
||||
dependentes = (
|
||||
IndicadorContabilComponente.objects.filter(
|
||||
tipo=IndicadorContabilComponente.TIPO_INDICADOR, indicador_referenciado=instance.chave
|
||||
)
|
||||
.exclude(definicao=instance)
|
||||
.select_related("definicao")
|
||||
)
|
||||
nomes = sorted({componente.definicao.nome for componente in dependentes})
|
||||
if nomes:
|
||||
raise ValidationError(
|
||||
{"detail": f"Não é possível excluir: usado na fórmula de {', '.join(nomes)}."}
|
||||
)
|
||||
chave = instance.chave
|
||||
instance.delete()
|
||||
# Limpa a chave excluída de toda apuração que a tinha selecionada
|
||||
# (não padrão) ou oculta do relatório — sem isso a chave fica órfã
|
||||
# em `ContabilApuracao.indicadores_selecionados`/`indicadores_ocultos`
|
||||
# e a próxima tentativa de alternar QUALQUER indicador no hub daquela
|
||||
# apuração falharia (a validação reenvia a lista inteira a cada
|
||||
# alternância). Ver bug real, rodada 106.
|
||||
for apuracao in ContabilApuracao.objects.filter(
|
||||
Q(indicadores_selecionados__contains=[chave]) | Q(indicadores_ocultos__contains=[chave])
|
||||
):
|
||||
campos_alterados = []
|
||||
if chave in apuracao.indicadores_selecionados:
|
||||
apuracao.indicadores_selecionados = [
|
||||
c for c in apuracao.indicadores_selecionados if c != chave
|
||||
]
|
||||
campos_alterados.append("indicadores_selecionados")
|
||||
if chave in apuracao.indicadores_ocultos:
|
||||
apuracao.indicadores_ocultos = [c for c in apuracao.indicadores_ocultos if c != chave]
|
||||
campos_alterados.append("indicadores_ocultos")
|
||||
if campos_alterados:
|
||||
apuracao.save(update_fields=campos_alterados)
|
||||
|
||||
@ -168,6 +168,12 @@
|
||||
.modal-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
/* .modal-overlay já centraliza com padding:var(--space-5) nos 4 lados —
|
||||
sem isso, um modal com conteúdo dinâmico (ex. lista de componentes de
|
||||
indicador, dashboard-contabil.js) cresce pra fora da tela e força o
|
||||
usuário a dar zoom out pra alcançar os botões no rodapé. */
|
||||
max-height: calc(100vh - var(--space-5) * 2);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
|
||||
@ -694,3 +694,409 @@
|
||||
color: var(--accent);
|
||||
background: rgba(var(--accent-rgb), 0.14);
|
||||
}
|
||||
|
||||
/* ---- Aba "Dashboard" (pré-visualização do relatório "Gerar Dashboard") ---- */
|
||||
|
||||
.dc-dash-intro {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 80ch;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.dc-dash-section {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.dc-dash-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.dc-dash-section__titulo {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.dc-dash-section__header .dc-dash-section__titulo {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dc-dash-section__dica {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.dc-dash-grupo {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.dc-dash-grupo__titulo {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.dc-dash-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.dc-dash-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-surface);
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.dc-dash-card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.dc-dash-card.is-oculto {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.dc-dash-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dc-dash-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dc-dash-card__label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.dc-dash-card__valor {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dc-dash-obs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.dc-dash-obs {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg-surface);
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.dc-dash-obs.is-oculto {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.dc-dash-obs__info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dc-dash-obs__origem {
|
||||
display: inline-block;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--accent);
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
|
||||
.dc-dash-obs__referencia {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dc-dash-obs__texto {
|
||||
margin-top: var(--space-1);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dc-dash-toggle-btn {
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.dc-dash-toggle-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Modal "Ver fórmula" (#dc-indicador-formula-modal) */
|
||||
|
||||
.dc-ind-formula-box {
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-canvas);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.dc-ind-formula-box__label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.dc-ind-formula-box p {
|
||||
font-size: 0.88rem;
|
||||
font-family: "DM Mono", monospace;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Modal "Novo/Editar Indicador" (#dc-indicador-modal) — construtor de
|
||||
componentes: cada .dc-ind-componente é um termo da fórmula (chave + tipo
|
||||
+ o "picker" certo pro tipo, ver pidDcRenderComponentePicker em
|
||||
dashboard-contabil.js), reaproveitando .checklist-box/.checklist-item/
|
||||
.checklist-search de components.css pros pickers de conta/linha da DRE. */
|
||||
|
||||
.dc-ind-componentes-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dc-ind-componente {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-canvas);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
/* Não reaproveita .modal-field-row (grid de exatamente 2 colunas,
|
||||
components.css) porque esta linha tem um 3º elemento (o botão de
|
||||
remover) que precisa ficar ao lado dos dois campos, não quebrar pra uma
|
||||
"3ª coluna" inexistente. */
|
||||
.dc-ind-componente__linha {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.dc-ind-componente__linha .modal-field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dc-ind-comp-chave,
|
||||
.dc-ind-comp-tipo,
|
||||
.dc-ind-comp-indicador {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dc-ind-comp-remover {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.dc-ind-comp-picker {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Único scroll de verdade dentro do construtor de componentes — o
|
||||
checklist de contas/linhas da DRE em si (até ~150 itens). Antes havia
|
||||
TAMBÉM um scroll na lista de componentes por fora (.dc-ind-componentes-lista,
|
||||
320px) — dois scrolls aninhados deixavam a área útil minúscula, a ponto
|
||||
de nem um componente inteiro caber sem rolar duas vezes (relatado pelo
|
||||
usuário). Removido o scroll externo: agora só o modal inteiro rola
|
||||
(`.modal-card` já tem `max-height`/`overflow-y`, ver components.css), e
|
||||
este checklist, bem maior que antes, é o único scroll aninhado que
|
||||
sobra — genuinamente necessário, já que uma apuração real tem ~100-150
|
||||
contas/linhas. */
|
||||
.dc-ind-comp-picker .checklist-box {
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
.dc-ind-formula-hint {
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.dc-ind-componentes-lista {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* Resultado de "Calcular com esta apuração" (#dc-indicador-calcular-btn) —
|
||||
mostra o valor resolvido de cada componente + o resultado final da
|
||||
fórmula, contra a apuração aberta agora, sem salvar nada. Pedido
|
||||
explícito do usuário: conferir se a fórmula pegou a conta certa antes
|
||||
de confirmar. */
|
||||
.dc-ind-preview {
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-canvas);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.dc-ind-preview__lista {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: 0.85rem;
|
||||
font-family: "DM Mono", monospace;
|
||||
}
|
||||
|
||||
.dc-ind-preview__lista li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1) 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.dc-ind-preview__lista li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dc-ind-preview__lista li span:first-child {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dc-ind-preview__lista li span:last-child {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dc-ind-preview__resultado {
|
||||
margin-top: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dc-ind-preview__resultado strong {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dc-ind-preview__erro {
|
||||
font-size: 0.85rem;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.dc-ind-preview__carregando {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Modal "Gerenciar Indicadores" (#dc-indicadores-hub-modal) — lista todo
|
||||
indicador personalizado (padrão e não padrão), pra visualizar/editar
|
||||
mesmo os que não estão ativos nesta apuração agora (ver
|
||||
pidDcRenderHubIndicadores em dashboard-contabil.js). */
|
||||
|
||||
.dc-ind-hub-secao {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.dc-ind-hub-secao__titulo {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.dc-ind-hub-lista {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dc-ind-hub-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-canvas);
|
||||
}
|
||||
|
||||
.dc-ind-hub-item__ativo {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.dc-ind-hub-item__spacer {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Reset de <button> pra parecer texto clicável — abre "Ver fórmula" (ver
|
||||
data-dc-hub-ver em dashboard-contabil.js). */
|
||||
.dc-ind-hub-item__nome {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dc-ind-hub-item__nome:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@ -70,6 +70,53 @@ function pidDcFormatMoeda(valor) {
|
||||
return numero.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
|
||||
}
|
||||
|
||||
// Os 3 formatos abaixo espelham os filtros moeda/percentual/indice de
|
||||
// portal_api/templatetags/contabil_extras.py (mesmo relatório "Gerar
|
||||
// Dashboard") — diferente de pidDcFormatMoeda acima, `null`/`undefined`
|
||||
// sempre vira "—", nunca "R$ 0,00": em IndicadoresFinanceiros, `None`
|
||||
// significa "indisponível" (ex. EBITDA sem histórico), não zero.
|
||||
function pidDcFormatIndicadorMoeda(valor) {
|
||||
if (valor === null || valor === undefined) return "—";
|
||||
return Number(valor).toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
|
||||
}
|
||||
|
||||
function pidDcFormatIndicadorPercentual(valor) {
|
||||
if (valor === null || valor === undefined) return "—";
|
||||
return `${(Number(valor) * 100).toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`;
|
||||
}
|
||||
|
||||
function pidDcFormatIndicadorIndice(valor) {
|
||||
if (valor === null || valor === undefined) return "—";
|
||||
return Number(valor).toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
// Label/grupo/formato de cada indicador (de sistema ou personalizado) vêm
|
||||
// sempre do servidor agora (`GET /contabil-apuracoes/{id}/indicadores/`,
|
||||
// campo `metadados`) — única fonte de verdade, já que um indicador
|
||||
// personalizado não tem como ter seu formato hardcoded aqui. Só a ORDEM de
|
||||
// exibição dos grupos continua fixa no frontend (grupo desconhecido, se
|
||||
// algum dia existir, cai no fim).
|
||||
const PID_DC_DASH_ORDEM_GRUPOS = [
|
||||
"Indicadores de Resultado",
|
||||
"Indicadores de Liquidez e Endividamento",
|
||||
"Indicadores Personalizados",
|
||||
];
|
||||
|
||||
function pidDcFormatIndicadorGenerico(formato, valor) {
|
||||
if (formato === "moeda") return pidDcFormatIndicadorMoeda(valor);
|
||||
if (formato === "percentual") return pidDcFormatIndicadorPercentual(valor);
|
||||
return pidDcFormatIndicadorIndice(valor);
|
||||
}
|
||||
|
||||
const PID_DC_ICON_OLHO_ABERTO =
|
||||
'<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="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||
const PID_DC_ICON_OLHO_FECHADO =
|
||||
'<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="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 7 11 7a13.16 13.16 0 0 1-1.67 2.68M6.61 6.61C3.35 8.44 1 12 1 12s4 7 11 7a9.26 9.26 0 0 0 5.39-1.61M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>';
|
||||
const PID_DC_ICON_EDITAR =
|
||||
'<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="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>';
|
||||
const PID_DC_ICON_LIXEIRA =
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
|
||||
function pidDcFormatCompetencia(iso) {
|
||||
if (!iso) return "";
|
||||
const [ano, mes] = iso.split("-");
|
||||
@ -136,6 +183,68 @@ async function pidAtualizarAchadoContabil(id, status, observacaoContador) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Aba "Dashboard" (pré-visualização do relatório "Gerar Dashboard") ----
|
||||
|
||||
async function pidBuscarIndicadoresContabil(id) {
|
||||
return pidApiRequest(`/contabil-apuracoes/${id}/indicadores/`);
|
||||
}
|
||||
|
||||
async function pidAtualizarIndicadoresOcultosContabil(id, indicadoresOcultos) {
|
||||
return pidApiRequest(`/contabil-apuracoes/${id}/indicadores-ocultos/`, {
|
||||
method: "POST",
|
||||
body: { indicadores_ocultos: indicadoresOcultos },
|
||||
});
|
||||
}
|
||||
|
||||
async function pidAlternarOcultoContaContabil(id, oculto) {
|
||||
return pidApiRequest(`/contabil-contas/${id}/`, { method: "PATCH", body: { oculta_no_relatorio: oculto } });
|
||||
}
|
||||
|
||||
async function pidAlternarOcultoLinhaDreContabil(id, oculto) {
|
||||
return pidApiRequest(`/contabil-linhas-dre/${id}/`, { method: "PATCH", body: { oculta_no_relatorio: oculto } });
|
||||
}
|
||||
|
||||
async function pidAlternarOcultoAchadoContabil(id, oculto) {
|
||||
return pidApiRequest(`/contabil-achados/${id}/alternar-oculto/`, {
|
||||
method: "POST",
|
||||
body: { oculto_no_relatorio: oculto },
|
||||
});
|
||||
}
|
||||
|
||||
async function pidBuscarIndicadorContabilDefinicao(id) {
|
||||
return pidApiRequest(`/contabil-indicadores-definicoes/${id}/`);
|
||||
}
|
||||
|
||||
async function pidCriarIndicadorContabil(payload) {
|
||||
return pidApiRequest("/contabil-indicadores-definicoes/", { method: "POST", body: payload });
|
||||
}
|
||||
|
||||
async function pidAtualizarIndicadorContabil(id, payload) {
|
||||
return pidApiRequest(`/contabil-indicadores-definicoes/${id}/`, { method: "PATCH", body: payload });
|
||||
}
|
||||
|
||||
async function pidExcluirIndicadorContabil(id) {
|
||||
return pidApiRequest(`/contabil-indicadores-definicoes/${id}/`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async function pidBuscarDefinicoesIndicadoresContabil() {
|
||||
return pidApiRequest("/contabil-indicadores-definicoes/");
|
||||
}
|
||||
|
||||
async function pidPreVisualizarIndicadorContabil(apuracaoId, payload) {
|
||||
return pidApiRequest(`/contabil-apuracoes/${apuracaoId}/pre-visualizar-indicador/`, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
async function pidAtualizarIndicadoresSelecionadosContabil(id, indicadoresSelecionados) {
|
||||
return pidApiRequest(`/contabil-apuracoes/${id}/indicadores-selecionados/`, {
|
||||
method: "POST",
|
||||
body: { indicadores_selecionados: indicadoresSelecionados },
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const listView = document.getElementById("dc-list-view");
|
||||
if (!listView) return;
|
||||
@ -175,6 +284,10 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
// regras gerais, ex. balanceamento Ativo x Passivo, não têm uma conta
|
||||
// única por trás, então não ganham o toggle).
|
||||
let dcAchadosContaExpandida = new Set();
|
||||
// { indicadores, indicadores_ocultos } da aba "Dashboard" — carregado sob
|
||||
// demanda (só quando essa aba é aberta pela primeira vez após abrir/criar
|
||||
// a apuração, ver renderDashboardTab), null até lá.
|
||||
let dcIndicadoresAtual = null;
|
||||
|
||||
function showView(view) {
|
||||
listView.hidden = view !== "list";
|
||||
@ -314,6 +427,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
dcContasColapsadas = new Set();
|
||||
dcDreColapsadas = new Set();
|
||||
dcAchadosContaExpandida = new Set();
|
||||
dcIndicadoresAtual = null;
|
||||
renderAchados();
|
||||
renderContas();
|
||||
renderDre();
|
||||
@ -691,7 +805,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
|
||||
// ---- Tabs ----
|
||||
|
||||
document.getElementById("dc-tabs").addEventListener("click", (event) => {
|
||||
document.getElementById("dc-tabs").addEventListener("click", async (event) => {
|
||||
const tabBtn = event.target.closest("[data-dc-tab]");
|
||||
if (!tabBtn) return;
|
||||
const tab = tabBtn.getAttribute("data-dc-tab");
|
||||
@ -699,6 +813,696 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
document.querySelectorAll("[data-dc-panel]").forEach((panel) => {
|
||||
panel.hidden = panel.getAttribute("data-dc-panel") !== tab;
|
||||
});
|
||||
if (tab === "dashboard") await renderDashboardTab();
|
||||
});
|
||||
|
||||
// ---- Aba "Dashboard" (pré-visualização do relatório "Gerar Dashboard") ----
|
||||
|
||||
// Indicadores só são buscados na primeira vez que a aba é aberta (mesmo
|
||||
// espírito de "canvas com tamanho zero não desenha certo" do relatório em
|
||||
// si — aqui o motivo é só evitar um cálculo/consulta extra ao banco em
|
||||
// toda apuração aberta, já que boa parte das revisões nem chega a olhar
|
||||
// esta aba). Observações (contas/DRE/achados) já estão todas carregadas
|
||||
// em apuracaoAtual, não precisam de fetch próprio.
|
||||
async function renderDashboardTab() {
|
||||
if (!dcIndicadoresAtual) {
|
||||
dcIndicadoresAtual = await pidBuscarIndicadoresContabil(apuracaoAtual.id);
|
||||
}
|
||||
renderDashboardIndicadores();
|
||||
renderDashboardObservacoes();
|
||||
}
|
||||
|
||||
// Agrupa as chaves de `metadados` pelo campo `grupo` de cada uma (vem do
|
||||
// servidor — os 11 de sistema já têm um grupo fixo, personalizados
|
||||
// sempre caem em "Indicadores Personalizados", ver
|
||||
// `ContabilApuracaoViewSet.indicadores()`); só a ORDEM de exibição dos
|
||||
// grupos é decidida aqui (`PID_DC_DASH_ORDEM_GRUPOS`).
|
||||
function renderDashboardIndicadores() {
|
||||
const container = document.getElementById("dc-dash-indicadores");
|
||||
container.innerHTML = "";
|
||||
if (!dcIndicadoresAtual) return;
|
||||
const { indicadores, indicadores_ocultos: indicadoresOcultos, metadados } = dcIndicadoresAtual;
|
||||
const concluida = apuracaoAtual.status === "concluida";
|
||||
|
||||
const grupos = new Map();
|
||||
Object.keys(metadados).forEach((chave) => {
|
||||
const nomeGrupo = metadados[chave].grupo;
|
||||
if (!grupos.has(nomeGrupo)) grupos.set(nomeGrupo, []);
|
||||
grupos.get(nomeGrupo).push(chave);
|
||||
});
|
||||
const nomesGrupos = [...grupos.keys()].sort((a, b) => {
|
||||
const posicaoA = PID_DC_DASH_ORDEM_GRUPOS.indexOf(a);
|
||||
const posicaoB = PID_DC_DASH_ORDEM_GRUPOS.indexOf(b);
|
||||
return (posicaoA === -1 ? 99 : posicaoA) - (posicaoB === -1 ? 99 : posicaoB);
|
||||
});
|
||||
|
||||
nomesGrupos.forEach((nomeGrupo) => {
|
||||
const secao = document.createElement("div");
|
||||
secao.className = "dc-dash-grupo";
|
||||
secao.innerHTML = `
|
||||
<h4 class="dc-dash-grupo__titulo">${pidDcEscapeHtml(nomeGrupo)}</h4>
|
||||
<div class="dc-dash-cards-grid"></div>
|
||||
`;
|
||||
const grid = secao.querySelector(".dc-dash-cards-grid");
|
||||
grupos.get(nomeGrupo).forEach((chave) => {
|
||||
const meta = metadados[chave];
|
||||
const oculto = indicadoresOcultos.includes(chave);
|
||||
const card = document.createElement("div");
|
||||
card.className = `dc-dash-card${oculto ? " is-oculto" : ""}`;
|
||||
card.innerHTML = `
|
||||
<div class="dc-dash-card__info">
|
||||
<span class="dc-dash-card__label">${pidDcEscapeHtml(meta.nome)}</span>
|
||||
<span class="dc-dash-card__valor">${pidDcFormatIndicadorGenerico(meta.formato, indicadores[chave])}</span>
|
||||
</div>
|
||||
<div class="dc-dash-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn dc-dash-toggle-btn"
|
||||
data-dc-dash-indicador-editar="${meta.definicao_id}"
|
||||
aria-label="Editar indicador"
|
||||
title="Editar indicador"
|
||||
${concluida ? "disabled" : ""}
|
||||
>${PID_DC_ICON_EDITAR}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn dc-dash-toggle-btn"
|
||||
data-dc-dash-indicador-toggle="${chave}"
|
||||
aria-label="${oculto ? "Mostrar" : "Ocultar"} no relatório"
|
||||
title="${oculto ? "Mostrar no relatório" : "Ocultar do relatório"}"
|
||||
${concluida ? "disabled" : ""}
|
||||
>${oculto ? PID_DC_ICON_OLHO_FECHADO : PID_DC_ICON_OLHO_ABERTO}</button>
|
||||
</div>
|
||||
`;
|
||||
card.addEventListener("click", (event) => {
|
||||
if (event.target.closest("button")) return;
|
||||
pidDcAbrirFormulaModal(meta);
|
||||
});
|
||||
grid.appendChild(card);
|
||||
});
|
||||
container.appendChild(secao);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("dc-dash-indicadores").addEventListener("click", async (event) => {
|
||||
const editarBtn = event.target.closest("[data-dc-dash-indicador-editar]");
|
||||
if (editarBtn) {
|
||||
if (editarBtn.disabled) return;
|
||||
const definicaoId = Number(editarBtn.getAttribute("data-dc-dash-indicador-editar"));
|
||||
try {
|
||||
const definicao = await pidBuscarIndicadorContabilDefinicao(definicaoId);
|
||||
pidDcAbrirIndicadorModal(definicao);
|
||||
} catch (e) {
|
||||
await pidAlert(e.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const btn = event.target.closest("[data-dc-dash-indicador-toggle]");
|
||||
if (!btn || btn.disabled) return;
|
||||
const chave = btn.getAttribute("data-dc-dash-indicador-toggle");
|
||||
const atual = dcIndicadoresAtual.indicadores_ocultos;
|
||||
const novaLista = atual.includes(chave) ? atual.filter((c) => c !== chave) : [...atual, chave];
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resposta = await pidAtualizarIndicadoresOcultosContabil(apuracaoAtual.id, novaLista);
|
||||
dcIndicadoresAtual.indicadores_ocultos = resposta.indicadores_ocultos;
|
||||
renderDashboardIndicadores();
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
await pidAlert(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Modal "Ver fórmula" ----
|
||||
|
||||
// Aceita um objeto {nome, descricao, formula_texto} direto — não só uma
|
||||
// chave de `dcIndicadoresAtual.metadados` (que só lista indicadores
|
||||
// ATIVOS nesta apuração). Um indicador não padrão ainda não selecionado
|
||||
// pra esta apuração não aparece em `metadados`, mas o hub de
|
||||
// "Gerenciar Indicadores" (ver pidDcCriaLinhaHub) ainda precisa poder
|
||||
// mostrar a fórmula dele — por isso o chamador monta o objeto certo em
|
||||
// cada caso, em vez desta função sempre resolver por chave.
|
||||
function pidDcAbrirFormulaModal(meta) {
|
||||
if (!meta) return;
|
||||
document.getElementById("dc-indicador-formula-modal-titulo").textContent = meta.nome;
|
||||
document.getElementById("dc-indicador-formula-modal-descricao").textContent =
|
||||
meta.descricao || "Sem descrição cadastrada.";
|
||||
document.getElementById("dc-indicador-formula-modal-formula").textContent = meta.formula_texto || "—";
|
||||
document.getElementById("dc-indicador-formula-modal").hidden = false;
|
||||
}
|
||||
|
||||
document.getElementById("dc-indicador-formula-modal-fechar-btn").addEventListener("click", () => {
|
||||
document.getElementById("dc-indicador-formula-modal").hidden = true;
|
||||
});
|
||||
document.getElementById("dc-indicador-formula-modal").addEventListener("click", (event) => {
|
||||
if (event.target.id === "dc-indicador-formula-modal") {
|
||||
document.getElementById("dc-indicador-formula-modal").hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Modal "Novo/Editar Indicador" ----
|
||||
|
||||
const indicadorModal = document.getElementById("dc-indicador-modal");
|
||||
const indicadorComponentesEl = document.getElementById("dc-indicador-componentes");
|
||||
let dcIndicadorEmEdicao = null; // definição completa (id/chave/.../componentes) ou null = criando
|
||||
let dcIndicadorComponenteSeq = 0;
|
||||
|
||||
const PID_DC_COMP_TIPO_LABELS = {
|
||||
contas: "Soma de contas do Balancete",
|
||||
linha_dre: "Soma de linhas da DRE",
|
||||
variacao_conta: "Variação de contas (atual − anterior)",
|
||||
indicador: "Referência a outro indicador",
|
||||
resultado_liquido: "Resultado Líquido da DRE (última linha)",
|
||||
};
|
||||
|
||||
function pidDcRenderComponentePicker(linhaEl, tipo, valoresIniciais) {
|
||||
const picker = linhaEl.querySelector(".dc-ind-comp-picker");
|
||||
if (tipo === "contas" || tipo === "variacao_conta") {
|
||||
const selecionados = valoresIniciais.contas_codigos || [];
|
||||
const codigosNestaApuracao = new Set(apuracaoAtual.contas.map((conta) => conta.codigo));
|
||||
// Um indicador pode ter sido criado a partir de outra apuração
|
||||
// (empresa/competência diferente) — código selecionado que não
|
||||
// existe aqui ainda precisa continuar marcado e ser salvo de volta
|
||||
// do jeito que estava, senão editar um indicador desses nesta tela
|
||||
// apagaria silenciosamente a conta que só existe na apuração
|
||||
// original. Aparece como item extra, sinalizado, no topo da lista.
|
||||
const orfaos = selecionados.filter((codigo) => !codigosNestaApuracao.has(codigo));
|
||||
const selecionadosSet = new Set(selecionados);
|
||||
picker.innerHTML = `
|
||||
<input type="text" class="checklist-search" placeholder="Buscar conta por código ou descrição..." data-dc-comp-busca />
|
||||
<div class="checklist-box" data-dc-comp-lista>
|
||||
${orfaos.map((codigo) => `
|
||||
<label class="checklist-item" data-dc-comp-item="${pidDcEscapeHtml(codigo.toLowerCase())}">
|
||||
<input type="checkbox" value="${pidDcEscapeHtml(codigo)}" checked />
|
||||
<span class="checklist-item__info"><span>${pidDcEscapeHtml(codigo)} — <em>conta não encontrada nesta apuração, mantida</em></span></span>
|
||||
</label>
|
||||
`).join("")}
|
||||
${apuracaoAtual.contas.map((conta) => `
|
||||
<label class="checklist-item" data-dc-comp-item="${pidDcEscapeHtml(`${conta.codigo} ${conta.descricao}`.toLowerCase())}">
|
||||
<input type="checkbox" value="${pidDcEscapeHtml(conta.codigo)}" ${selecionadosSet.has(conta.codigo) ? "checked" : ""} />
|
||||
<span class="checklist-item__info"><span>${pidDcEscapeHtml(conta.codigo)} — ${pidDcEscapeHtml(conta.descricao)}</span></span>
|
||||
</label>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
} else if (tipo === "linha_dre") {
|
||||
const selecionados = valoresIniciais.linhas_dre_descricoes || [];
|
||||
const descricoesNestaApuracao = new Set(apuracaoAtual.linhas_dre.map((linha) => linha.descricao));
|
||||
const orfaos = selecionados.filter((descricao) => !descricoesNestaApuracao.has(descricao));
|
||||
const selecionadosSet = new Set(selecionados);
|
||||
picker.innerHTML = `
|
||||
<input type="text" class="checklist-search" placeholder="Buscar linha da DRE..." data-dc-comp-busca />
|
||||
<div class="checklist-box" data-dc-comp-lista>
|
||||
${orfaos.map((descricao) => `
|
||||
<label class="checklist-item" data-dc-comp-item="${pidDcEscapeHtml(descricao.toLowerCase())}">
|
||||
<input type="checkbox" value="${pidDcEscapeHtml(descricao)}" checked />
|
||||
<span class="checklist-item__info"><span>${pidDcEscapeHtml(descricao)} — <em>linha não encontrada nesta apuração, mantida</em></span></span>
|
||||
</label>
|
||||
`).join("")}
|
||||
${apuracaoAtual.linhas_dre.map((linha) => `
|
||||
<label class="checklist-item" data-dc-comp-item="${pidDcEscapeHtml(linha.descricao.toLowerCase())}">
|
||||
<input type="checkbox" value="${pidDcEscapeHtml(linha.descricao)}" ${selecionadosSet.has(linha.descricao) ? "checked" : ""} />
|
||||
<span class="checklist-item__info"><span>${pidDcEscapeHtml(linha.descricao)}</span></span>
|
||||
</label>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
} else if (tipo === "indicador") {
|
||||
const atual = valoresIniciais.indicador_referenciado || "";
|
||||
const chaveEditando = dcIndicadorEmEdicao ? dcIndicadorEmEdicao.chave : null;
|
||||
const metadados = (dcIndicadoresAtual && dcIndicadoresAtual.metadados) || {};
|
||||
const opcoes = Object.keys(metadados)
|
||||
.filter((chave) => chave !== chaveEditando)
|
||||
.sort((a, b) => metadados[a].nome.localeCompare(metadados[b].nome));
|
||||
picker.innerHTML = `
|
||||
<select class="dc-ind-comp-indicador">
|
||||
<option value="">Selecione um indicador...</option>
|
||||
${opcoes.map((chave) => `<option value="${pidDcEscapeHtml(chave)}" ${chave === atual ? "selected" : ""}>${pidDcEscapeHtml(metadados[chave].nome)}</option>`).join("")}
|
||||
</select>
|
||||
`;
|
||||
} else if (tipo === "resultado_liquido") {
|
||||
// Sem seleção nenhuma — sempre resolve pra última linha da DRE desta
|
||||
// apuração, seja qual for o rótulo (evita quebrar quando o resultado
|
||||
// muda de sinal e a linha passa de "LUCRO" pra "PREJUÍZO", ou
|
||||
// vice-versa, ver dashboard_contabil/models.py).
|
||||
picker.innerHTML = `<p class="dc-ind-formula-hint">Usa automaticamente o resultado líquido do período (última linha da DRE desta apuração), sem precisar escolher nada aqui.</p>`;
|
||||
} else {
|
||||
picker.innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
function pidDcCriaLinhaComponente(valoresIniciais) {
|
||||
dcIndicadorComponenteSeq += 1;
|
||||
const linha = document.createElement("div");
|
||||
linha.className = "dc-ind-componente";
|
||||
linha.dataset.dcCompId = String(dcIndicadorComponenteSeq);
|
||||
const tipoInicial = valoresIniciais.tipo || "contas";
|
||||
linha.innerHTML = `
|
||||
<div class="dc-ind-componente__linha">
|
||||
<div class="modal-field">
|
||||
<label>Chave (usada na fórmula)</label>
|
||||
<input type="text" class="dc-ind-comp-chave" placeholder="ex.: receita" value="${pidDcEscapeHtml(valoresIniciais.chave || "")}" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Tipo</label>
|
||||
<select class="dc-ind-comp-tipo">
|
||||
${Object.keys(PID_DC_COMP_TIPO_LABELS).map((tipo) => `<option value="${tipo}" ${tipo === tipoInicial ? "selected" : ""}>${PID_DC_COMP_TIPO_LABELS[tipo]}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="icon-btn dc-ind-comp-remover" aria-label="Remover componente" title="Remover componente">${PID_DC_ICON_LIXEIRA}</button>
|
||||
</div>
|
||||
<div class="dc-ind-comp-picker"></div>
|
||||
`;
|
||||
pidDcRenderComponentePicker(linha, tipoInicial, valoresIniciais);
|
||||
return linha;
|
||||
}
|
||||
|
||||
indicadorComponentesEl.addEventListener("change", (event) => {
|
||||
if (event.target.classList.contains("dc-ind-comp-tipo")) {
|
||||
const linha = event.target.closest(".dc-ind-componente");
|
||||
pidDcRenderComponentePicker(linha, event.target.value, {});
|
||||
}
|
||||
});
|
||||
indicadorComponentesEl.addEventListener("input", (event) => {
|
||||
if (!event.target.hasAttribute("data-dc-comp-busca")) return;
|
||||
const linha = event.target.closest(".dc-ind-componente");
|
||||
const termo = event.target.value.trim().toLowerCase();
|
||||
linha.querySelectorAll("[data-dc-comp-item]").forEach((item) => {
|
||||
item.hidden = termo !== "" && !item.getAttribute("data-dc-comp-item").includes(termo);
|
||||
});
|
||||
});
|
||||
indicadorComponentesEl.addEventListener("click", (event) => {
|
||||
const removerBtn = event.target.closest(".dc-ind-comp-remover");
|
||||
if (removerBtn) removerBtn.closest(".dc-ind-componente").remove();
|
||||
});
|
||||
|
||||
document.getElementById("dc-indicador-add-componente-btn").addEventListener("click", () => {
|
||||
indicadorComponentesEl.appendChild(pidDcCriaLinhaComponente({}));
|
||||
});
|
||||
|
||||
function pidDcAbrirIndicadorModal(definicao) {
|
||||
dcIndicadorEmEdicao = definicao || null;
|
||||
document.getElementById("dc-indicador-modal-titulo").textContent = definicao ? "Editar Indicador" : "Novo Indicador";
|
||||
document.getElementById("dc-indicador-modal-nome").value = definicao ? definicao.nome : "";
|
||||
document.getElementById("dc-indicador-modal-descricao").value = definicao ? definicao.descricao : "";
|
||||
document.getElementById("dc-indicador-modal-formato").value = definicao ? definicao.formato : "indice";
|
||||
document.getElementById("dc-indicador-modal-formula").value = definicao ? definicao.formula : "";
|
||||
document.getElementById("dc-indicador-modal-padrao").checked = definicao ? definicao.padrao : true;
|
||||
document.getElementById("dc-indicador-modal-error").textContent = "";
|
||||
document.getElementById("dc-indicador-modal-excluir-btn").hidden = !definicao;
|
||||
document.getElementById("dc-indicador-preview").hidden = true;
|
||||
|
||||
indicadorComponentesEl.innerHTML = "";
|
||||
const componentesIniciais = definicao ? definicao.componentes : [];
|
||||
if (componentesIniciais.length) {
|
||||
componentesIniciais.forEach((componente) => indicadorComponentesEl.appendChild(pidDcCriaLinhaComponente(componente)));
|
||||
} else {
|
||||
indicadorComponentesEl.appendChild(pidDcCriaLinhaComponente({}));
|
||||
}
|
||||
indicadorModal.hidden = false;
|
||||
}
|
||||
|
||||
function pidDcFecharIndicadorModal() {
|
||||
indicadorModal.hidden = true;
|
||||
dcIndicadorEmEdicao = null;
|
||||
}
|
||||
|
||||
document.getElementById("dc-indicador-modal-cancel-btn").addEventListener("click", pidDcFecharIndicadorModal);
|
||||
indicadorModal.addEventListener("click", (event) => {
|
||||
if (event.target === indicadorModal) pidDcFecharIndicadorModal();
|
||||
});
|
||||
|
||||
// Depois de criar/editar/excluir, além de recarregar os cards da aba
|
||||
// "Dashboard", atualiza o hub também se ele estiver aberto por trás
|
||||
// (o form de indicador é sempre stacked por cima dele, ver
|
||||
// `.modal-overlay--top`/pidDcAbrirHubIndicadores) — sem isso, o hub
|
||||
// ficaria com a lista desatualizada até ser reaberto.
|
||||
async function pidDcAposSalvarOuExcluirIndicador() {
|
||||
dcIndicadoresAtual = null;
|
||||
await renderDashboardTab();
|
||||
if (!indicadoresHubModal.hidden) await pidDcRenderHubIndicadores();
|
||||
}
|
||||
|
||||
// Reaproveitada pelo "Salvar" e pelo "Calcular com esta apuração" — os
|
||||
// dois precisam exatamente da mesma leitura do formulário (chave/tipo/
|
||||
// seleção de cada linha de componente).
|
||||
function pidDcColetaComponentesForm() {
|
||||
return [...indicadorComponentesEl.querySelectorAll(".dc-ind-componente")].map((linha) => {
|
||||
const tipo = linha.querySelector(".dc-ind-comp-tipo").value;
|
||||
const chave = linha.querySelector(".dc-ind-comp-chave").value.trim();
|
||||
const componente = { chave, tipo, contas_codigos: [], linhas_dre_descricoes: [], indicador_referenciado: "" };
|
||||
if (tipo === "contas" || tipo === "variacao_conta") {
|
||||
componente.contas_codigos = [...linha.querySelectorAll('[data-dc-comp-lista] input[type="checkbox"]:checked')].map(
|
||||
(cb) => cb.value
|
||||
);
|
||||
} else if (tipo === "linha_dre") {
|
||||
componente.linhas_dre_descricoes = [
|
||||
...linha.querySelectorAll('[data-dc-comp-lista] input[type="checkbox"]:checked'),
|
||||
].map((cb) => cb.value);
|
||||
} else if (tipo === "indicador") {
|
||||
const select = linha.querySelector(".dc-ind-comp-indicador");
|
||||
componente.indicador_referenciado = select ? select.value : "";
|
||||
}
|
||||
return componente;
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("dc-indicador-modal-save-btn").addEventListener("click", async () => {
|
||||
const errorEl = document.getElementById("dc-indicador-modal-error");
|
||||
errorEl.textContent = "";
|
||||
|
||||
const componentes = pidDcColetaComponentesForm();
|
||||
|
||||
const payload = {
|
||||
nome: document.getElementById("dc-indicador-modal-nome").value.trim(),
|
||||
descricao: document.getElementById("dc-indicador-modal-descricao").value.trim(),
|
||||
formato: document.getElementById("dc-indicador-modal-formato").value,
|
||||
formula: document.getElementById("dc-indicador-modal-formula").value.trim(),
|
||||
padrao: document.getElementById("dc-indicador-modal-padrao").checked,
|
||||
componentes,
|
||||
};
|
||||
|
||||
if (!payload.nome) {
|
||||
errorEl.textContent = "Informe o nome do indicador.";
|
||||
return;
|
||||
}
|
||||
if (!componentes.length) {
|
||||
errorEl.textContent = "Adicione pelo menos um componente.";
|
||||
return;
|
||||
}
|
||||
if (componentes.some((c) => !c.chave)) {
|
||||
errorEl.textContent = "Preencha a chave de todos os componentes.";
|
||||
return;
|
||||
}
|
||||
if (!payload.formula) {
|
||||
errorEl.textContent = "Informe a fórmula do indicador.";
|
||||
return;
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById("dc-indicador-modal-save-btn");
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
if (dcIndicadorEmEdicao) {
|
||||
await pidAtualizarIndicadorContabil(dcIndicadorEmEdicao.id, payload);
|
||||
} else {
|
||||
await pidCriarIndicadorContabil(payload);
|
||||
}
|
||||
pidDcFecharIndicadorModal();
|
||||
await pidDcAposSalvarOuExcluirIndicador();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Calcula, contra a apuração aberta agora, o valor de cada componente e
|
||||
// o resultado final — sem salvar nada — pro contador conferir se a
|
||||
// fórmula pegou a conta/linha certa antes de confirmar. Reaproveita a
|
||||
// mesma coleta do formulário do botão "Salvar" (`pidDcColetaComponentesForm`).
|
||||
document.getElementById("dc-indicador-calcular-btn").addEventListener("click", async () => {
|
||||
const previewEl = document.getElementById("dc-indicador-preview");
|
||||
const componentes = pidDcColetaComponentesForm();
|
||||
const formula = document.getElementById("dc-indicador-modal-formula").value.trim();
|
||||
|
||||
previewEl.hidden = false;
|
||||
if (!componentes.length || componentes.some((c) => !c.chave) || !formula) {
|
||||
previewEl.innerHTML = `<p class="dc-ind-preview__erro">Preencha a chave de todos os componentes e a fórmula antes de calcular.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
nome: document.getElementById("dc-indicador-modal-nome").value.trim() || "Pré-visualização",
|
||||
descricao: "",
|
||||
formato: document.getElementById("dc-indicador-modal-formato").value,
|
||||
formula,
|
||||
componentes,
|
||||
};
|
||||
|
||||
const calcularBtn = document.getElementById("dc-indicador-calcular-btn");
|
||||
calcularBtn.disabled = true;
|
||||
previewEl.innerHTML = `<p class="dc-ind-preview__carregando">Calculando...</p>`;
|
||||
try {
|
||||
const resultado = await pidPreVisualizarIndicadorContabil(apuracaoAtual.id, payload);
|
||||
if (resultado.erro) {
|
||||
previewEl.innerHTML = `<p class="dc-ind-preview__erro">${pidDcEscapeHtml(resultado.erro)}</p>`;
|
||||
} else {
|
||||
previewEl.innerHTML = `
|
||||
<ul class="dc-ind-preview__lista">
|
||||
${resultado.componentes.map((c) => `
|
||||
<li><span>${pidDcEscapeHtml(c.chave)}</span><span>${c.valor_formatado === null ? "—" : pidDcEscapeHtml(c.valor_formatado)}</span></li>
|
||||
`).join("")}
|
||||
</ul>
|
||||
<div class="dc-ind-preview__resultado">Resultado: <strong>${resultado.resultado_formatado === null ? "—" : pidDcEscapeHtml(resultado.resultado_formatado)}</strong></div>
|
||||
`;
|
||||
}
|
||||
} catch (e) {
|
||||
previewEl.innerHTML = `<p class="dc-ind-preview__erro">${pidDcEscapeHtml(e.message)}</p>`;
|
||||
} finally {
|
||||
calcularBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("dc-indicador-modal-excluir-btn").addEventListener("click", async () => {
|
||||
if (!dcIndicadorEmEdicao) return;
|
||||
if (
|
||||
!(await pidConfirm(`Excluir o indicador "${dcIndicadorEmEdicao.nome}"? Essa ação não pode ser desfeita.`, {
|
||||
perigoso: true,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const errorEl = document.getElementById("dc-indicador-modal-error");
|
||||
errorEl.textContent = "";
|
||||
try {
|
||||
await pidExcluirIndicadorContabil(dcIndicadorEmEdicao.id);
|
||||
pidDcFecharIndicadorModal();
|
||||
await pidDcAposSalvarOuExcluirIndicador();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Modal "Gerenciar Indicadores" (hub: lista todos os indicadores —
|
||||
// padrão e não padrão — pra visualizar/editar/ativar, mesmo os que não
|
||||
// estão aparecendo nesta apuração agora) ----
|
||||
|
||||
const indicadoresHubModal = document.getElementById("dc-indicadores-hub-modal");
|
||||
|
||||
function pidDcCriaLinhaHub(definicao, ativo, concluida) {
|
||||
const linha = document.createElement("div");
|
||||
linha.className = "dc-ind-hub-item";
|
||||
linha.innerHTML = `
|
||||
${
|
||||
ativo === null
|
||||
? `<span class="dc-ind-hub-item__spacer"></span>`
|
||||
: `<input
|
||||
type="checkbox"
|
||||
class="dc-ind-hub-item__ativo"
|
||||
data-dc-hub-toggle="${definicao.chave}"
|
||||
${ativo ? "checked" : ""}
|
||||
${concluida ? "disabled" : ""}
|
||||
aria-label="Ativo nesta apuração"
|
||||
title="Ativo nesta apuração"
|
||||
/>`
|
||||
}
|
||||
<button type="button" class="dc-ind-hub-item__nome" data-dc-hub-ver="${definicao.id}">${pidDcEscapeHtml(definicao.nome)}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
data-dc-hub-editar="${definicao.id}"
|
||||
aria-label="Editar"
|
||||
title="Editar"
|
||||
${concluida ? "disabled" : ""}
|
||||
>${PID_DC_ICON_EDITAR}</button>
|
||||
`;
|
||||
return linha;
|
||||
}
|
||||
|
||||
async function pidDcRenderHubIndicadores() {
|
||||
const listaPadrao = document.getElementById("dc-ind-hub-lista-padrao");
|
||||
const listaNaoPadrao = document.getElementById("dc-ind-hub-lista-nao-padrao");
|
||||
const vazioPadrao = document.getElementById("dc-ind-hub-vazio-padrao");
|
||||
const vazioNaoPadrao = document.getElementById("dc-ind-hub-vazio-nao-padrao");
|
||||
listaPadrao.innerHTML = "";
|
||||
listaNaoPadrao.innerHTML = "";
|
||||
|
||||
let definicoes;
|
||||
try {
|
||||
definicoes = await pidBuscarDefinicoesIndicadoresContabil();
|
||||
} catch (e) {
|
||||
await pidAlert(e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const selecionados = new Set((dcIndicadoresAtual && dcIndicadoresAtual.indicadores_selecionados) || []);
|
||||
const concluida = apuracaoAtual.status === "concluida";
|
||||
const padrao = definicoes.filter((d) => d.padrao);
|
||||
const naoPadrao = definicoes.filter((d) => !d.padrao);
|
||||
|
||||
vazioPadrao.hidden = padrao.length > 0;
|
||||
vazioNaoPadrao.hidden = naoPadrao.length > 0;
|
||||
padrao.forEach((definicao) => listaPadrao.appendChild(pidDcCriaLinhaHub(definicao, null, concluida)));
|
||||
naoPadrao.forEach((definicao) =>
|
||||
listaNaoPadrao.appendChild(pidDcCriaLinhaHub(definicao, selecionados.has(definicao.chave), concluida))
|
||||
);
|
||||
}
|
||||
|
||||
async function pidDcAbrirHubIndicadores() {
|
||||
indicadoresHubModal.hidden = false;
|
||||
await pidDcRenderHubIndicadores();
|
||||
}
|
||||
|
||||
document.getElementById("dc-indicadores-gerenciar-btn").addEventListener("click", pidDcAbrirHubIndicadores);
|
||||
document.getElementById("dc-indicadores-hub-fechar-btn").addEventListener("click", () => {
|
||||
indicadoresHubModal.hidden = true;
|
||||
});
|
||||
indicadoresHubModal.addEventListener("click", (event) => {
|
||||
if (event.target === indicadoresHubModal) indicadoresHubModal.hidden = true;
|
||||
});
|
||||
document.getElementById("dc-indicadores-hub-novo-btn").addEventListener("click", () => pidDcAbrirIndicadorModal(null));
|
||||
|
||||
indicadoresHubModal.addEventListener("click", async (event) => {
|
||||
const verBtn = event.target.closest("[data-dc-hub-ver]");
|
||||
if (verBtn) {
|
||||
const definicaoId = Number(verBtn.getAttribute("data-dc-hub-ver"));
|
||||
try {
|
||||
const definicao = await pidBuscarIndicadorContabilDefinicao(definicaoId);
|
||||
pidDcAbrirFormulaModal({
|
||||
nome: definicao.nome,
|
||||
descricao: definicao.descricao,
|
||||
formula_texto: definicao.formula,
|
||||
});
|
||||
} catch (e) {
|
||||
await pidAlert(e.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const editarBtn = event.target.closest("[data-dc-hub-editar]");
|
||||
if (editarBtn) {
|
||||
if (editarBtn.disabled) return;
|
||||
const definicaoId = Number(editarBtn.getAttribute("data-dc-hub-editar"));
|
||||
try {
|
||||
const definicao = await pidBuscarIndicadorContabilDefinicao(definicaoId);
|
||||
pidDcAbrirIndicadorModal(definicao);
|
||||
} catch (e) {
|
||||
await pidAlert(e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
indicadoresHubModal.addEventListener("change", async (event) => {
|
||||
const checkbox = event.target.closest("[data-dc-hub-toggle]");
|
||||
if (!checkbox || checkbox.disabled) return;
|
||||
const chave = checkbox.getAttribute("data-dc-hub-toggle");
|
||||
const atual = new Set(dcIndicadoresAtual.indicadores_selecionados || []);
|
||||
if (checkbox.checked) atual.add(chave);
|
||||
else atual.delete(chave);
|
||||
checkbox.disabled = true;
|
||||
try {
|
||||
await pidAtualizarIndicadoresSelecionadosContabil(apuracaoAtual.id, [...atual]);
|
||||
dcIndicadoresAtual = null;
|
||||
await renderDashboardTab();
|
||||
await pidDcRenderHubIndicadores();
|
||||
} catch (e) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
checkbox.disabled = false;
|
||||
await pidAlert(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
function renderDashboardObservacoes() {
|
||||
const lista = document.getElementById("dc-dash-observacoes-list");
|
||||
const empty = document.getElementById("dc-dash-observacoes-empty");
|
||||
const concluida = apuracaoAtual.status === "concluida";
|
||||
lista.innerHTML = "";
|
||||
|
||||
const itens = [
|
||||
...apuracaoAtual.contas
|
||||
.filter((conta) => conta.observacao)
|
||||
.map((conta) => ({
|
||||
tipo: "conta",
|
||||
id: conta.id,
|
||||
origem: "Balancete",
|
||||
referencia: `${conta.codigo} — ${conta.descricao}`,
|
||||
texto: conta.observacao,
|
||||
oculto: conta.oculta_no_relatorio,
|
||||
})),
|
||||
...apuracaoAtual.linhas_dre
|
||||
.filter((linha) => linha.observacao)
|
||||
.map((linha) => ({
|
||||
tipo: "dre",
|
||||
id: linha.id,
|
||||
origem: "D.R.E.",
|
||||
referencia: linha.descricao,
|
||||
texto: linha.observacao,
|
||||
oculto: linha.oculta_no_relatorio,
|
||||
})),
|
||||
...apuracaoAtual.achados
|
||||
.filter((achado) => achado.observacao_contador)
|
||||
.map((achado) => ({
|
||||
tipo: "achado",
|
||||
id: achado.id,
|
||||
origem: "Auditoria",
|
||||
referencia: achado.titulo,
|
||||
texto: achado.observacao_contador,
|
||||
oculto: achado.oculto_no_relatorio,
|
||||
})),
|
||||
];
|
||||
|
||||
empty.hidden = itens.length > 0;
|
||||
|
||||
itens.forEach((item) => {
|
||||
const linha = document.createElement("div");
|
||||
linha.className = `dc-dash-obs${item.oculto ? " is-oculto" : ""}`;
|
||||
linha.innerHTML = `
|
||||
<div class="dc-dash-obs__info">
|
||||
<span class="dc-dash-obs__origem">${pidDcEscapeHtml(item.origem)}</span>
|
||||
<span class="dc-dash-obs__referencia">${pidDcEscapeHtml(item.referencia)}</span>
|
||||
<p class="dc-dash-obs__texto">${pidDcEscapeHtml(item.texto)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn dc-dash-toggle-btn"
|
||||
data-dc-dash-obs-toggle="${item.tipo}:${item.id}"
|
||||
aria-label="${item.oculto ? "Mostrar" : "Ocultar"} no relatório"
|
||||
title="${item.oculto ? "Mostrar no relatório" : "Ocultar do relatório"}"
|
||||
${concluida ? "disabled" : ""}
|
||||
>
|
||||
${item.oculto ? PID_DC_ICON_OLHO_FECHADO : PID_DC_ICON_OLHO_ABERTO}
|
||||
</button>
|
||||
`;
|
||||
lista.appendChild(linha);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("dc-dash-observacoes-list").addEventListener("click", async (event) => {
|
||||
const btn = event.target.closest("[data-dc-dash-obs-toggle]");
|
||||
if (!btn || btn.disabled) return;
|
||||
const [tipo, idTexto] = btn.getAttribute("data-dc-dash-obs-toggle").split(":");
|
||||
const id = Number(idTexto);
|
||||
btn.disabled = true;
|
||||
try {
|
||||
if (tipo === "conta") {
|
||||
const conta = apuracaoAtual.contas.find((c) => c.id === id);
|
||||
const atualizada = await pidAlternarOcultoContaContabil(id, !conta.oculta_no_relatorio);
|
||||
const indice = apuracaoAtual.contas.findIndex((c) => c.id === id);
|
||||
apuracaoAtual.contas[indice] = atualizada;
|
||||
} else if (tipo === "dre") {
|
||||
const linhaDre = apuracaoAtual.linhas_dre.find((l) => l.id === id);
|
||||
const atualizada = await pidAlternarOcultoLinhaDreContabil(id, !linhaDre.oculta_no_relatorio);
|
||||
const indice = apuracaoAtual.linhas_dre.findIndex((l) => l.id === id);
|
||||
apuracaoAtual.linhas_dre[indice] = atualizada;
|
||||
} else {
|
||||
const achado = apuracaoAtual.achados.find((a) => a.id === id);
|
||||
const atualizado = await pidAlternarOcultoAchadoContabil(id, !achado.oculto_no_relatorio);
|
||||
const indice = apuracaoAtual.achados.findIndex((a) => a.id === id);
|
||||
apuracaoAtual.achados[indice] = atualizado;
|
||||
}
|
||||
renderDashboardObservacoes();
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
await pidAlert(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Modal de observação (reaproveitado por conta do Balancete e linha da DRE) ----
|
||||
|
||||
@ -527,77 +527,28 @@
|
||||
|
||||
<div class="dcr-tab-panel" data-dcr-panel="indicadores" hidden>
|
||||
<div class="dcr-secao">
|
||||
{% for grupo in indicadores_grupos %}
|
||||
<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
|
||||
<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 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
|
||||
{{ grupo.nome }}
|
||||
</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>
|
||||
{% for indicador in grupo.cards %}
|
||||
<div class="dcr-card{% if indicador.dourado %} dcr-card--dourado{% endif %}" style="--i:{{ indicador.indice_animacao }}">
|
||||
<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 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg></div>
|
||||
<div class="dcr-card__label">{{ indicador.nome }}</div>
|
||||
<div
|
||||
class="dcr-card__value"
|
||||
data-final="{{ indicador.valor_formatado }}"
|
||||
data-count="{{ indicador.valor_bruto }}"
|
||||
data-format="{{ indicador.data_format }}"
|
||||
{% if indicador.cor_regra %}data-color-rule="{{ indicador.cor_regra }}"{% endif %}
|
||||
>{{ indicador.valor_formatado }}</div>
|
||||
{% if indicador.nota %}<div class="dcr-card__nota">{{ indicador.nota }}</div>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="dcr-secao">
|
||||
|
||||
@ -406,6 +406,7 @@
|
||||
<button type="button" class="pa-tab is-active" data-dc-tab="achados">Observações</button>
|
||||
<button type="button" class="pa-tab" data-dc-tab="balancete">Balancete</button>
|
||||
<button type="button" class="pa-tab" data-dc-tab="dre">DRE</button>
|
||||
<button type="button" class="pa-tab" data-dc-tab="dashboard">Dashboard</button>
|
||||
</div>
|
||||
|
||||
<div class="pa-tab-panel" data-dc-panel="achados">
|
||||
@ -476,6 +477,27 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pa-tab-panel" data-dc-panel="dashboard" hidden>
|
||||
<p class="dc-dash-intro">
|
||||
Pré-visualização do que vai para o relatório gerado pelo botão "Gerar Dashboard". Use o botão de olho pra ocultar um card de indicador ou uma observação — o que estiver oculto aqui não aparece no relatório.
|
||||
</p>
|
||||
|
||||
<section class="dc-dash-section">
|
||||
<div class="dc-dash-section__header">
|
||||
<h3 class="dc-dash-section__titulo">Indicadores</h3>
|
||||
<button type="button" class="btn-outline" id="dc-indicadores-gerenciar-btn">Gerenciar Indicadores</button>
|
||||
</div>
|
||||
<p class="dc-dash-section__dica">Clique num card pra ver a fórmula usada.</p>
|
||||
<div id="dc-dash-indicadores"></div>
|
||||
</section>
|
||||
|
||||
<section class="dc-dash-section">
|
||||
<h3 class="dc-dash-section__titulo">Observações</h3>
|
||||
<div class="dc-dash-obs-list" id="dc-dash-observacoes-list"></div>
|
||||
<p class="dc-empty" id="dc-dash-observacoes-empty" hidden>Nenhuma observação registrada ainda no Balancete, na DRE ou na auditoria.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@ -514,6 +536,97 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="dc-indicador-formula-modal" hidden>
|
||||
<div class="modal-card">
|
||||
<h2 class="modal-card__title" id="dc-indicador-formula-modal-titulo"></h2>
|
||||
<p class="modal-card__subtitle" id="dc-indicador-formula-modal-descricao"></p>
|
||||
<div class="dc-ind-formula-box">
|
||||
<span class="dc-ind-formula-box__label">Fórmula</span>
|
||||
<p id="dc-indicador-formula-modal-formula"></p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-solid" id="dc-indicador-formula-modal-fechar-btn">Fechar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="dc-indicadores-hub-modal" hidden>
|
||||
<div class="modal-card modal-card--wide">
|
||||
<h2 class="modal-card__title">Gerenciar Indicadores</h2>
|
||||
<p class="modal-card__subtitle">Indicadores padrão aparecem automaticamente em toda apuração. Indicadores não padrão ficam salvos aqui e só entram nesta apuração quando marcados.</p>
|
||||
|
||||
<div class="dc-ind-hub-secao">
|
||||
<h3 class="dc-ind-hub-secao__titulo">Padrão</h3>
|
||||
<div class="dc-ind-hub-lista" id="dc-ind-hub-lista-padrao"></div>
|
||||
<p class="dc-empty" id="dc-ind-hub-vazio-padrao" hidden>Nenhum indicador padrão personalizado cadastrado.</p>
|
||||
</div>
|
||||
|
||||
<div class="dc-ind-hub-secao">
|
||||
<h3 class="dc-ind-hub-secao__titulo">Não padrão</h3>
|
||||
<div class="dc-ind-hub-lista" id="dc-ind-hub-lista-nao-padrao"></div>
|
||||
<p class="dc-empty" id="dc-ind-hub-vazio-nao-padrao" hidden>Nenhum indicador não padrão cadastrado.</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-outline" id="dc-indicadores-hub-fechar-btn">Fechar</button>
|
||||
<button type="button" class="btn-solid" id="dc-indicadores-hub-novo-btn">+ Novo Indicador</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay modal-overlay--top" id="dc-indicador-modal" hidden>
|
||||
<div class="modal-card modal-card--wide">
|
||||
<h2 class="modal-card__title" id="dc-indicador-modal-titulo">Novo Indicador</h2>
|
||||
<p class="modal-card__subtitle">Monte a fórmula a partir de contas do Balancete, linhas da DRE, variação entre apurações ou outros indicadores já existentes.</p>
|
||||
|
||||
<div class="modal-field">
|
||||
<label for="dc-indicador-modal-nome">Nome</label>
|
||||
<input type="text" id="dc-indicador-modal-nome" placeholder="ex.: Margem Líquida" />
|
||||
</div>
|
||||
<div class="modal-field-row">
|
||||
<div class="modal-field">
|
||||
<label for="dc-indicador-modal-descricao">Descrição</label>
|
||||
<textarea id="dc-indicador-modal-descricao" rows="3" placeholder="O que este indicador mede, como interpretar o resultado..."></textarea>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label for="dc-indicador-modal-formato">Formato de exibição</label>
|
||||
<select id="dc-indicador-modal-formato">
|
||||
<option value="indice">Índice</option>
|
||||
<option value="percentual">Percentual (%)</option>
|
||||
<option value="moeda">Moeda (R$)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="modal-checkbox">
|
||||
<input type="checkbox" id="dc-indicador-modal-padrao" checked />
|
||||
Indicador padrão (aparece automaticamente em todas as apurações — desmarque pra deixar disponível só quando selecionado em "Gerenciar Indicadores")
|
||||
</label>
|
||||
|
||||
<div class="dc-ind-componentes-header">
|
||||
<span>Componentes</span>
|
||||
<button type="button" class="btn-outline" id="dc-indicador-add-componente-btn">+ Adicionar componente</button>
|
||||
</div>
|
||||
<div id="dc-indicador-componentes" class="dc-ind-componentes-lista"></div>
|
||||
|
||||
<div class="modal-field">
|
||||
<label for="dc-indicador-modal-formula">Fórmula</label>
|
||||
<input type="text" id="dc-indicador-modal-formula" placeholder="ex.: (receita - custo) / receita" />
|
||||
<p class="dc-ind-formula-hint" id="dc-indicador-modal-formula-hint">Use a chave de cada componente acima, combinadas com +, -, * , / e parênteses.</p>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-outline" id="dc-indicador-calcular-btn">Calcular com esta apuração</button>
|
||||
<div class="dc-ind-preview" id="dc-indicador-preview" hidden></div>
|
||||
|
||||
<p class="modal-error" id="dc-indicador-modal-error"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-danger-outline" id="dc-indicador-modal-excluir-btn" hidden>Excluir</button>
|
||||
<button type="button" class="btn-outline" id="dc-indicador-modal-cancel-btn">Cancelar</button>
|
||||
<button type="button" class="btn-solid" id="dc-indicador-modal-save-btn">Salvar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="manage-account-modal" hidden>
|
||||
<div class="modal-card" id="manage-account-modal-card">
|
||||
<h2 class="modal-card__title">Gerenciar Usuário</h2>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user