diff --git a/CLAUDE.md b/CLAUDE.md index dae1cfa..b61d710 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -379,7 +379,7 @@ Ver `portal_api/nao_conformidades/CLAUDE.md`. Chamado de "Dashboard Contábil" até uma rodada anterior — renomeado pra "Relatório Contábil" a pedido explícito do usuário, pra soar como um aliado do trabalho do contador em vez de mais um processo/sistema novo (rename só de rótulo visível: menu, título da página, cabeçalhos e o botão que gera o relatório; nomes técnicos internos — pasta do pacote, arquivos, classes de model, rotas — continuam `dashboard_contabil`/`dashboard-contabil.*`/`Contabil*`/`/contabil-*`, sem nenhuma mudança). -Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (`ITD-FISCO-7513`): o contador anexa o PDF de Balancete + DRE (modelo Questor, mesmo relatório hoje enviado ao cliente), a ferramenta extrai as contas e roda um motor de regras de auditoria (saldos negativos, contas transitórias/genéricas com saldo, contas que deveriam ficar zeradas, débito ≠ crédito, variações atípicas mês a mês contra o histórico já processado no Portal), com revisão de achados e observações por conta antes da conclusão — cada conta/linha também ganha um checkbox de "validado" (marcador informativo de que o contador já conferiu aquele item) e um editor de observação inline na própria tabela (não mais um popup), com um toggle "mostrar ao cliente" que nasce desmarcado por padrão. O botão "Gerar Relatório" gera um relatório HTML autocontido (indicadores financeiros, gráfico de evolução, DRE/Balancete agrupados, observações do contador — com a marca do escritório, não a "P.I.D." do Portal), com exportação de Balancete/DRE em XLSX a partir dele. **Ainda fora de escopo**: consolidação entre várias empresas/competências ao mesmo tempo (substituiria o BI Contábil por completo). +Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (`ITD-FISCO-7513`): o contador anexa o PDF de Balancete + DRE (modelo Questor, mesmo relatório hoje enviado ao cliente), a ferramenta extrai as contas e roda um motor de regras de auditoria (saldos negativos, contas transitórias/genéricas com saldo, contas que deveriam ficar zeradas, débito ≠ crédito, variações atípicas mês a mês contra o histórico já processado no Portal), com revisão de achados e observações por conta antes da conclusão (a observação fica no histórico da empresa + conta e reaparece assinada nas competências seguintes, até ser encerrada) — cada conta/linha também ganha um checkbox de "validado" (marcador informativo de que o contador já conferiu aquele item) e um editor de observação inline na própria tabela (não mais um popup), com um toggle "mostrar ao cliente" que nasce desmarcado por padrão. O botão "Gerar Relatório" gera um relatório HTML autocontido (indicadores financeiros, gráfico de evolução, DRE/Balancete agrupados, observações do contador — com a marca do escritório, não a "P.I.D." do Portal), com exportação de Balancete/DRE em XLSX a partir dele. **Ainda fora de escopo**: consolidação entre várias empresas/competências ao mesmo tempo (substituiria o BI Contábil por completo). Ver `portal_api/dashboard_contabil/CLAUDE.md`. diff --git a/portal_api/admin.py b/portal_api/admin.py index e8b5ba0..d6e3f78 100644 --- a/portal_api/admin.py +++ b/portal_api/admin.py @@ -11,6 +11,7 @@ from .models import ( ContabilConta, ContabilLinhaAnaliseVertical, ContabilLinhaDre, + ContabilObservacao, Departamento, EmpresaQuestor, Favorito, @@ -272,6 +273,22 @@ class ContabilLinhaAnaliseVerticalAdmin(admin.ModelAdmin): search_fields = ("descricao",) +@admin.register(ContabilObservacao) +class ContabilObservacaoAdmin(admin.ModelAdmin): + list_display = ( + "codigo_empresa", + "alvo_tipo", + "alvo_rotulo", + "competencia_origem", + "encerrada_em_competencia", + "mostrar_ao_cliente", + "criado_por", + ) + list_filter = ("alvo_tipo", "mostrar_ao_cliente") + search_fields = ("codigo_empresa", "alvo_chave", "alvo_rotulo", "texto") + ordering = ("-competencia_origem", "codigo_empresa") + + @admin.register(ContabilAchado) class ContabilAchadoAdmin(admin.ModelAdmin): list_display = ("apuracao", "regra", "severidade", "status", "titulo") diff --git a/portal_api/dashboard_contabil/CHANGELOG.md b/portal_api/dashboard_contabil/CHANGELOG.md index cb06dd0..ca18ecb 100644 --- a/portal_api/dashboard_contabil/CHANGELOG.md +++ b/portal_api/dashboard_contabil/CHANGELOG.md @@ -228,3 +228,15 @@ Dois ajustes de UX pedidos pelo usuário depois de ver o badge funcionando: (1) Cliente novo (`1751 - Balancete 07.2026.pdf`) deu 400 "Nenhuma linha de DRE encontrada" ao processar. Causa raiz confirmada rodando `pdfplumber` de verdade contra o arquivo: a fonte embutida nesse PDF perde o til do "Ã" ao extrair "DEMONSTRAÇÃO DO RESULTADO DO EXERCÍCIO" (sai "DEMONSTRAÇAO..."), e `parser.py` comparava esse título por igualdade exata — a seção DRE nunca era reconhecida. Corrigido com `_normaliza_titulo()` (remove acento antes de comparar), mesmo espírito de `_RE_PERIODO` já aceitar `Per[ií]odo` pra essa mesma classe de variação de fonte entre clientes/instalações do Questor. O mesmo PDF também tem algumas descrições de conta com palavras coladas (ex. "BANCÁRIOSA VISTA") — investigado a fundo (medição real dos vãos entre caracteres, tentativa de usar os espaços literais do PDF como sinal), mas não há correção automática segura: o espaçamento dessa fonte é inconsistente a ponto de um vão "dentro de palavra" às vezes ser maior que um vão real "entre palavras". Cheguei a propor um botão de lápis pra edição manual da descrição, mas o usuário suspendeu essa ideia e pediu algo mais simples: `ContabilApuracao.fonte_pdf_atipica` (migração `0070`) fica `True` quando a normalização de acento foi realmente necessária pra reconhecer a seção — sinal indireto de que este PDF usa fonte diferente da de referência, calculado em `extrai_balancete_dre()`/persistido por `create()`/`reprocessar()`. Frontend mostra um ícone de aviso (cor `--gold`) ao lado do nome da empresa, na lista e no cabeçalho da revisão, avisando pra conferir os nomes de conta com atenção — puramente informativo. Validado `True` só no PDF com o problema, `False` nos dois PDFs de referência já confirmados corretos (`792`, `2017`). Detalhe completo no `CLAUDE.md` desta pasta. + +### 126. Observação virou histórico por empresa+conta, atravessando competências + +Pedido explícito do usuário: uma observação registrada num mês (ex. um ajuste de estoque) precisava reaparecer na análise do mês seguinte, assinada por quem escreveu e com a data, bloqueada pra edição por ser registro histórico, com três caminhos pro contador (manter o histórico, que é o padrão; ocultar das próximas execuções; incluir uma observação nova) e filtro de visibilidade ao cliente em todas elas. Antes disso a observação era um campo da linha da apuração, então morria junto com a competência. + +Estrutura alinhada com o usuário antes de implementar (três decisões confirmadas por `AskUserQuestion`): toda observação propaga por padrão (encerrar é uma ação explícita, não existe "fixar"); o histórico cobre Balancete/D.R.E./Análise Vertical, e a justificativa de tratativa dos itens de auditoria continua presa à apuração como já era; e o "mostrar ao cliente" continua alternável mesmo numa observação já travada, porque é decisão editorial de cada relatório, não parte do registro histórico. + +Model novo `ContabilObservacao` (migração `0071`, que também copia as observações já existentes e remove os campos `observacao`/`oculta_no_relatorio` dos três models de linha): escopo `codigo_empresa` + chave natural do alvo (`codigo` no Balancete, `descricao|nivel` na DRE/Análise Vertical, as mesmas chaves do reprocessamento), `competencia_origem`/`encerrada_em_competencia` definindo a vigência, `criado_por`/`criado_em` como assinatura visível e `mostrar_ao_cliente` no lugar do antigo `oculta_no_relatorio`. `ContabilObservacaoViewSet` (`POST`/`PATCH`/`DELETE` + `encerrar`/`reativar`) e `GET /api/contabil-apuracoes/{id}/observacoes/` para o recorte de vigência. Como a observação não mora mais na linha, reprocessar uma apuração deixou de ter qualquer risco sobre ela. + +Na tela, o editor inline virou uma thread: o histórico da conta em cima (cada item com autor, data, competência de origem, selos de "Histórico"/"Encerrada"/"Aparece ao cliente" e ações de olho, editar, excluir e encerrar) e o campo de observação nova embaixo; o botão da coluna "Observação" ganhou um contador. Os resumos por aba e a lista consolidada da aba "Dashboard" passaram a listar observações vigentes com assinatura, e ganharam os chips "Todas / Visíveis ao cliente / Internas". No relatório "Gerar Dashboard", cada observação visível aparece assinada e, quando vem de um mês anterior, com a competência em que foi registrada. + +Testado ponta a ponta via `Client.force_login()` dentro de uma transação com rollback (listagem por vigência, criação com chave derivada no servidor, alvo de outra apuração recusado, edição, bloqueio do texto quando a origem está concluída, visibilidade ainda alternável nesse caso, encerrar/reativar com as fronteiras de competência, herança numa competência seguinte e o relatório nos dois meses) — nada gravado em produção além da migração em si. Detalhe completo no `CLAUDE.md` desta pasta. diff --git a/portal_api/dashboard_contabil/CLAUDE.md b/portal_api/dashboard_contabil/CLAUDE.md index c2a3b9a..41acac1 100644 --- a/portal_api/dashboard_contabil/CLAUDE.md +++ b/portal_api/dashboard_contabil/CLAUDE.md @@ -349,7 +349,11 @@ Pedido explícito do usuário: até aqui, corrigir uma apuração com o PDF erra **Por que atualização no lugar em vez de delete+recria**: a alternativa óbvia (apagar tudo e rodar `bulk_create` como em `create()`) quebraria a FK de todo achado preservado que referencia uma `ContabilConta` (o `on_delete=SET_NULL` desvincularia silenciosamente a conta do achado) — mantendo o mesmo `id` por conta/linha casada, a FK nunca precisa ser tocada, e achados "sobreviventes" continuam apontando pra conta certa sem nenhum código extra de re-vinculação. -**Frontend** (`dashboard-contabil.html`/`.js`): botão de ícone (refresh) ao lado de "Abrir" na lista (`data-dc-reprocessar-abrir`, escondido quando `status === "concluida"`) abre `#dc-reprocessar-modal` (mesmo campo de arquivo de "Nova Análise", reaproveitando `wireArquivoField()` que já era genérico o bastante) — `pidReprocessarApuracaoContabil(id, formData)` chama o endpoint, atualiza a lista e, se a apuração reprocessada é a que já está aberta na revisão, também re-renderiza a tela (`apuracaoAtual = atualizada; renderRevisao()`). Cada uma das 3 tabelas (Balancete/DRE/Análise Vertical) ganhou um badge de alerta (`pidDcAlteradaBadgeHtml()`, ícone de triângulo) ao lado do botão de observação, visível enquanto `alterada_reprocessamento` for `True`. **Marcar a conta/linha como validada de novo NÃO limpa o alerta** (pedido explícito do usuário, revertendo a primeira versão desta rodada, que limpava — "quando o usuário marcar como validado uma conta que foi reprocessada, não deve sumir o ícone de aviso, mas sim, ficar verde... conseguimos verificar quais itens foram reprocessados e revalidados") — o badge muda de cor conforme `validado` da própria conta/linha: `--danger` (vermelho) enquanto pendente, verde (`--validada`, mesmo hex de `.status-pill--ativo`) depois de validado; `pidAtualizarValidadoContaContabil()`/`...LinhaDreContabil()`/`...LinhaAnaliseVerticalContabil()` voltaram a enviar só `{ validado }`, sem tocar em `alterada_reprocessamento`. O campo só é limpo de verdade num próximo reprocessamento sem mudança naquela conta/linha específica (`_contabil_sincroniza_*`, backend). **Tooltip do badge mostra o valor de antes do reprocessamento** (`title`, pedido explícito do usuário): Balancete/DRE formatam `valor_anterior_reprocessamento` direto com `pidDcFormatMoeda()`; Análise Vertical usa `pidDcValorAnteriorAvTexto()`, que junta o valor+percentual de cada mês de `valores_anterior_reprocessamento` (mesmo alinhamento posicional de `analise_vertical_meses`) numa linha por mês dentro do mesmo tooltip. Nenhum badge tem tooltip de valor quando o campo vem `null`/vazio (conta/linha nova nesta apuração, sem "antes" pra comparar). +**Frontend** (`dashboard-contabil.html`/`.js`): botão de ícone (refresh) ao lado de "Abrir" na lista (`data-dc-reprocessar-abrir`, escondido quando `status === "concluida"`) abre `#dc-reprocessar-modal` (mesmo campo de arquivo de "Nova Análise", reaproveitando `wireArquivoField()` que já era genérico o bastante) — `pidReprocessarApuracaoContabil(id, formData)` chama o endpoint, atualiza a lista e, se a apuração reprocessada é a que já está aberta na revisão, também re-renderiza a tela (`apuracaoAtual = atualizada; renderRevisao()`). Cada uma das 3 tabelas (Balancete/DRE/Análise Vertical) ganhou um badge de alerta (`pidDcAlteradaBadgeHtml()`, ícone de triângulo) ao lado do botão de observação, visível enquanto `alterada_reprocessamento` for `True`. **Marcar a conta/linha como validada de novo NÃO limpa o alerta** (pedido explícito do usuário, revertendo a primeira versão desta rodada, que limpava — "quando o usuário marcar como validado uma conta que foi reprocessada, não deve sumir o ícone de aviso, mas sim, ficar verde... conseguimos verificar quais itens foram reprocessados e revalidados") — o badge muda de cor conforme `validado` da própria conta/linha: `--danger` (vermelho) enquanto pendente, verde (`--validada`, mesmo hex de `.status-pill--ativo`) depois de validado; `pidAtualizarValidadoContaContabil()`/`...LinhaDreContabil()`/`...LinhaAnaliseVerticalContabil()` voltaram a enviar só `{ validado }`, sem tocar em `alterada_reprocessamento`. O campo só é limpo de verdade num próximo reprocessamento sem mudança naquela conta/linha específica (`_contabil_sincroniza_*`, backend). **Tooltip do badge mostra o valor de antes do reprocessamento**: Balancete/DRE formatam `valor_anterior_reprocessamento` direto com `pidDcFormatMoeda()`; Análise Vertical usa `pidDcValorAnteriorAvTexto()`, que junta o valor+percentual de cada mês de `valores_anterior_reprocessamento` (mesmo alinhamento posicional de `analise_vertical_meses`) numa linha por mês dentro do mesmo tooltip. Nenhum badge tem tooltip de valor quando o campo vem `null`/vazio (conta/linha nova nesta apuração, sem "antes" pra comparar). + +**Tooltip customizado no visual do Portal, não o balão nativo do navegador** (pedido explícito do usuário, depois de ver o balão cinza padrão do Chrome nesse badge e no de "fonte de PDF atípica" abaixo — mesmo raciocínio já aplicado a `pidConfirm()`/`pidAlert()` no lugar de `window.confirm()`/`window.alert()`, ver "Modal de confirmação genérico" no `CLAUDE.md` raiz): `pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto)` (novo, `dashboard-contabil.js`) monta um par gatilho+texto (`.dc-hover-tooltip`/`__gatilho`/`__texto`, `dashboard-contabil.css`) — mesma ideia de `.info-tooltip`/`.info-tooltip__text` (components.css), só que com `white-space: pre-line` (preserva o `\n` deliberado entre a frase e "Valor antes do reprocessamento: ..." e ainda envolve o resto normalmente) em vez de `nowrap` (que só serve pro rótulo curto "Mais informações"). Não promovido pra components.css por enquanto — só usado aqui, mas a mecânica é genérica o bastante pra virar um componente compartilhado se outro pacote precisar do mesmo tipo de tooltip mais longo. `tabindex="0"` no gatilho + `:focus-visible`/`:hover` no CSS mostram o tooltip também via teclado, não só no hover do mouse. `pidDcAlteradaBadgeHtml()`/`pidDcFontePdfAtipicaBadgeHtml()` passaram a chamar essa função em vez de escrever `title="..."` direto no ``. + +**Bug real reportado pelo usuário logo depois: o balão saía cortado** (em cima da primeira linha da lista, e na lateral perto da borda da tabela) — `.dc-hover-tooltip__texto` nasceu `position:absolute`, mas os badges deste pacote vivem dentro de `.pa-table-wrap` (perfis-acesso.css), que tem `overflow:hidden` pra arredondar o canto da tabela; qualquer coisa `absolute` que escape da própria tabela é cortada por esse `overflow`. Corrigido trocando pra `position:fixed` (relativo à viewport, não mais preso pelo `overflow` de nenhum ancestral) com `top`/`left` calculados em JS (`pidDcPosicionaTooltip()`, topo de `dashboard-contabil.js`) no `mouseenter`/`focus` do gatilho — centralizado acima por padrão, desce pra baixo quando não tem espaço acima (badge perto do topo da tela), clampado nas duas laterais pra nunca vazar da tela (badge perto da borda da tabela). Delegado no `document` com `capture:true` (`mouseenter`/`focus` não borbulham) — um único par de listeners, registrado uma vez no carregamento do script, cobre todo badge atual e futuro, mesmo os recriados a cada re-render de `renderContas()`/`renderDre()`/`renderAnaliseVertical()`/`carregarLista()`. **Validado com os 3 testes reais**: (1) reprocessar com o **mesmo** arquivo duas vezes seguidas — `validado`/`observacao` de conta/DRE/Análise Vertical preservados, `alterada_reprocessamento` continua `False` em tudo, contagem de linhas idêntica; (2) tentar reprocessar com o arquivo de **outra empresa** — 400 com mensagem explicando a diferença de empresa/competência; (3) tentar reprocessar uma apuração **Concluída** — 400 bloqueado por `_contabil_garante_em_revisao()`. As 4 funções de sincronização também foram testadas isoladamente (dry-run com `transaction.atomic()` + rollback forçado, dados fabricados cobrindo conta/linha inalterada, alterada, nova e removida, mais achado que continua disparando e achado que para de disparar) — todos os casos bateram com o comportamento esperado antes de considerar a implementação pronta. Nenhum resíduo deixado em produção. @@ -363,6 +367,33 @@ Pedido explícito do usuário: até aqui, corrigir uma apuração com o PDF erra **Solução adotada — avisar, não tentar corrigir automaticamente** (pedido explícito do usuário, depois de rejeitar a ideia inicial de um botão de lápis pra editar a descrição manualmente — suspenso por enquanto): `ContabilApuracao.fonte_pdf_atipica` (`BooleanField`, migração `0070`) é `True` quando `_normaliza_titulo()` precisou de verdade (o título bateu sem acento mas não bateria com acento) pra reconhecer a seção DRE ou Análise Vertical — sinal indireto mas real de que este PDF usa uma fonte diferente da do relatório de referência, a mesma classe de variação que já se provou capaz de grudar palavras em descrição de conta. `ResultadoExtracao.fonte_pdf_atipica` (novo campo no dataclass, `dashboard_contabil/modelos.py`) carrega o valor calculado em `extrai_balancete_dre()`; `ContabilApuracaoViewSet.create()`/`.reprocessar()` persistem no model. Exposto em `ContabilApuracaoListSerializer`/`ContabilApuracaoDetailSerializer` (`fonte_pdf_atipica`, sem rota de escrita — `http_method_names` do ViewSet nem inclui PATCH/PUT). -**Frontend**: `pidDcFontePdfAtipicaBadgeHtml()` (`dashboard-contabil.js`) — ícone de "i" (mesma forma de `PID_DC_OBSERVACAO_ICONE`, cor `--gold` pra diferenciar visualmente, os dois nunca aparecem lado a lado) ao lado do nome da empresa, tanto na linha da lista quanto no cabeçalho `#dc-review-empresa` da tela de revisão (que passou de `.textContent` pra `.innerHTML`, escapando `codigo_empresa`/`nome_empresa` manualmente com `pidDcEscapeHtml()` já que precisa comportar HTML agora). Tooltip via `title` explica o motivo e pede pra conferir os nomes de conta com atenção — puramente informativo, não bloqueia nem oculta nada. +**Frontend**: `pidDcFontePdfAtipicaBadgeHtml()` (`dashboard-contabil.js`) — ícone de "i" (mesma forma de `PID_DC_OBSERVACAO_ICONE`, cor `--gold` pra diferenciar visualmente, os dois nunca aparecem lado a lado) ao lado do nome da empresa, tanto na linha da lista quanto no cabeçalho `#dc-review-empresa` da tela de revisão (que passou de `.textContent` pra `.innerHTML`, escapando `codigo_empresa`/`nome_empresa` manualmente com `pidDcEscapeHtml()` já que precisa comportar HTML agora). Tooltip customizado (`pidDcHoverTooltipHtml()`, ver "Tooltip customizado no visual do Portal" acima) explica o motivo e pede pra conferir os nomes de conta com atenção — puramente informativo, não bloqueia nem oculta nada. **Validado contra os 3 arquivos reais disponíveis**: `fonte_pdf_atipica` calculado `True` só pro `1751` (o PDF com o problema), `False` pros PDFs de referência já validados (`792`, `2017`) — sem falso positivo nos dois já confirmados corretos, e a extração completa do `1751` (252 contas, 231 linhas de DRE, Análise Vertical com 3 meses) bate exatamente com o total esperado depois do fix de `_normaliza_titulo()`. + +### Observação virou histórico por empresa+conta (rodada 126) + +Pedido explícito do usuário: uma observação registrada num mês (o exemplo dele foi um ajuste de estoque) precisa reaparecer na análise do mês seguinte, assinada por quem escreveu e com a data, **bloqueada pra edição** por ser registro histórico, com três caminhos (manter o histórico — padrão; ocultar das próximas execuções; incluir uma observação nova) e filtro de visibilidade ao cliente em todas elas. Até aqui a observação era um campo da linha (`ContabilConta.observacao`/`oculta_no_relatorio` e equivalentes na DRE/Análise Vertical), então morria junto com a competência. + +Três decisões de escopo confirmadas por `AskUserQuestion` **antes** de implementar, todas com a opção recomendada aceita: +1. **Toda observação propaga por padrão** — não existe "fixar"; o que existe é o inverso, encerrar explicitamente. Evita histórico que só existe quando alguém lembra de marcar. +2. **O histórico cobre Balancete/D.R.E./Análise Vertical** — a justificativa de tratativa de um item de auditoria (`ContabilAchado.observacao_contador`) continua presa à apuração como sempre foi (ela já tem histórico próprio, nunca é apagada nem sobrescrita, ver "Models" acima). Misturar os dois fluxos aumentaria o escopo sem ganho claro. +3. **`mostrar_ao_cliente` é sempre alternável**, inclusive numa observação já travada — o bloqueio protege texto, autor e data; mostrar ou não ao cliente é decisão editorial de cada relatório, e uma marcação errada precisa ser corrigível sem reescrever o histórico. + +**Model `ContabilObservacao`** (`portal_api/models.py`, migração `0071`): escopo `codigo_empresa` + `alvo_tipo` (`conta`/`dre`/`analise_vertical`) + `alvo_chave`, mais `alvo_rotulo` (descrição no momento em que foi escrita, só pra exibir se aquela conta sumir do plano), `apuracao_origem` (`SET_NULL`) + `competencia_origem` (cópia, pra vigência continuar resolvendo se a apuração for excluída), `texto`, `mostrar_ao_cliente` (substitui `oculta_no_relatorio`, com o sinal invertido pra bater com o rótulo que o contador vê), `criado_por`/`criado_em` e o trio `encerrada_em_competencia`/`encerrada_por`/`encerrada_em`. + +**Chave natural, nunca FK pra linha**: `codigo` no Balancete, `"descricao|nivel"` na DRE/Análise Vertical (`chave_conta()`/`chave_linha()` no model, `_contabil_chave_alvo()` na view) — exatamente as chaves que `_contabil_sincroniza_*()` já usa no reprocessamento e que `regras.py` usa no histórico de variação. É o que faz a observação seguir a mesma conta de uma competência pra outra, e de brinde tira qualquer risco do reprocessamento sobre ela (antes era preciso garantir explicitamente que `observacao` não fosse tocada; agora ela nem mora lá). + +**Vigência** (`vigentes_para()`/`vigente_em()`): aparece em toda apuração da mesma empresa com `competencia_origem <= C` e (`encerrada_em_competencia` nulo ou `>= C`). Daí saem os três caminhos pedidos: manter é não fazer nada; encerrar grava a competência aberta (a observação **continua visível nela** e some da seguinte em diante, o histórico nunca é reescrito); incluir uma nova cria outro registro, então uma conta passa a ter uma thread, não um texto único. + +**Imutabilidade** (`ContabilObservacaoViewSet._garante_texto_editavel()`): `texto` só é aceito enquanto a apuração de origem estiver "Em revisão"; depois disso (ou numa competência posterior, onde o frontend já mostra a observação travada) a edição é recusada com 400, pedindo pra registrar uma observação nova ou encerrar a existente. `DELETE` segue a mesma regra: histórico não se apaga, se encerra. A regra do backend olha o status da apuração de origem, e a da tela olha `historica` (calculado contra a competência aberta) — as duas convergem no uso real; a diferença só apareceria se alguém editasse pela API uma observação de um mês ainda em revisão estando com outro mês aberto na tela. + +**Endpoints** (mesma permissão de toggle único do resto da ferramenta): `GET /api/contabil-apuracoes/{id}/observacoes/` (recorte de vigência já serializado com `historica`/`encerrada` calculados contra a competência da apuração), `POST /api/contabil-observacoes/` (recebe `apuracao` + `alvo_tipo` + `alvo_id`, o id da conta/linha **desta** apuração — empresa, competência, chave e rótulo são derivados no servidor, mesmo espírito da `chave` derivada em `IndicadorContabilDefinicao`; alvo de outra apuração é 400), `PATCH` (aceita `texto` e/ou `mostrar_ao_cliente`, cada um com sua regra), `DELETE`, e as actions `encerrar`/`reativar` (as duas recebem a apuração aberta no corpo, já que é ela quem define a competência de corte). O viewset não tem `list`/`retrieve` de propósito: a leitura é sempre pelo recorte de vigência de uma apuração. + +**Relatório "Gerar Dashboard"**: `dashboard()` monta as observações vigentes com `mostrar_ao_cliente=True` uma vez e as usa em dois lugares — penduradas em cada linha da árvore (`_contabil_arvore_contexto()` ganhou os parâmetros opcionais `observacoes_por_chave`/`chave_fn`), pro ícone/painel inline por conta, e nas listas `observacoes_contas`/`observacoes_dre`/`observacoes_analise_vertical`, que agora são listas de `ContabilObservacao` (não mais de contas/linhas). Cada item mostra a assinatura e, quando a observação vem de um mês anterior, a competência em que foi registrada. + +**Frontend** (`dashboard-contabil.js`): as observações são carregadas à parte da apuração (`dcCarregarObservacoes()`, chamada ao abrir/criar uma análise) e indexadas por chave natural (`dcObsIndice`), porque não pertencem ao payload da apuração. O editor inline virou uma thread (`dcObsPainelHtml()`/`dcObsItemHtml()`): histórico em cima (autor, data, competência de origem, selos "Histórico"/"Encerrada"/"Aparece ao cliente"/"Interna" e ações de olho, editar, excluir, encerrar/reativar), campo de observação nova embaixo. Um handler único (`dcTrataCliqueObservacao()`) atende as três tabelas e as quatro listas de resumo, e cada mutação refaz o fetch e re-renderiza tudo (`dcRenderObservacoesTudo()`) — a mesma observação pode estar visível em mais de um lugar ao mesmo tempo. O botão da coluna "Observação" ganhou um contador (`.dc-obs-contador`), já que uma conta pode ter várias. Os chips "Todas / Visíveis ao cliente / Internas" (`dcObsFiltro`) existem nas quatro listas e compartilham a mesma variável: filtrar numa aba filtra em todas. + +**Migração de dados**: a `0071` cria o model, copia cada observação preenchida das três tabelas (autor e data vêm da apuração, a melhor aproximação disponível — o modelo antigo não guardava nada disso por observação; `oculta_no_relatorio` vira `mostrar_ao_cliente` invertido) e só então remove os seis campos antigos. Em produção eram 3 observações (2 de conta, 1 de DRE), todas migradas e conferidas depois de aplicar. + +**Testado ponta a ponta via `Client.force_login()`** dentro de uma transação com rollback forçado: listagem por vigência, criação com chave derivada no servidor, alvo de outra apuração recusado, edição de texto, bloqueio do texto quando a apuração de origem está concluída (com a visibilidade ainda alternável nesse mesmo caso), encerrar e reativar com as fronteiras de competência conferidas nos dois sentidos, herança numa competência seguinte (observação marcada como histórica) e o relatório gerado nos dois meses, incluindo a conferência de que observação interna não vaza pro relatório do cliente. Nada gravado em produção além da própria migração. diff --git a/portal_api/dashboard_contabil/README.md b/portal_api/dashboard_contabil/README.md index 370d583..015ab31 100644 --- a/portal_api/dashboard_contabil/README.md +++ b/portal_api/dashboard_contabil/README.md @@ -2,13 +2,13 @@ > Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico (extração do PDF, regras de auditoria, models). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal. Chamado de "Dashboard Contábil" até uma rodada anterior — renomeado a pedido do usuário (rótulo visível só, nomes técnicos internos continuam `dashboard_contabil`/`Contabil*`). -Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil: o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor hoje enviado ao cliente), a ferramenta extrai as contas automaticamente e roda um conjunto de checagens de auditoria (saldos negativos de caixa, contas transitórias/genéricas com saldo, contas que deveriam estar zeradas, diferença débito/crédito, variações atípicas mês a mês) — o contador revisa os achados, marca contas/linhas como validadas, registra observações por conta/linha (editor inline na própria tabela, com um toggle "mostrar ao cliente" que nasce desmarcado), escreve um resumo livre do fechamento (considerações/análises, texto rico com imagem) e conclui a análise. Quando o PDF traz a seção "Demonstração Mensal (Análise Vertical)" (histórico de 3 meses, valor+variação percentual por linha), ela também é extraída e ganha sua própria aba de revisão (mesmos recursos de observação/validado/ocultar do Balancete/D.R.E.). Se o arquivo original estava errado/incompleto, o botão "Reprocessar" reanexa um PDF novo pra mesma empresa/competência sem perder nada já registrado — conta/linha sem mudança mantém observação e validação, a que mudou volta pra "não validada" com um alerta, e achado já tratado nunca é apagado ou tem sua justificativa sobrescrita. O botão "Gerar Relatório" gera um relatório HTML autocontido (resumo do fechamento, indicadores financeiros, gráfico de evolução, DRE/Balancete/Análise Vertical agrupados, observações do contador) com a marca do escritório, pronto pra enviar ao administrador da empresa. +Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil: o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor hoje enviado ao cliente), a ferramenta extrai as contas automaticamente e roda um conjunto de checagens de auditoria (saldos negativos de caixa, contas transitórias/genéricas com saldo, contas que deveriam estar zeradas, diferença débito/crédito, variações atípicas mês a mês) — o contador revisa os achados, marca contas/linhas como validadas, registra observações por conta/linha (thread inline na própria tabela, com um toggle "mostrar ao cliente" que nasce desmarcado), escreve um resumo livre do fechamento (considerações/análises, texto rico com imagem) e conclui a análise. As observações não morrem com a competência: cada uma fica no histórico daquela empresa + conta, reaparecendo na análise do mês seguinte assinada por quem escreveu e com a data, travada pra edição, até o contador encerrá-la para as próximas execuções. Quando o PDF traz a seção "Demonstração Mensal (Análise Vertical)" (histórico de 3 meses, valor+variação percentual por linha), ela também é extraída e ganha sua própria aba de revisão (mesmos recursos de observação/validado/ocultar do Balancete/D.R.E.). Se o arquivo original estava errado/incompleto, o botão "Reprocessar" reanexa um PDF novo pra mesma empresa/competência sem perder nada já registrado — conta/linha sem mudança mantém observação e validação, a que mudou volta pra "não validada" com um alerta, e achado já tratado nunca é apagado ou tem sua justificativa sobrescrita. O botão "Gerar Relatório" gera um relatório HTML autocontido (resumo do fechamento, indicadores financeiros, gráfico de evolução, DRE/Balancete/Análise Vertical agrupados, observações do contador) com a marca do escritório, pronto pra enviar ao administrador da empresa. **Ainda fora de escopo**: exportação em XLSX; consolidação entre várias empresas/competências ao mesmo tempo (é sempre uma apuração por vez). ## Onde mexer - `portal_api/dashboard_contabil/` — `parser.py` (extração do PDF via `pdfplumber`), `regras.py` (motor de regras de auditoria), `indicadores.py` (indicadores financeiros do relatório), `pipeline.py` (orquestração), `modelos.py` (dataclasses). -- `ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical`/`ContabilAchado` (`portal_api/models.py`). +- `ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical`/`ContabilAchado`/`ContabilObservacao` (`portal_api/models.py`). - `dashboard-contabil.html` / `static/js/dashboard-contabil.js` / `static/css/dashboard-contabil.css`. - `dashboard-contabil-relatorio.html` (relatório em si) / `portal_api/templatetags/contabil_extras.py` (filtros de formatação). diff --git a/portal_api/migrations/0071_contabil_observacao_historico.py b/portal_api/migrations/0071_contabil_observacao_historico.py new file mode 100644 index 0000000..5be52c3 --- /dev/null +++ b/portal_api/migrations/0071_contabil_observacao_historico.py @@ -0,0 +1,134 @@ +# Generated by Django 6.0.7 on 2026-09-11 19:00 +# +# Observação do Relatório Contábil deixou de ser um campo da linha +# (ContabilConta/ContabilLinhaDre/ContabilLinhaAnaliseVertical, onde morria +# junto com a competência) e virou um registro próprio, por empresa+conta, +# que atravessa competências — ver `ContabilObservacao` em models.py. +# +# Ordem das operações importa: cria o model novo, copia o que já existe +# (RunPython) e só então remove os campos antigos. + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def copia_observacoes(apps, schema_editor): + """Cada `observacao` preenchida de conta/DRE/Análise Vertical vira um + `ContabilObservacao` da competência da própria apuração. Não existe + histórico de autoria/data por observação no modelo antigo, então o autor + e a data são os da apuração (quem criou a análise, quando criou) — a + melhor aproximação disponível; `oculta_no_relatorio` vira + `mostrar_ao_cliente` com o sinal invertido.""" + ContabilConta = apps.get_model("portal_api", "ContabilConta") + ContabilLinhaDre = apps.get_model("portal_api", "ContabilLinhaDre") + ContabilLinhaAnaliseVertical = apps.get_model("portal_api", "ContabilLinhaAnaliseVertical") + ContabilObservacao = apps.get_model("portal_api", "ContabilObservacao") + + novas = [] + datas = [] + + for conta in ContabilConta.objects.exclude(observacao="").select_related("apuracao"): + apuracao = conta.apuracao + novas.append( + ContabilObservacao( + codigo_empresa=apuracao.codigo_empresa, + alvo_tipo="conta", + alvo_chave=conta.codigo, + alvo_rotulo=conta.descricao, + apuracao_origem=apuracao, + competencia_origem=apuracao.competencia, + texto=conta.observacao, + mostrar_ao_cliente=not conta.oculta_no_relatorio, + criado_por=apuracao.criado_por, + ) + ) + datas.append(apuracao.criado_em) + + for model, alvo_tipo in ( + (ContabilLinhaDre, "dre"), + (ContabilLinhaAnaliseVertical, "analise_vertical"), + ): + for linha in model.objects.exclude(observacao="").select_related("apuracao"): + apuracao = linha.apuracao + novas.append( + ContabilObservacao( + codigo_empresa=apuracao.codigo_empresa, + alvo_tipo=alvo_tipo, + alvo_chave=f"{linha.descricao}|{linha.nivel}", + alvo_rotulo=linha.descricao, + apuracao_origem=apuracao, + competencia_origem=apuracao.competencia, + texto=linha.observacao, + mostrar_ao_cliente=not linha.oculta_no_relatorio, + criado_por=apuracao.criado_por, + ) + ) + datas.append(apuracao.criado_em) + + criadas = ContabilObservacao.objects.bulk_create(novas) + # `criado_em` é auto_now_add — só um UPDATE depois consegue gravar a data + # da apuração de origem no lugar do "agora" desta migração. + for observacao, data in zip(criadas, datas): + ContabilObservacao.objects.filter(pk=observacao.pk).update(criado_em=data) + + +class Migration(migrations.Migration): + + dependencies = [ + ('portal_api', '0070_contabil_fonte_pdf_atipica'), + ] + + operations = [ + migrations.CreateModel( + name='ContabilObservacao', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('codigo_empresa', models.CharField(max_length=20, verbose_name='Código da empresa')), + ('alvo_tipo', models.CharField(choices=[('conta', 'Conta do Balancete'), ('dre', 'Linha da DRE'), ('analise_vertical', 'Linha da Análise Vertical')], max_length=20, verbose_name='Tipo do alvo')), + ('alvo_chave', models.CharField(max_length=320, verbose_name='Chave natural do alvo')), + ('alvo_rotulo', models.CharField(blank=True, max_length=320, verbose_name='Rótulo do alvo')), + ('competencia_origem', models.DateField(verbose_name='Competência de origem')), + ('texto', models.TextField(verbose_name='Observação')), + ('mostrar_ao_cliente', models.BooleanField(default=False, verbose_name='Mostrar ao cliente no relatório')), + ('criado_em', models.DateTimeField(auto_now_add=True)), + ('atualizado_em', models.DateTimeField(auto_now=True)), + ('encerrada_em_competencia', models.DateField(blank=True, null=True, verbose_name='Encerrada na competência')), + ('encerrada_em', models.DateTimeField(blank=True, null=True, verbose_name='Encerrada em')), + ('apuracao_origem', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observacoes', to='portal_api.contabilapuracao')), + ('criado_por', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observacoes_contabeis', to=settings.AUTH_USER_MODEL)), + ('encerrada_por', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observacoes_contabeis_encerradas', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Observação do Relatório Contábil', + 'verbose_name_plural': 'Observações do Relatório Contábil', + 'ordering': ['competencia_origem', 'criado_em', 'id'], + 'indexes': [models.Index(fields=['codigo_empresa', 'alvo_tipo', 'alvo_chave'], name='contabil_obs_alvo_idx')], + }, + ), + migrations.RunPython(copia_observacoes, migrations.RunPython.noop), + migrations.RemoveField( + model_name='contabilconta', + name='observacao', + ), + migrations.RemoveField( + model_name='contabilconta', + name='oculta_no_relatorio', + ), + migrations.RemoveField( + model_name='contabillinhaanalisevertical', + name='observacao', + ), + migrations.RemoveField( + model_name='contabillinhaanalisevertical', + name='oculta_no_relatorio', + ), + migrations.RemoveField( + model_name='contabillinhadre', + name='observacao', + ), + migrations.RemoveField( + model_name='contabillinhadre', + name='oculta_no_relatorio', + ), + ] diff --git a/portal_api/models.py b/portal_api/models.py index 66aff65..12acfe6 100644 --- a/portal_api/models.py +++ b/portal_api/models.py @@ -2069,14 +2069,10 @@ class ContabilConta(models.Model): debito = models.DecimalField(max_digits=16, decimal_places=2) 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). Default True - # (oculta) — pedido explícito do usuário: uma observação nova só vai pro - # relatório do cliente depois que o contador confirmar isso explicitamente - # no editor inline da tabela, nunca por padrão. - oculta_no_relatorio = models.BooleanField("Observação oculta no relatório", default=True) + # A observação do contador NÃO mora mais aqui — virou `ContabilObservacao` + # (histórico por empresa+conta que atravessa competências, ver o model + # abaixo). Os campos `observacao`/`oculta_no_relatorio` que existiam aqui + # foram migrados e removidos na migração `0071`. # Checkbox de "já revisei esta conta" — puramente informativo (não afeta # achados/status/relatório), pedido explícito do usuário como um segundo # botão ao lado do de observação, pra marcar contas já conferidas durante @@ -2092,8 +2088,8 @@ class ContabilConta(models.Model): # cor, pra dar pra identificar depois quais itens já foram reprocessados # E revalidados; só um próximo reprocessamento sem mudança nesta conta # específica limpa de vez. Não afeta contas sem mudança nenhuma (essas - # mantêm `validado` e `observacao` como estavam, ver "Reprocessar" no - # CLAUDE.md do pacote). + # mantêm `validado` como estava, ver "Reprocessar" no CLAUDE.md do + # pacote; observação nunca é afetada, mora em `ContabilObservacao`). alterada_reprocessamento = models.BooleanField("Alterada no último reprocessamento", default=False) # Cópia de `saldo_atual` de ANTES do reprocessamento que ligou # `alterada_reprocessamento` — só existe pra alimentar o tooltip do @@ -2126,11 +2122,7 @@ class ContabilLinhaDre(models.Model): nivel = models.IntegerField("Nível de indentação", default=0) 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". Default True - # (oculta), mesmo motivo documentado lá. - oculta_no_relatorio = models.BooleanField("Observação oculta no relatório", default=True) + # Observação virou `ContabilObservacao` — ver a nota em `ContabilConta`. # Mesmo espírito de ContabilConta.validado. validado = models.BooleanField("Validado pelo contador", default=False) # Mesmo espírito de ContabilConta.alterada_reprocessamento. @@ -2166,9 +2158,8 @@ class ContabilLinhaAnaliseVertical(models.Model): nivel = models.IntegerField("Nível de indentação", default=0) totalizador = models.BooleanField("Linha totalizadora", default=False) valores = models.JSONField("Valores mensais", default=list) - observacao = models.TextField("Observação", blank=True) - # Mesmo espírito de ContabilLinhaDre.oculta_no_relatorio/.validado. - oculta_no_relatorio = models.BooleanField("Observação oculta no relatório", default=True) + # Observação virou `ContabilObservacao` — ver a nota em `ContabilConta`. + # Mesmo espírito de ContabilLinhaDre.validado. validado = models.BooleanField("Validado pelo contador", default=False) # Mesmo espírito de ContabilConta.alterada_reprocessamento. alterada_reprocessamento = models.BooleanField("Alterada no último reprocessamento", default=False) @@ -2242,6 +2233,129 @@ class ContabilAchado(models.Model): return f"{self.titulo} ({self.get_status_display()})" +class ContabilObservacao(models.Model): + """Uma observação escrita pelo contador sobre uma conta do Balancete, uma + linha da DRE ou uma linha da Análise Vertical — **não** pertence à + apuração, e sim à empresa + conta, atravessando competências (pedido + explícito do usuário: "se eu fizer uma observação sobre o estoque do + cliente... no mês seguinte este histórico deve aparecer, informando que + fui eu quem fiz, no dia e data tal"). Substituiu os campos + `observacao`/`oculta_no_relatorio` que viviam em `ContabilConta`/ + `ContabilLinhaDre`/`ContabilLinhaAnaliseVertical` (migração `0071`), + onde morriam junto com a competência. + + O alvo é guardado por **chave natural**, nunca por FK a uma linha de uma + apuração específica (uma linha é recriada/ressincronizada a cada + apuração/reprocessamento): `codigo` de classificação no Balancete e + `"descricao|nivel"` na DRE/Análise Vertical — as mesmas chaves já usadas + por `_contabil_sincroniza_*()` em views.py e pelo histórico de variação + em `regras.py`. Efeito colateral bem-vindo: reprocessar uma apuração não + toca em observação nenhuma, já que elas não moram mais na linha que é + resincronizada. + + **Vigência** (ver `vigentes_para()`): aparece em toda apuração da mesma + empresa com competência entre `competencia_origem` e + `encerrada_em_competencia` (inclusive nas duas pontas; `None` = vigente + pra sempre). "Manter o histórico" é o padrão (nada a fazer), "ocultar das + próximas execuções" grava `encerrada_em_competencia` com a competência + em que o contador encerrou, e "incluir uma nova observação" cria outro + registro — nunca reescreve um já existente. + + **Imutabilidade**: `texto` só pode ser editado enquanto a apuração de + origem estiver "Em revisão" (ver `ContabilObservacaoViewSet`); numa + competência posterior a observação é histórica, somente leitura, com + autor/data à vista. `mostrar_ao_cliente` é a exceção deliberada, sempre + alternável (decisão confirmada com o usuário): o bloqueio protege texto, + autor e data, mas mostrar ou não ao cliente é uma decisão editorial de + cada relatório.""" + + ALVO_CONTA = "conta" + ALVO_DRE = "dre" + ALVO_ANALISE_VERTICAL = "analise_vertical" + ALVO_CHOICES = [ + (ALVO_CONTA, "Conta do Balancete"), + (ALVO_DRE, "Linha da DRE"), + (ALVO_ANALISE_VERTICAL, "Linha da Análise Vertical"), + ] + + codigo_empresa = models.CharField("Código da empresa", max_length=20) + alvo_tipo = models.CharField("Tipo do alvo", max_length=20, choices=ALVO_CHOICES) + alvo_chave = models.CharField("Chave natural do alvo", max_length=320) + # Descrição da conta/linha no momento em que a observação foi escrita — + # só pra exibir o histórico quando aquela conta não existir mais na + # apuração aberta (plano de contas mudou, linha saiu do relatório). + alvo_rotulo = models.CharField("Rótulo do alvo", max_length=320, blank=True) + # `SET_NULL` (não CASCADE) de propósito: o histórico é do cliente, não da + # apuração — excluir a análise de um mês não pode apagar o que o contador + # registrou sobre aquela conta. `competencia_origem` é uma cópia + # justamente pra vigência continuar resolvendo sem a apuração original. + apuracao_origem = models.ForeignKey( + ContabilApuracao, on_delete=models.SET_NULL, null=True, blank=True, related_name="observacoes" + ) + competencia_origem = models.DateField("Competência de origem") + texto = models.TextField("Observação") + # Substitui o antigo `oculta_no_relatorio` das linhas, com o sinal + # invertido pra ficar igual ao rótulo que o contador vê no editor + # ("Mostrar esta observação ao cliente no relatório"). Nasce desmarcado, + # mesma decisão de sempre: uma observação nova só vai pro relatório do + # cliente quando o contador confirmar explicitamente. + mostrar_ao_cliente = models.BooleanField("Mostrar ao cliente no relatório", default=False) + criado_por = models.ForeignKey( + Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="observacoes_contabeis" + ) + criado_em = models.DateTimeField(auto_now_add=True) + atualizado_em = models.DateTimeField(auto_now=True) + # "Ocultar das próximas execuções": a observação continua visível na + # competência em que foi encerrada (e em todas as anteriores, o histórico + # nunca é reescrito) e some a partir da seguinte. + encerrada_em_competencia = models.DateField("Encerrada na competência", null=True, blank=True) + encerrada_por = models.ForeignKey( + Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="observacoes_contabeis_encerradas" + ) + encerrada_em = models.DateTimeField("Encerrada em", null=True, blank=True) + + class Meta: + verbose_name = "Observação do Relatório Contábil" + verbose_name_plural = "Observações do Relatório Contábil" + ordering = ["competencia_origem", "criado_em", "id"] + indexes = [ + models.Index(fields=["codigo_empresa", "alvo_tipo", "alvo_chave"], name="contabil_obs_alvo_idx"), + ] + + def __str__(self) -> str: + return f"{self.codigo_empresa} {self.alvo_chave} ({self.competencia_origem:%m/%Y})" + + @staticmethod + def chave_conta(codigo: str) -> str: + """Chave natural de uma conta do Balancete — mesmo critério de + `_contabil_sincroniza_contas()` (casa por `codigo` de classificação).""" + return codigo + + @staticmethod + def chave_linha(descricao: str, nivel: int) -> str: + """Chave natural de uma linha da DRE/Análise Vertical — o par + `(descricao, nivel)` de `_contabil_sincroniza_linhas_dre()`, que + desambigua descrições repetidas em ramos diferentes da árvore.""" + return f"{descricao}|{nivel}" + + @classmethod + def vigentes_para(cls, codigo_empresa: str, competencia: date) -> models.QuerySet["ContabilObservacao"]: + return cls.objects.filter( + codigo_empresa=codigo_empresa, competencia_origem__lte=competencia + ).filter(models.Q(encerrada_em_competencia__isnull=True) | models.Q(encerrada_em_competencia__gte=competencia)) + + def vigente_em(self, competencia: date) -> bool: + if self.competencia_origem > competencia: + return False + return self.encerrada_em_competencia is None or self.encerrada_em_competencia >= competencia + + def eh_historica_em(self, competencia: date) -> bool: + """Observação de uma competência anterior à que está aberta — texto, + autor e data ficam travados (só dá pra encerrar ou responder com uma + observação nova).""" + return self.competencia_origem < competencia + + class IndicadorContabilDefinicao(models.Model): """Um indicador financeiro do Relatório Contábil — todo indicador é um registro aqui agora, inclusive os antigos 11 "de sistema" (ROA/ROE/ diff --git a/portal_api/serializers.py b/portal_api/serializers.py index fafaca0..55e25a5 100644 --- a/portal_api/serializers.py +++ b/portal_api/serializers.py @@ -21,6 +21,7 @@ from .models import ( ContabilConta, ContabilLinhaAnaliseVertical, ContabilLinhaDre, + ContabilObservacao, Departamento, EmpresaQuestor, Favorito, @@ -2246,8 +2247,6 @@ class ContabilContaSerializer(serializers.ModelSerializer): "debito", "credito", "saldo_atual", - "observacao", - "oculta_no_relatorio", "validado", "alterada_reprocessamento", "valor_anterior_reprocessamento", @@ -2277,8 +2276,6 @@ class ContabilLinhaDreSerializer(serializers.ModelSerializer): "nivel", "valor", "totalizador", - "observacao", - "oculta_no_relatorio", "validado", "alterada_reprocessamento", "valor_anterior_reprocessamento", @@ -2305,8 +2302,6 @@ class ContabilLinhaAnaliseVerticalSerializer(serializers.ModelSerializer): "nivel", "totalizador", "valores", - "observacao", - "oculta_no_relatorio", "validado", "alterada_reprocessamento", "valores_anterior_reprocessamento", @@ -2323,6 +2318,88 @@ class ContabilLinhaAnaliseVerticalSerializer(serializers.ModelSerializer): ] +class ContabilObservacaoSerializer(serializers.ModelSerializer): + """Leitura de uma observação do histórico (ver `ContabilObservacao`). + `historica`/`encerrada` são derivados da competência da apuração que + está sendo exibida, passada em `context["competencia"]` — a mesma + observação é editável na apuração que a criou e somente leitura em + qualquer competência posterior.""" + + criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None) + encerrada_por_nome = serializers.CharField(source="encerrada_por.nome", read_only=True, default=None) + historica = serializers.SerializerMethodField() + encerrada = serializers.SerializerMethodField() + + class Meta: + model = ContabilObservacao + fields = [ + "id", + "codigo_empresa", + "alvo_tipo", + "alvo_chave", + "alvo_rotulo", + "apuracao_origem", + "competencia_origem", + "texto", + "mostrar_ao_cliente", + "criado_por_nome", + "criado_em", + "encerrada_em_competencia", + "encerrada_por_nome", + "encerrada_em", + "historica", + "encerrada", + ] + read_only_fields = fields + + def get_historica(self, obj: ContabilObservacao) -> bool: + competencia = self.context.get("competencia") + return bool(competencia and obj.eh_historica_em(competencia)) + + def get_encerrada(self, obj: ContabilObservacao) -> bool: + return obj.encerrada_em_competencia is not None + + +class ContabilObservacaoCreateSerializer(serializers.Serializer): + """Entrada de `ContabilObservacaoViewSet.create()` — o cliente informa a + apuração aberta e **qual linha dela** recebeu a observação (`alvo_id`, o + id da `ContabilConta`/`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical`); + empresa, competência, chave natural e rótulo do alvo são derivados no + servidor a partir desse objeto, nunca aceitos do cliente (mesmo espírito + da `chave` derivada em `IndicadorContabilDefinicao`).""" + + apuracao = serializers.PrimaryKeyRelatedField(queryset=ContabilApuracao.objects.all()) + alvo_tipo = serializers.ChoiceField(choices=[opcao[0] for opcao in ContabilObservacao.ALVO_CHOICES]) + alvo_id = serializers.IntegerField() + texto = serializers.CharField(allow_blank=False) + mostrar_ao_cliente = serializers.BooleanField(required=False, default=False) + + +class ContabilObservacaoUpdateSerializer(serializers.Serializer): + """PATCH de uma observação — os dois campos são opcionais e independentes: + `texto` só é aceito enquanto a observação não for histórica (ver + `ContabilObservacaoViewSet.partial_update()`), `mostrar_ao_cliente` é + sempre aceito (decisão editorial de cada relatório, não faz parte do que + o histórico congela).""" + + texto = serializers.CharField(allow_blank=False, required=False) + mostrar_ao_cliente = serializers.BooleanField(required=False) + + def validate(self, attrs: dict) -> dict: + if not attrs: + raise serializers.ValidationError({"detail": "Informe ao menos um campo para alterar."}) + return attrs + + +class ContabilObservacaoApuracaoSerializer(serializers.Serializer): + """Corpo de `ContabilObservacaoViewSet.encerrar()`/`reativar()` — a + apuração aberta é quem define a competência em que a observação deixa de + valer ("ocultar das próximas execuções"), então ela precisa vir no corpo + mesmo nessas ações que não mudam o texto.""" + + apuracao = serializers.PrimaryKeyRelatedField(queryset=ContabilApuracao.objects.all()) + + class ContabilAchadoSerializer(serializers.ModelSerializer): conta_codigo = serializers.CharField(source="conta.codigo", read_only=True, default=None) conta_descricao = serializers.CharField(source="conta.descricao", read_only=True, default=None) diff --git a/portal_api/urls.py b/portal_api/urls.py index 3095cb8..8fac25a 100644 --- a/portal_api/urls.py +++ b/portal_api/urls.py @@ -103,6 +103,7 @@ router.register( views.ContabilLinhaAnaliseVerticalViewSet, basename="contabil-linha-analise-vertical", ) +router.register("contabil-observacoes", views.ContabilObservacaoViewSet, basename="contabil-observacao") router.register("contabil-achados", views.ContabilAchadoViewSet, basename="contabil-achado") router.register( "contabil-indicadores-definicoes", diff --git a/portal_api/views.py b/portal_api/views.py index e35dad3..f2204d8 100644 --- a/portal_api/views.py +++ b/portal_api/views.py @@ -57,6 +57,7 @@ from .models import ( ContabilConta, ContabilLinhaAnaliseVertical, ContabilLinhaDre, + ContabilObservacao, Departamento, Favorito, FuncaoTelefonia, @@ -136,6 +137,10 @@ from .serializers import ( ContabilResumoFechamentoSerializer, ContabilLinhaAnaliseVerticalSerializer, ContabilLinhaDreSerializer, + ContabilObservacaoApuracaoSerializer, + ContabilObservacaoCreateSerializer, + ContabilObservacaoSerializer, + ContabilObservacaoUpdateSerializer, DepartamentoSerializer, FavoritoSerializer, FuncaoTelefoniaSerializer, @@ -3687,7 +3692,12 @@ _CONTABIL_NIVEL_ABERTO_PADRAO = 2 def _contabil_arvore_contexto( - itens: list[Any], chave: str, nivel_fn: Callable[[Any], int], px_por_nivel: int + itens: list[Any], + chave: str, + nivel_fn: Callable[[Any], int], + px_por_nivel: int, + observacoes_por_chave: dict[str, list[ContabilObservacao]] | None = None, + chave_fn: Callable[[Any], str] | None = None, ) -> list[dict[str, Any]]: """Monta o contexto de uma árvore recolhível (Balancete/DRE) pro relatório "Gerar Dashboard" — mesmo algoritmo de `dcContaNivel()`/ @@ -3696,11 +3706,19 @@ def _contabil_arvore_contexto( relatório é HTML estático renderizado uma vez, não uma tela que re-renderiza a cada clique — o JS do relatório só alterna `hidden` nas linhas já prontas (ver `dashboard-contabil-relatorio.html`). `colapsado_padrao` - marca os itens que devem nascer recolhidos (ver `_CONTABIL_NIVEL_ABERTO_PADRAO`).""" + marca os itens que devem nascer recolhidos (ver `_CONTABIL_NIVEL_ABERTO_PADRAO`). + + `observacoes_por_chave`+`chave_fn` (opcionais, usados pelo relatório) + penduram em cada item as observações vigentes daquela conta/linha que o + contador marcou pra mostrar ao cliente — casadas pela chave natural do + histórico (`ContabilObservacao.alvo_chave`), não por id de linha.""" niveis = [nivel_fn(item) for item in itens] contexto = [] for i, item in enumerate(itens): tem_filhos = i + 1 < len(itens) and niveis[i + 1] > niveis[i] + observacoes: list[ContabilObservacao] = [] + if observacoes_por_chave is not None and chave_fn is not None: + observacoes = observacoes_por_chave.get(chave_fn(item), []) contexto.append( { chave: item, @@ -3708,11 +3726,38 @@ def _contabil_arvore_contexto( "nivel_px": niveis[i] * px_por_nivel, "tem_filhos": tem_filhos, "colapsado_padrao": tem_filhos and niveis[i] >= _CONTABIL_NIVEL_ABERTO_PADRAO, + "observacoes": observacoes, } ) return contexto +def _contabil_chave_alvo(alvo_tipo: str, alvo: Any) -> str: + """Chave natural de uma conta/linha pro histórico de observações — as + mesmas usadas por `_contabil_sincroniza_*()` no reprocessamento (`codigo` + no Balancete, `(descricao, nivel)` na DRE/Análise Vertical), pra uma + observação seguir a mesma conta de uma competência pra outra.""" + if alvo_tipo == ContabilObservacao.ALVO_CONTA: + return ContabilObservacao.chave_conta(alvo.codigo) + return ContabilObservacao.chave_linha(alvo.descricao, alvo.nivel) + + +def _contabil_observacoes_por_chave(apuracao: ContabilApuracao, alvo_tipo: str) -> dict[str, list[ContabilObservacao]]: + """Observações vigentes desta empresa/competência (ver + `ContabilObservacao.vigentes_para()`), agrupadas pela chave natural do + alvo — um dict pra casar com cada conta/linha da apuração sem uma query + por linha.""" + agrupadas: dict[str, list[ContabilObservacao]] = {} + consulta = ( + ContabilObservacao.vigentes_para(apuracao.codigo_empresa, apuracao.competencia) + .filter(alvo_tipo=alvo_tipo) + .select_related("criado_por", "encerrada_por") + ) + for observacao in consulta: + agrupadas.setdefault(observacao.alvo_chave, []).append(observacao) + return agrupadas + + def _contabil_sincroniza_contas( apuracao: ContabilApuracao, contas_extraidas: list[dashboard_contabil_modelos.LinhaBalanceteExtraida] ) -> None: @@ -3725,8 +3770,9 @@ def _contabil_sincroniza_contas( conta com algum campo divergente da versão anterior volta pra `validado=False` e `alterada_reprocessamento=True`, guardando o `saldo_atual` de antes em `valor_anterior_reprocessamento` (só pro - tooltip do badge no frontend). `observacao`/`oculta_no_relatorio` nunca - são tocados aqui.""" + tooltip do badge no frontend). Observação nunca é afetada por aqui: ela + não mora mais na linha, e sim em `ContabilObservacao` (histórico por + empresa+conta).""" antigas = {conta.codigo: conta for conta in apuracao.contas.all()} vistos: set[str] = set() @@ -4068,8 +4114,9 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet): `(descricao, nivel)`, na DRE/Análise Vertical — mesma convenção já usada pelo histórico de variação em `regras.py`) tem seus campos brutos (saldo/valor/ordem) atualizados **no mesmo registro** - (mesmo `id`), preservando `observacao`/`oculta_no_relatorio` - sempre. Se algum valor realmente mudou, `validado` volta pra + (mesmo `id`). Observação não é preservada "por cuidado", ela + simplesmente não vive aqui — mora em `ContabilObservacao`, casada + por chave natural. Se algum valor realmente mudou, `validado` volta pra `False` e `alterada_reprocessamento` vira `True` (frontend mostra um alerta); se nada mudou, os dois continuam como estavam. - Conta/linha que só existe na versão nova é criada normalmente @@ -4175,6 +4222,22 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet): apuracao.save(update_fields=["status", "concluida_em"]) return Response(ContabilApuracaoDetailSerializer(apuracao).data) + @action(detail=True, methods=["get"]) + def observacoes(self, request: Request, pk: str | None = None) -> Response: + """Observações vigentes nesta apuração (ver `ContabilObservacao`) — + as escritas nesta competência mais as herdadas de competências + anteriores que ainda não foram encerradas. O frontend casa cada uma + com a conta/linha pela dupla `alvo_tipo`+`alvo_chave`, não por id de + linha (a linha é recriada a cada apuração, a observação não).""" + apuracao = self.get_object() + consulta = ContabilObservacao.vigentes_para(apuracao.codigo_empresa, apuracao.competencia).select_related( + "criado_por", "encerrada_por" + ) + serializada = ContabilObservacaoSerializer( + consulta, many=True, context={"competencia": apuracao.competencia} + ) + return Response(serializada.data) + @action(detail=True, methods=["get"]) def dashboard(self, request: Request, pk: str | None = None) -> HttpResponse: """Gera o relatório "Gerar Dashboard": um documento HTML autocontido, @@ -4206,27 +4269,70 @@ class ContabilApuracaoViewSet(viewsets.ModelViewSet): {"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 que o contador escondeu na aba "Dashboard" da tela de revisão + # (ver `indicadores_ocultos()`) e observação sem `mostrar_ao_cliente` + # 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 é que os omitem. + # Observações que o contador marcou pra mostrar ao cliente, já no + # recorte de vigência desta competência (ver `ContabilObservacao`) — + # inclui as herdadas de competências anteriores ainda não encerradas, + # que é justamente o ponto do histórico. + observacoes_visiveis = [ + observacao + for observacao in ContabilObservacao.vigentes_para( + apuracao.codigo_empresa, apuracao.competencia + ).select_related("criado_por") + if observacao.mostrar_ao_cliente + ] + observacoes_por_tipo: dict[str, dict[str, list[ContabilObservacao]]] = { + ContabilObservacao.ALVO_CONTA: {}, + ContabilObservacao.ALVO_DRE: {}, + ContabilObservacao.ALVO_ANALISE_VERTICAL: {}, + } + for observacao in observacoes_visiveis: + observacoes_por_tipo[observacao.alvo_tipo].setdefault(observacao.alvo_chave, []).append(observacao) + cards = _contabil_monta_cards_indicadores(apuracao, valores_indicadores) contexto = { "apuracao": apuracao, "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), + "contas": _contabil_arvore_contexto( + contas, + "conta", + lambda c: c.codigo.count("."), + 18, + observacoes_por_tipo[ContabilObservacao.ALVO_CONTA], + lambda c: ContabilObservacao.chave_conta(c.codigo), + ), + "linhas_dre": _contabil_arvore_contexto( + linhas_dre, + "linha", + lambda l: max(0, l.nivel), + 16, + observacoes_por_tipo[ContabilObservacao.ALVO_DRE], + lambda l: ContabilObservacao.chave_linha(l.descricao, l.nivel), + ), "analise_vertical_meses": apuracao.analise_vertical_meses, "linhas_analise_vertical": _contabil_arvore_contexto( - linhas_analise_vertical, "linha", lambda l: max(0, l.nivel), 16 + linhas_analise_vertical, + "linha", + lambda l: max(0, l.nivel), + 16, + observacoes_por_tipo[ContabilObservacao.ALVO_ANALISE_VERTICAL], + lambda l: ContabilObservacao.chave_linha(l.descricao, l.nivel), ), - "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], + "observacoes_contas": [ + observacao for observacao in observacoes_visiveis if observacao.alvo_tipo == ContabilObservacao.ALVO_CONTA + ], + "observacoes_dre": [ + observacao for observacao in observacoes_visiveis if observacao.alvo_tipo == ContabilObservacao.ALVO_DRE + ], "observacoes_analise_vertical": [ - linha for linha in linhas_analise_vertical if linha.observacao and not linha.oculta_no_relatorio + observacao + for observacao in observacoes_visiveis + if observacao.alvo_tipo == ContabilObservacao.ALVO_ANALISE_VERTICAL ], "achados_com_observacao": [ achado for achado in achados if achado.observacao_contador and not achado.oculto_no_relatorio @@ -4497,6 +4603,142 @@ class ContabilLinhaAnaliseVerticalViewSet(viewsets.ModelViewSet): serializer.save() +class ContabilObservacaoViewSet(viewsets.ModelViewSet): + """Histórico de observações do contador (ver `ContabilObservacao`) — uma + thread por empresa+conta, que atravessa competências em vez de morrer + junto com a apuração (era um campo de `ContabilConta`/`ContabilLinhaDre`/ + `ContabilLinhaAnaliseVertical` até a migração `0071`). + + Não tem `list`/`retrieve` de propósito: o frontend sempre lê pelo recorte + de vigência de uma apuração (`ContabilApuracaoViewSet.observacoes()`), + nunca a tabela inteira. + + O que cada método pode mexer: + - `create` escreve uma observação nova na apuração aberta (precisa estar + "Em revisão"); empresa/competência/chave do alvo saem do servidor. + - `partial_update` aceita `texto` **só enquanto a apuração de origem + estiver em revisão** (depois disso a observação é histórica, e o texto/ + autor/data ficam congelados) e `mostrar_ao_cliente` sempre — decisão + confirmada com o usuário: o bloqueio protege o registro histórico, mas + mostrar ou não ao cliente é uma decisão editorial de cada relatório, + inclusive de uma competência posterior. + - `destroy` segue a mesma regra do texto (só apaga o que ainda é da + competência aberta; histórico não se apaga, se encerra). + - `encerrar`/`reativar` ligam/desligam `encerrada_em_competencia` — é o + "ocultar das próximas execuções": continua visível na competência em + que foi encerrada e some a partir da seguinte.""" + + http_method_names = ["post", "patch", "delete", "head", "options"] + queryset = ContabilObservacao.objects.select_related("criado_por", "encerrada_por", "apuracao_origem") + serializer_class = ContabilObservacaoSerializer + + def get_permissions(self) -> list[BasePermission]: + return [PermissaoApp("relatorios", "dashboard-contabil")] + + def _resolve_alvo(self, apuracao: ContabilApuracao, alvo_tipo: str, alvo_id: int) -> Any: + """Conta/linha **desta** apuração que recebeu a observação — pegar o + objeto (em vez de confiar numa chave enviada pelo cliente) garante + que a chave natural gravada é sempre a real e que o alvo pertence + mesmo à apuração informada.""" + modelos = { + ContabilObservacao.ALVO_CONTA: ContabilConta, + ContabilObservacao.ALVO_DRE: ContabilLinhaDre, + ContabilObservacao.ALVO_ANALISE_VERTICAL: ContabilLinhaAnaliseVertical, + } + alvo = modelos[alvo_tipo].objects.filter(pk=alvo_id, apuracao=apuracao).first() + if alvo is None: + raise ValidationError({"alvo_id": "Conta ou linha não encontrada nesta análise."}) + return alvo + + def _garante_texto_editavel(self, observacao: ContabilObservacao) -> None: + origem = observacao.apuracao_origem + if origem is None or origem.status == ContabilApuracao.STATUS_CONCLUIDA: + raise ValidationError( + { + "detail": ( + "Esta observação já faz parte do histórico, o texto não pode mais ser alterado. " + "Registre uma observação nova ou encerre esta para as próximas competências." + ) + } + ) + + def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: + entrada = ContabilObservacaoCreateSerializer(data=request.data) + entrada.is_valid(raise_exception=True) + dados = entrada.validated_data + apuracao: ContabilApuracao = dados["apuracao"] + _contabil_garante_em_revisao(apuracao) + alvo = self._resolve_alvo(apuracao, dados["alvo_tipo"], dados["alvo_id"]) + observacao = ContabilObservacao.objects.create( + codigo_empresa=apuracao.codigo_empresa, + alvo_tipo=dados["alvo_tipo"], + alvo_chave=_contabil_chave_alvo(dados["alvo_tipo"], alvo), + alvo_rotulo=alvo.descricao, + apuracao_origem=apuracao, + competencia_origem=apuracao.competencia, + texto=dados["texto"].strip(), + mostrar_ao_cliente=dados["mostrar_ao_cliente"], + criado_por=request.user, + ) + serializada = ContabilObservacaoSerializer(observacao, context={"competencia": apuracao.competencia}) + return Response(serializada.data, status=status.HTTP_201_CREATED) + + def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Response: + observacao: ContabilObservacao = self.get_object() + entrada = ContabilObservacaoUpdateSerializer(data=request.data) + entrada.is_valid(raise_exception=True) + dados = entrada.validated_data + campos: list[str] = [] + if "texto" in dados: + self._garante_texto_editavel(observacao) + observacao.texto = dados["texto"].strip() + campos.append("texto") + if "mostrar_ao_cliente" in dados: + observacao.mostrar_ao_cliente = dados["mostrar_ao_cliente"] + campos.append("mostrar_ao_cliente") + observacao.save(update_fields=[*campos, "atualizado_em"]) + competencia = observacao.apuracao_origem.competencia if observacao.apuracao_origem else None + return Response(ContabilObservacaoSerializer(observacao, context={"competencia": competencia}).data) + + def perform_destroy(self, instance: ContabilObservacao) -> None: + self._garante_texto_editavel(instance) + instance.delete() + + @action(detail=True, methods=["post"]) + def encerrar(self, request: Request, pk: str | None = None) -> Response: + """"Ocultar das próximas execuções": a observação continua visível na + competência informada (e em todas as anteriores — o histórico nunca é + reescrito) e some a partir da seguinte.""" + observacao = self.get_object() + entrada = ContabilObservacaoApuracaoSerializer(data=request.data) + entrada.is_valid(raise_exception=True) + apuracao: ContabilApuracao = entrada.validated_data["apuracao"] + _contabil_garante_em_revisao(apuracao) + observacao.encerrada_em_competencia = apuracao.competencia + observacao.encerrada_por = request.user + observacao.encerrada_em = timezone.now() + observacao.save(update_fields=["encerrada_em_competencia", "encerrada_por", "encerrada_em", "atualizado_em"]) + serializada = ContabilObservacaoSerializer(observacao, context={"competencia": apuracao.competencia}) + return Response(serializada.data) + + @action(detail=True, methods=["post"]) + def reativar(self, request: Request, pk: str | None = None) -> Response: + """Desfaz um `encerrar()` — a observação volta a valer daqui pra + frente. Existe pro contador não ficar preso a um clique errado; o + texto continua intocado nos dois caminhos.""" + observacao = self.get_object() + entrada = ContabilObservacaoApuracaoSerializer(data=request.data) + entrada.is_valid(raise_exception=True) + apuracao: ContabilApuracao = entrada.validated_data["apuracao"] + _contabil_garante_em_revisao(apuracao) + observacao.encerrada_em_competencia = None + observacao.encerrada_por = None + observacao.encerrada_em = None + observacao.save(update_fields=["encerrada_em_competencia", "encerrada_por", "encerrada_em", "atualizado_em"]) + serializada = ContabilObservacaoSerializer(observacao, context={"competencia": apuracao.competencia}) + return Response(serializada.data) + + class ContabilAchadoViewSet(viewsets.ModelViewSet): """Achados de auditoria gerados automaticamente na criação da apuração — GET/PATCH (marcar como tratado/ignorado, sempre com justificativa) + diff --git a/static/css/dashboard-contabil.css b/static/css/dashboard-contabil.css index 24a24a3..60b0348 100644 --- a/static/css/dashboard-contabil.css +++ b/static/css/dashboard-contabil.css @@ -683,6 +683,7 @@ (`.icon-btn`, `perfis-acesso.css`), mas menor, pra caber numa célula sem aumentar a altura da linha. */ .dc-conta-observacao-btn { + position: relative; /* âncora do `.dc-obs-contador` (badge de quantidade) */ display: inline-flex; align-items: center; justify-content: center; @@ -759,16 +760,71 @@ opacity: 0.5; } +/* Tooltip customizado no visual do Portal — os badges abaixo (alterada no + reprocessamento, fonte de PDF atípica) precisavam de um texto mais longo, + às vezes com quebra de linha própria (ex. "valor antes do + reprocessamento" numa segunda linha), do que `.info-tooltip__text` + (components.css) suporta — aquele é `white-space:nowrap`, pensado só pro + rótulo curto "Mais informações". `tabindex="0"` no gatilho + `:hover`/ + `:focus-visible` mostram o tooltip também via teclado, não só no hover do + mouse. `white-space: pre-line` preserva um `\n` deliberado no texto + (quebra de linha própria) e ainda assim quebra/envolve o resto + normalmente pela largura máxima. + + `position: fixed` (não `absolute` como `.info-tooltip__text`) — os badges + deste pacote vivem dentro de `.pa-table-wrap`, que tem `overflow:hidden` + pra arredondar o canto da tabela (perfis-acesso.css); um tooltip + `absolute` cortava (confirmado pelo usuário: balão cortado em cima da + primeira linha, cortado na lateral perto da borda da tabela). `fixed` + posiciona relativo à viewport, escapando desse corte — `top`/`left` são + calculados em JS (`pidDcPosicionaTooltip()`, dashboard-contabil.js, no + `mouseenter`/`focus` do gatilho) e clampados pra nunca sair da tela nem + pra cima nem pros lados; sem JS nenhum, ficaria sempre num canto fixo da + tela, errado pra qualquer badge que não esteja bem naquele ponto. */ +.dc-hover-tooltip { + position: relative; + display: inline-flex; +} + +.dc-hover-tooltip__gatilho { + display: inline-flex; +} + +.dc-hover-tooltip__texto { + position: fixed; + width: max-content; + max-width: 280px; + white-space: pre-line; + text-align: left; + background: var(--bg-surface-raised); + color: var(--text-primary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + font-size: 0.75rem; + line-height: 1.4; + box-shadow: var(--shadow-sm); + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-fast); + z-index: 20; +} + +.dc-hover-tooltip__gatilho:hover + .dc-hover-tooltip__texto, +.dc-hover-tooltip__gatilho:focus-visible + .dc-hover-tooltip__texto { + opacity: 1; +} + /* Badge de alerta — conta/linha que mudou no último reprocessamento (`alterada_reprocessamento`, ver "Reprocessar" no CLAUDE.md do pacote). - Puramente informativo (não é um botão, só um `` com tooltip via - `title`) — continua visível mesmo depois do contador marcar a conta/linha - como validada de novo (pedido explícito do usuário: precisa dar pra - identificar quais itens já foram reprocessados E revalidados), só troca - de cor via `--validada` abaixo; só some de vez num próximo reprocessamento - sem essa mudança específica. Cor `--danger` (pendente), igual à - severidade "alta" dos achados — chama mais atenção que o dourado já usado - pelo estado "parcial" do validado. */ + Puramente informativo (não é um botão, só o gatilho de um + `.dc-hover-tooltip` acima) — continua visível mesmo depois do contador + marcar a conta/linha como validada de novo (pedido explícito do usuário: + precisa dar pra identificar quais itens já foram reprocessados E + revalidados), só troca de cor via `--validada` abaixo; só some de vez num + próximo reprocessamento sem essa mudança específica. Cor `--danger` + (pendente), igual à severidade "alta" dos achados — chama mais atenção + que o dourado já usado pelo estado "parcial" do validado. */ .dc-conta-alterada-badge { display: inline-flex; align-items: center; @@ -787,9 +843,9 @@ /* Aviso de "fonte de PDF atípica" (ContabilApuracao.fonte_pdf_atipica) ao lado do nome da empresa — mesmo espírito de .dc-conta-alterada-badge - (span informativo, tooltip via `title`, não é um botão), cor `--gold` - porque é um aviso de atenção, não um alerta de severidade alta como o - badge de alteração acima. */ + (gatilho de um `.dc-hover-tooltip`, não é um botão), cor `--gold` porque + é um aviso de atenção, não um alerta de severidade alta como o badge de + alteração acima. */ .dc-fonte-atipica-badge { display: inline-flex; align-items: center; @@ -814,6 +870,23 @@ padding: var(--space-4); } +/* Este `` é sempre o único da linha (colspan cobre todas as colunas), + então ele é ao mesmo tempo "primeiro" e "último" filho pro seletor CSS — + herdaria `text-align:right` de `.pa-table td:last-child` (bug de + alinhamento já visto no painel de detalhe de outras telas) E a cor + apagada de `.dc-contas-table td:nth-child(1)` (pensada pra coluna do + código da conta, `--text-muted`), deixando o texto da thread de + observações "apagado" mesmo já sem cor própria nele. Precisa do seletor + com 2 classes (não só `.dc-obs-edit-row td`) pra empatar/ganhar em + especificidade das duas regras acima (a versão de 1 classe perdia); força + a esquerda (é texto corrido, não valor numérico) e `--text-primary` + (texto principal do tema, não um branco fixo — continua legível nos dois + temas) como cor herdada por padrão pra tudo que não define a própria. */ +.pa-table .dc-obs-edit-row td { + text-align: left; + color: var(--text-primary); +} + .dc-obs-edit { display: flex; flex-direction: column; @@ -828,6 +901,131 @@ margin-top: 0; } +/* ---- Thread de observações (histórico por empresa+conta) ---- + O painel inline deixou de ser "um textarea com o texto da linha" e virou + uma thread: as observações vigentes daquela conta (as desta competência e + as herdadas de competências anteriores, ver `ContabilObservacao` no + backend) em cima, o campo de observação nova embaixo. Observação herdada + fica visualmente mais apagada e sem os botões de editar/excluir — é + registro histórico, só dá pra encerrar ou responder com uma nova. */ +.dc-obs-thread { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.dc-obs-thread__vazio { + margin: 0; + font-size: 0.85rem; + color: var(--text-muted); +} + +.dc-obs-item { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--bg-surface-raised); + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.dc-obs-item--historica { + border-left: 3px solid var(--accent); +} + +.dc-obs-item--encerrada { + opacity: 0.72; +} + +.dc-obs-item__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.dc-obs-item__assinatura { + font-size: 0.78rem; + color: var(--text-muted); +} + +.dc-obs-item__selos { + display: inline-flex; + flex-wrap: wrap; + gap: var(--space-1); +} + +.dc-obs-item__acoes { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + +.dc-obs-item__texto { + margin: 0; + white-space: pre-wrap; + font-size: 0.9rem; +} + +.dc-obs-selo { + display: inline-flex; + align-items: center; + padding: 2px var(--space-2); + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + background: var(--card-bg-hover); + color: var(--text-secondary); +} + +.dc-obs-selo--historica { + background: rgba(var(--accent-rgb), 0.16); + color: var(--accent); +} + +.dc-obs-selo--encerrada { + background: rgba(var(--gold-rgb), 0.18); + color: var(--gold); +} + +.dc-obs-selo--cliente { + background: rgba(var(--teal-rgb), 0.18); + color: var(--teal); +} + +.dc-obs-nova { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding-top: var(--space-2); + border-top: 1px dashed var(--border-subtle); +} + +/* Contagem de observações vigentes ao lado do ícone da coluna "Observação" — + o texto em si nunca aparece na tabela (ver `.dc-conta-observacao-btn`), + então o número é o único sinal de "quantas coisas já foram anotadas aqui". */ +.dc-obs-contador { + position: absolute; + top: -2px; + right: -4px; + min-width: 15px; + height: 15px; + padding: 0 3px; + border-radius: 999px; + background: var(--accent); + color: #fff; + font-size: 0.62rem; + font-weight: 700; + line-height: 15px; + text-align: center; +} + +.dc-obs-filtros { + margin-bottom: var(--space-3); +} + /* Resumo de observações no final do Balancete/DRE (pedido explícito do usuário) — reaproveita `.dc-dash-obs-list`/`.dc-dash-obs*` (já definidas mais abaixo) pros itens da lista, só a moldura da seção é nova. */ @@ -1131,7 +1329,12 @@ .dc-dash-obs__texto { margin-top: var(--space-1); font-size: 0.82rem; - color: var(--text-secondary); + /* Era `--text-secondary` (cinza) — pedido explícito do usuário: no tema + escuro o texto da observação em si ficava "apagado"/difícil de ler + contra o fundo do card. `--text-primary` é o token de texto principal + (branco no escuro, escuro no claro), não um branco fixo — continua + legível nos dois temas. */ + color: var(--text-primary); white-space: pre-wrap; } diff --git a/static/js/dashboard-contabil.js b/static/js/dashboard-contabil.js index 542d855..ff48f2a 100644 --- a/static/js/dashboard-contabil.js +++ b/static/js/dashboard-contabil.js @@ -149,6 +149,42 @@ function pidDcEscapeHtml(texto) { return div.innerHTML; } +// Posiciona o `` (fixed, ver +// dashboard-contabil.css) relativo à viewport, calculado no momento do +// hover/foco — nasce acima do gatilho, centralizado; sem espaço acima +// (badge perto do topo da tela) desce pra baixo do gatilho; clampado nas +// laterais pra nunca vazar pra fora da tela (badge perto da borda da +// tabela). Necessário porque os badges deste pacote vivem dentro de +// `.pa-table-wrap`, que corta (`overflow:hidden`) qualquer coisa +// posicionada com `position:absolute` que escape da própria tabela — +// `position:fixed` já resolve o corte sozinho, isto aqui só calcula onde. +// Delegado no `document` com `capture:true` porque `mouseenter`/`focus` não +// borbulham — um único listener cobre todo badge atual e futuro, mesmo os +// recriados a cada re-render da tabela. +function pidDcPosicionaTooltip(event) { + const gatilho = event.target.closest && event.target.closest(".dc-hover-tooltip__gatilho"); + if (!gatilho) return; + const texto = gatilho.nextElementSibling; + if (!texto || !texto.classList.contains("dc-hover-tooltip__texto")) return; + + const margem = 8; + const rect = gatilho.getBoundingClientRect(); + const largura = texto.offsetWidth; + const altura = texto.offsetHeight; + + let esquerda = rect.left + rect.width / 2 - largura / 2; + esquerda = Math.max(margem, Math.min(esquerda, window.innerWidth - largura - margem)); + + let topo = rect.top - altura - margem; + if (topo < margem) topo = rect.bottom + margem; + + texto.style.left = `${esquerda}px`; + texto.style.top = `${topo}px`; +} + +document.addEventListener("mouseenter", pidDcPosicionaTooltip, true); +document.addEventListener("focus", pidDcPosicionaTooltip, true); + async function pidFetchApuracoesContabil() { return pidApiRequest("/contabil-apuracoes/"); } @@ -187,16 +223,53 @@ function pidGerarDashboardContabil(id) { window.open(`/api/contabil-apuracoes/${id}/dashboard/`, "_blank"); } -// `payload` sempre {observacao, oculta_no_relatorio} — o editor inline -// (ver renderContas/renderDre) salva os dois campos juntos numa única -// chamada, já que "mostrar ao cliente" é decidido no mesmo instante em que -// a observação é escrita. -async function pidAtualizarObservacaoContaContabil(id, payload) { - return pidApiRequest(`/contabil-contas/${id}/`, { method: "PATCH", body: payload }); +// ---- Observações (histórico por empresa+conta, atravessa competências) ---- +// A observação não pertence mais à linha da apuração (era um campo de +// ContabilConta/ContabilLinhaDre/ContabilLinhaAnaliseVertical até a migração +// 0071) — é um registro próprio (`ContabilObservacao`), vinculado à empresa + +// chave natural da conta, que continua aparecendo nas competências seguintes +// até o contador encerrá-la. Por isso todas as chamadas abaixo são contra +// `/contabil-observacoes/`, nunca contra a linha. + +async function pidBuscarObservacoesContabil(apuracaoId) { + return pidApiRequest(`/contabil-apuracoes/${apuracaoId}/observacoes/`); } -async function pidAtualizarObservacaoLinhaDreContabil(id, payload) { - return pidApiRequest(`/contabil-linhas-dre/${id}/`, { method: "PATCH", body: payload }); +// `alvoId` é o id da conta/linha DESTA apuração — o servidor deriva empresa, +// competência, chave natural e rótulo a partir dele (ver +// ContabilObservacaoViewSet.create). +async function pidCriarObservacaoContabil(apuracaoId, alvoTipo, alvoId, texto, mostrarAoCliente) { + return pidApiRequest("/contabil-observacoes/", { + method: "POST", + body: { + apuracao: apuracaoId, + alvo_tipo: alvoTipo, + alvo_id: alvoId, + texto, + mostrar_ao_cliente: mostrarAoCliente, + }, + }); +} + +// `payload` aceita {texto} e/ou {mostrar_ao_cliente} — o texto só é aceito +// enquanto a observação ainda for da competência aberta; a visibilidade ao +// cliente é sempre alternável, inclusive numa observação histórica. +async function pidAtualizarObservacaoContabil(id, payload) { + return pidApiRequest(`/contabil-observacoes/${id}/`, { method: "PATCH", body: payload }); +} + +async function pidExcluirObservacaoContabil(id) { + return pidApiRequest(`/contabil-observacoes/${id}/`, { method: "DELETE" }); +} + +// "Ocultar das próximas execuções": continua visível nesta competência e +// some a partir da seguinte. `reativar` desfaz. +async function pidEncerrarObservacaoContabil(id, apuracaoId) { + return pidApiRequest(`/contabil-observacoes/${id}/encerrar/`, { method: "POST", body: { apuracao: apuracaoId } }); +} + +async function pidReativarObservacaoContabil(id, apuracaoId) { + return pidApiRequest(`/contabil-observacoes/${id}/reativar/`, { method: "POST", body: { apuracao: apuracaoId } }); } // `alterada_reprocessamento` nunca é tocado por aqui (pedido explícito do @@ -213,10 +286,6 @@ async function pidAtualizarValidadoLinhaDreContabil(id, validado) { return pidApiRequest(`/contabil-linhas-dre/${id}/`, { method: "PATCH", body: { validado } }); } -async function pidAtualizarObservacaoLinhaAnaliseVerticalContabil(id, payload) { - return pidApiRequest(`/contabil-linhas-analise-vertical/${id}/`, { method: "PATCH", body: payload }); -} - async function pidAtualizarValidadoLinhaAnaliseVerticalContabil(id, validado) { return pidApiRequest(`/contabil-linhas-analise-vertical/${id}/`, { method: "PATCH", body: { validado } }); } @@ -241,21 +310,6 @@ async function pidAtualizarIndicadoresOcultosContabil(id, 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 pidAlternarOcultoLinhaAnaliseVerticalContabil(id, oculto) { - return pidApiRequest(`/contabil-linhas-analise-vertical/${id}/`, { - method: "PATCH", - body: { oculta_no_relatorio: oculto }, - }); -} - async function pidAlternarOcultoAchadoContabil(id, oculto) { return pidApiRequest(`/contabil-achados/${id}/alternar-oculto/`, { method: "POST", @@ -354,18 +408,19 @@ document.addEventListener("DOMContentLoaded", async () => { // indentação já vem pronto do backend (linha.nivel), sem precisar derivar // de um código de classificação como no Balancete. let dcDreColapsadas = new Set(); - // Id da conta/linha com o editor inline de observação aberto (no máximo um - // por tabela) — substitui o antigo modal `#dc-observacao-modal`: clicar no - // botão de observação abre uma linha extra logo abaixo da conta/linha - // dentro da própria tabela, evitando perder o texto por causa de um clique - // fora do popup (pedido explícito do usuário). null = nenhum editor aberto. - let dcContaObsEditId = null; - let dcDreObsEditId = null; - // Mesma ideia, agora pra árvore da Análise Vertical (ContabilLinhaAnaliseVertical) - // — mesmo nível de indentação da DRE (linha.nivel), só que cada linha tem - // um valor+percentual por mês em vez de um valor único. + // Observações vigentes nesta apuração (as desta competência + as herdadas + // de competências anteriores ainda não encerradas) — carregadas à parte da + // apuração, porque não pertencem a ela: `GET /contabil-apuracoes/{id}/ + // observacoes/`, ver `ContabilObservacao` no backend. `dcObsIndice` é o + // mesmo conteúdo indexado pela chave natural do alvo, pra cada linha da + // tabela achar as suas sem varrer a lista inteira. + let dcObservacoes = []; + let dcObsIndice = { conta: new Map(), dre: new Map(), analise_vertical: new Map() }; + // Mesma ideia de dcDreColapsadas, agora pra árvore da Análise Vertical + // (ContabilLinhaAnaliseVertical) — mesmo nível de indentação da DRE + // (linha.nivel), só que cada linha tem um valor+percentual por mês em vez + // de um valor único. let dcAvColapsadas = new Set(); - let dcAvObsEditId = null; // Ids de ContabilAchado com o detalhe da conta usada no apontamento // expandido (ver renderAchados) — só achados com `conta` preenchido (as // regras gerais, ex. balanceamento Ativo x Passivo, não têm uma conta @@ -491,6 +546,11 @@ document.addEventListener("DOMContentLoaded", async () => { // dados desatualizados até sair e reabrir. if (apuracaoAtual && apuracaoAtual.id === atualizada.id) { apuracaoAtual = atualizada; + // As observações não são tocadas pelo reprocessamento (moram em + // `ContabilObservacao`, casadas por chave natural), mas recarrega + // junto mesmo assim: se uma conta mudou de código no PDF novo, a + // thread dela passa a pendurar em outra linha. + await dcCarregarObservacoes(); renderRevisao(); } await pidAlert("Análise reprocessada com sucesso."); @@ -553,6 +613,7 @@ document.addEventListener("DOMContentLoaded", async () => { try { const apuracao = await pidCriarApuracaoContabil(formData); apuracaoAtual = apuracao; + await dcCarregarObservacoes(); filtroSeveridade = "todas"; filtroStatusAchado = "pendente"; filtroRegra = null; @@ -570,6 +631,7 @@ document.addEventListener("DOMContentLoaded", async () => { async function abrirRevisao(id) { apuracaoAtual = await pidFetchApuracaoContabil(id); + await dcCarregarObservacoes(); filtroSeveridade = "todas"; filtroStatusAchado = "pendente"; filtroRegra = null; @@ -600,8 +662,8 @@ document.addEventListener("DOMContentLoaded", async () => { dcDreDestaque = dcUltimaLevaVisivel(apuracao.linhas_dre, (linha) => Math.max(0, linha.nivel), dcDreColapsadas); dcContasDestaqueHistorico = []; dcDreDestaqueHistorico = []; - dcContaObsEditId = null; - dcDreObsEditId = null; + dcObsPainelAberto = { conta: null, dre: null, analise_vertical: null }; + dcObsEditandoId = null; dcAchadosContaExpandida = new Set(); dcIndicadoresAtual = null; @@ -629,7 +691,6 @@ document.addEventListener("DOMContentLoaded", async () => { dcAvColapsadas ); dcAvDestaqueHistorico = []; - dcAvObsEditId = null; renderAchados(); renderContas(); @@ -1201,15 +1262,13 @@ document.addEventListener("DOMContentLoaded", async () => { // Conteúdo do botão de observação de uma conta/linha — pedido explícito // do usuário: a coluna "Observação" ficava poluída visualmente com // "+ Observação"/o texto da observação em si repetido em toda linha; - // virou sempre o mesmo ícone (o `title`/tooltip mostra o texto da - // observação quando houver, "Adicionar observação" quando não) — só a cor - // muda (`.dc-conta-observacao-btn--preenchida`, ver dashboard-contabil.css) - // pra indicar de relance quais linhas já têm observação, sem precisar - // mostrar o texto inteiro na tabela. Clicar não abre mais um popup — abre - // um editor inline dentro da própria tabela (ver dcContaObsEditId/ - // dcDreObsEditId acima e os `` abaixo), pedido - // explícito do usuário pra não perder o texto por causa de um clique fora - // de um modal. + // virou sempre o mesmo ícone, só a cor muda + // (`.dc-conta-observacao-btn--preenchida`, ver dashboard-contabil.css) pra + // indicar de relance quais linhas já têm observação, com um contador ao + // lado (ver dcObsBotaoHtml). Clicar não abre um popup — abre o painel de + // observações dentro da própria tabela (``), + // pedido explícito do usuário pra não perder o texto por causa de um + // clique fora de um modal. const PID_DC_OBSERVACAO_ICONE = ''; @@ -1220,6 +1279,20 @@ document.addEventListener("DOMContentLoaded", async () => { const PID_DC_VALIDADO_ICONE = ''; + // Tooltip customizado no visual do Portal (ver `.dc-hover-tooltip` em + // dashboard-contabil.css) — troca o balão nativo do navegador (que mostra + // o `title`, sem estilo nenhum do app) por um `` posicionado via CSS + // puro, pedido explícito do usuário depois de ver o tooltip nativo nos + // badges abaixo. `gatilhoHtml` é o ícone (já pronto, com sua própria + // classe/cor); `texto` vai só no `` do tooltip (nunca em `title`, + // que voltaria a mostrar o balão nativo do navegador em cima do custom). + function pidDcHoverTooltipHtml(gatilhoHtml, gatilhoClasse, texto) { + return ` + ${gatilhoHtml} + ${pidDcEscapeHtml(texto)} + `; + } + // Badge de alerta ao lado dos botões de validado/observação — marca uma // conta/linha que mudou no último reprocessamento (`alterada_reprocessamento`, // ver `ContabilApuracaoViewSet.reprocessar()`/`_contabil_sincroniza_*` em @@ -1243,8 +1316,8 @@ document.addEventListener("DOMContentLoaded", async () => { const situacao = validado ? "Esta conta/linha mudou no último reprocessamento e já foi revalidada." : "Esta conta/linha mudou no último reprocessamento e voltou a ficar não validada."; - const titulo = valorAnteriorTexto ? `${situacao}\nValor antes do reprocessamento: ${valorAnteriorTexto}` : situacao; - return `${PID_DC_ALTERADA_ICONE}`; + const texto = valorAnteriorTexto ? `${situacao}\nValor antes do reprocessamento: ${valorAnteriorTexto}` : situacao; + return pidDcHoverTooltipHtml(PID_DC_ALTERADA_ICONE, classe, texto); } // Só usado pela Análise Vertical (Balancete/DRE têm um valor único — @@ -1276,9 +1349,376 @@ document.addEventListener("DOMContentLoaded", async () => { function pidDcFontePdfAtipicaBadgeHtml(fontePdfAtipica) { if (!fontePdfAtipica) return ""; - return `${PID_DC_FONTE_ATIPICA_ICONE}`; + const texto = + "Este relatório foi gerado a partir de um modelo de PDF diferente do padrão — pequenos erros de nomenclatura podem ocorrer em algumas contas/linhas devido à fonte usada neste documento. Vale conferir os nomes com atenção."; + return pidDcHoverTooltipHtml(PID_DC_FONTE_ATIPICA_ICONE, "dc-fonte-atipica-badge", texto); } + // ---- Observações: histórico por empresa+conta (ContabilObservacao) ---- + // A observação deixou de ser um campo da linha e virou uma thread por + // conta/linha que atravessa competências: o que o contador escreveu em + // 07/2026 continua aparecendo em 08/2026, assinado e travado pra edição, + // até ele encerrar ("ocultar das próximas execuções"). Por isso nada aqui + // é casado por id de linha — a linha é recriada a cada apuração, a + // observação não; o vínculo é a chave natural (`alvo_tipo`+`alvo_chave`), + // as mesmas usadas pelo reprocessamento em `_contabil_sincroniza_*`. + + // Qual linha está com o painel de observações aberto, por tabela (no + // máximo uma por tabela, mesmo espírito dos antigos dcContaObsEditId/ + // dcDreObsEditId/dcAvObsEditId que este objeto substituiu). + let dcObsPainelAberto = { conta: null, dre: null, analise_vertical: null }; + // Observação (id) em edição inline dentro do painel — só uma por vez, e só + // enquanto ela for da competência aberta (histórica nunca é editável). + let dcObsEditandoId = null; + // "todas" | "cliente" | "internas" — filtro das listas de resumo (chips em + // cada aba e na aba "Dashboard"); não afeta a thread dentro do painel, que + // é sempre o histórico completo daquela conta. + let dcObsFiltro = "todas"; + + // Rótulo da aba de origem de cada tipo de alvo, usado na lista consolidada + // da aba "Dashboard" (os resumos por aba não precisam, já são de um tipo). + const PID_DC_OBS_ORIGENS = { conta: "Balancete", dre: "D.R.E.", analise_vertical: "Análise Vertical" }; + + function dcChaveObsConta(conta) { + return conta.codigo; + } + + function dcChaveObsLinha(linha) { + return `${linha.descricao}|${linha.nivel}`; + } + + function dcReindexaObservacoes() { + dcObsIndice = { conta: new Map(), dre: new Map(), analise_vertical: new Map() }; + dcObservacoes.forEach((obs) => { + const mapa = dcObsIndice[obs.alvo_tipo]; + if (!mapa) return; + if (!mapa.has(obs.alvo_chave)) mapa.set(obs.alvo_chave, []); + mapa.get(obs.alvo_chave).push(obs); + }); + } + + async function dcCarregarObservacoes() { + dcObservacoes = await pidBuscarObservacoesContabil(apuracaoAtual.id); + dcReindexaObservacoes(); + } + + function dcObservacoesDe(tipo, chave) { + return dcObsIndice[tipo].get(chave) || []; + } + + function dcObservacoesDoTipo(tipo) { + return dcObservacoes.filter((obs) => obs.alvo_tipo === tipo); + } + + function dcObsAplicaFiltro(lista) { + if (dcObsFiltro === "cliente") return lista.filter((obs) => obs.mostrar_ao_cliente); + if (dcObsFiltro === "internas") return lista.filter((obs) => !obs.mostrar_ao_cliente); + return lista; + } + + function dcRenderTabelaDoTipo(tipo) { + if (tipo === "conta") renderContas(); + else if (tipo === "dre") renderDre(); + else renderAnaliseVertical(); + } + + // Qualquer mutação de observação afeta as 3 tabelas (a mesma conta pode + // aparecer em mais de uma), os resumos por aba e a lista consolidada da + // aba "Dashboard" — mais simples (e barato o bastante) re-renderizar tudo + // do que tentar acertar só o pedaço que mudou. + function dcRenderObservacoesTudo() { + renderContas(); + renderDre(); + renderAnaliseVertical(); + renderDashboardObservacoes(); + } + + // Botão da coluna "Observação": ícone com a contagem de observações + // vigentes daquela conta/linha (as desta competência mais as herdadas), + // colorido quando há alguma — o texto em si nunca aparece na tabela, só no + // painel (pedido antigo do usuário pra não poluir a coluna). + function dcObsBotaoHtml(tipo, alvoId, observacoes) { + const doMes = observacoes.filter((obs) => !obs.historica).length; + const herdadas = observacoes.length - doMes; + let titulo = "Adicionar observação"; + if (observacoes.length) { + const partes = []; + if (doMes) partes.push(`${doMes} desta competência`); + if (herdadas) partes.push(`${herdadas} do histórico`); + titulo = `${observacoes.length} observação(ões): ${partes.join(", ")}`; + } + const contador = observacoes.length ? `${observacoes.length}` : ""; + return ``; + } + + const PID_DC_ICON_ENCERRAR = + ''; + const PID_DC_ICON_REATIVAR = + ''; + + function dcObsItemHtml(obs, concluida) { + // "Histórica" = veio de uma competência anterior: texto/autor/data + // congelados (o backend também recusa a edição, ver + // `ContabilObservacaoViewSet._garante_texto_editavel`). + const podeEditarTexto = !concluida && !obs.historica; + const assinatura = `${pidDcEscapeHtml(obs.criado_por_nome || "Usuário removido")} · ${pidDcFormatData(obs.criado_em)} · competência ${pidDcFormatCompetencia(obs.competencia_origem)}`; + const selos = []; + if (obs.historica) selos.push('Histórico'); + if (obs.encerrada) { + selos.push( + `Encerrada em ${pidDcFormatCompetencia(obs.encerrada_em_competencia)}` + ); + } + selos.push( + obs.mostrar_ao_cliente + ? 'Aparece ao cliente' + : 'Interna' + ); + + const acoes = [ + ``, + ]; + if (podeEditarTexto) { + acoes.push( + ``, + `` + ); + } + if (!concluida) { + acoes.push( + obs.encerrada + ? `` + : `` + ); + } + + const corpo = + dcObsEditandoId === obs.id && podeEditarTexto + ? ` + + ` + : `

${pidDcEscapeHtml(obs.texto)}

`; + + return ` +
+
+ ${assinatura} + ${selos.join("")} + ${acoes.join("")} +
+ ${corpo} +
+ `; + } + + // `` extra logo abaixo da conta/linha (mesma `.dc-obs-edit-row` de + // antes): a thread do histórico em cima, o campo de observação nova + // embaixo. Uma observação nunca é sobrescrita — escrever de novo cria mais + // um item na thread. + function dcObsPainelHtml(tipo, alvoId, observacoes, colspan, concluida) { + const thread = observacoes.length + ? observacoes.map((obs) => dcObsItemHtml(obs, concluida)).join("") + : '

Nenhuma observação registrada até agora.

'; + const nova = concluida + ? "" + : `
+ + + + +
`; + const rodape = concluida + ? '

Análise concluída, histórico somente leitura.

' + : ""; + return ` + +
+
${thread}
+ ${nova}${rodape} +
+ + `; + } + + // Handler único das ações de observação, compartilhado pelos três `tbody` + // (Balancete/DRE/Análise Vertical) — devolve `true` quando tratou o clique, + // pra cada listener de tabela continuar cuidando só do que é dele + // (expandir/recolher, validado). + async function dcTrataCliqueObservacao(event, tipo) { + const abrirBtn = event.target.closest("[data-dc-obs-abrir]"); + if (abrirBtn) { + const [tipoBtn, idTexto] = abrirBtn.getAttribute("data-dc-obs-abrir").split(":"); + const id = Number(idTexto); + dcObsPainelAberto[tipoBtn] = dcObsPainelAberto[tipoBtn] === id ? null : id; + dcObsEditandoId = null; + dcRenderTabelaDoTipo(tipo); + return true; + } + + const fecharBtn = event.target.closest("[data-dc-obs-fechar]"); + if (fecharBtn) { + dcObsPainelAberto[fecharBtn.getAttribute("data-dc-obs-fechar")] = null; + dcObsEditandoId = null; + dcRenderTabelaDoTipo(tipo); + return true; + } + + const criarBtn = event.target.closest("[data-dc-obs-criar]"); + if (criarBtn) { + const [alvoTipo, idTexto] = criarBtn.getAttribute("data-dc-obs-criar").split(":"); + const alvoId = Number(idTexto); + const erroEl = document.getElementById(`dc-obs-nova-erro-${alvoTipo}-${alvoId}`); + const texto = document.getElementById(`dc-obs-nova-texto-${alvoTipo}-${alvoId}`).value.trim(); + const mostrar = document.getElementById(`dc-obs-nova-mostrar-${alvoTipo}-${alvoId}`).checked; + erroEl.textContent = ""; + if (!texto) { + erroEl.textContent = "Escreva a observação antes de salvar."; + return true; + } + criarBtn.disabled = true; + try { + await pidCriarObservacaoContabil(apuracaoAtual.id, alvoTipo, alvoId, texto, mostrar); + await dcCarregarObservacoes(); + dcRenderObservacoesTudo(); + } catch (e) { + erroEl.textContent = e.message; + criarBtn.disabled = false; + } + return true; + } + + const editarBtn = event.target.closest("[data-dc-obs-editar]"); + if (editarBtn) { + dcObsEditandoId = Number(editarBtn.getAttribute("data-dc-obs-editar")); + dcRenderTabelaDoTipo(tipo); + return true; + } + + const editCancelarBtn = event.target.closest("[data-dc-obs-edit-cancelar]"); + if (editCancelarBtn) { + dcObsEditandoId = null; + dcRenderTabelaDoTipo(tipo); + return true; + } + + const editSalvarBtn = event.target.closest("[data-dc-obs-edit-salvar]"); + if (editSalvarBtn) { + const id = Number(editSalvarBtn.getAttribute("data-dc-obs-edit-salvar")); + const erroEl = document.getElementById(`dc-obs-edit-erro-${id}`); + const texto = document.getElementById(`dc-obs-edit-texto-${id}`).value.trim(); + erroEl.textContent = ""; + if (!texto) { + erroEl.textContent = "A observação não pode ficar vazia. Exclua-a se não quiser mais registrá-la."; + return true; + } + editSalvarBtn.disabled = true; + try { + await pidAtualizarObservacaoContabil(id, { texto }); + dcObsEditandoId = null; + await dcCarregarObservacoes(); + dcRenderObservacoesTudo(); + } catch (e) { + erroEl.textContent = e.message; + editSalvarBtn.disabled = false; + } + return true; + } + + const excluirBtn = event.target.closest("[data-dc-obs-excluir]"); + if (excluirBtn) { + const id = Number(excluirBtn.getAttribute("data-dc-obs-excluir")); + if (!(await pidConfirm("Excluir esta observação?", { perigoso: true }))) return true; + try { + await pidExcluirObservacaoContabil(id); + await dcCarregarObservacoes(); + dcRenderObservacoesTudo(); + } catch (e) { + await pidAlert(e.message); + } + return true; + } + + const encerrarBtn = event.target.closest("[data-dc-obs-encerrar]"); + if (encerrarBtn) { + const id = Number(encerrarBtn.getAttribute("data-dc-obs-encerrar")); + const ok = await pidConfirm( + "Ocultar esta observação das próximas competências? Ela continua registrada aqui e no histórico desta análise." + ); + if (!ok) return true; + try { + await pidEncerrarObservacaoContabil(id, apuracaoAtual.id); + await dcCarregarObservacoes(); + dcRenderObservacoesTudo(); + } catch (e) { + await pidAlert(e.message); + } + return true; + } + + const reativarBtn = event.target.closest("[data-dc-obs-reativar]"); + if (reativarBtn) { + const id = Number(reativarBtn.getAttribute("data-dc-obs-reativar")); + try { + await pidReativarObservacaoContabil(id, apuracaoAtual.id); + await dcCarregarObservacoes(); + dcRenderObservacoesTudo(); + } catch (e) { + await pidAlert(e.message); + } + return true; + } + + const visibilidadeBtn = event.target.closest("[data-dc-obs-visibilidade]"); + if (visibilidadeBtn) { + if (visibilidadeBtn.disabled) return true; + const id = Number(visibilidadeBtn.getAttribute("data-dc-obs-visibilidade")); + const obs = dcObservacoes.find((o) => o.id === id); + visibilidadeBtn.disabled = true; + try { + await pidAtualizarObservacaoContabil(id, { mostrar_ao_cliente: !obs.mostrar_ao_cliente }); + await dcCarregarObservacoes(); + dcRenderObservacoesTudo(); + } catch (e) { + visibilidadeBtn.disabled = false; + await pidAlert(e.message); + } + return true; + } + + return false; + } + + // Os chips "Todas / Visíveis ao cliente / Internas" existem em quatro + // lugares (resumo de cada aba + lista consolidada da aba "Dashboard") e + // compartilham a mesma variável — filtrar numa aba filtra em todas, que é o + // comportamento esperado de um filtro de visibilidade global. + document.querySelectorAll("[data-dc-obs-filtros]").forEach((grupo) => { + grupo.addEventListener("click", (event) => { + const btn = event.target.closest("[data-dc-obs-filtro]"); + if (!btn) return; + dcObsFiltro = btn.getAttribute("data-dc-obs-filtro"); + document.querySelectorAll("[data-dc-obs-filtro]").forEach((chip) => { + chip.classList.toggle("is-active", chip.getAttribute("data-dc-obs-filtro") === dcObsFiltro); + }); + if (apuracaoAtual) dcRenderObservacoesTudo(); + }); + }); + function renderContas() { const body = document.getElementById("dc-contas-body"); body.innerHTML = ""; @@ -1311,6 +1751,7 @@ document.addEventListener("DOMContentLoaded", async () => { : ``; const validadoInfo = dcValidadoInfo(conta, contas, dcContaNivel, temFilhos[i]); + const observacoesDaConta = dcObservacoesDe("conta", dcChaveObsConta(conta)); const tr = document.createElement("tr"); if (conta.tipo === "S") tr.classList.add("dc-conta-row--sintetica"); @@ -1329,9 +1770,7 @@ document.addEventListener("DOMContentLoaded", async () => { - + ${dcObsBotaoHtml("conta", conta.id, observacoesDaConta)} ${pidDcAlteradaBadgeHtml( conta.alterada_reprocessamento, conta.validado, @@ -1342,27 +1781,10 @@ document.addEventListener("DOMContentLoaded", async () => { `; body.appendChild(tr); - if (dcContaObsEditId === conta.id) { + if (dcObsPainelAberto.conta === conta.id) { const editRow = document.createElement("tr"); editRow.className = "dc-obs-edit-row"; - editRow.innerHTML = ` - -
- - - - -
- - `; + editRow.innerHTML = dcObsPainelHtml("conta", conta.id, observacoesDaConta, 7, concluida); body.appendChild(editRow); } }); @@ -1404,43 +1826,7 @@ document.addEventListener("DOMContentLoaded", async () => { return; } - const obsBtn = event.target.closest("[data-dc-conta-observacao]"); - if (obsBtn) { - const id = Number(obsBtn.getAttribute("data-dc-conta-observacao")); - dcContaObsEditId = dcContaObsEditId === id ? null : id; - renderContas(); - return; - } - - const cancelBtn = event.target.closest("[data-dc-conta-obs-cancel]"); - if (cancelBtn) { - dcContaObsEditId = null; - renderContas(); - return; - } - - const saveBtn = event.target.closest("[data-dc-conta-obs-save]"); - if (saveBtn) { - const id = Number(saveBtn.getAttribute("data-dc-conta-obs-save")); - const errorEl = document.getElementById(`dc-conta-obs-error-${id}`); - errorEl.textContent = ""; - const texto = document.getElementById(`dc-conta-obs-texto-${id}`).value; - const mostrar = document.getElementById(`dc-conta-obs-mostrar-${id}`).checked; - saveBtn.disabled = true; - try { - const atualizada = await pidAtualizarObservacaoContaContabil(id, { - observacao: texto, - oculta_no_relatorio: !mostrar, - }); - const indice = apuracaoAtual.contas.findIndex((c) => c.id === id); - apuracaoAtual.contas[indice] = atualizada; - dcContaObsEditId = null; - renderContas(); - } catch (e) { - errorEl.textContent = e.message; - saveBtn.disabled = false; - } - } + await dcTrataCliqueObservacao(event, "conta"); }); // Mesma árvore recolhível do Balancete (ver renderContas), agora sobre @@ -1475,6 +1861,7 @@ document.addEventListener("DOMContentLoaded", async () => { : ``; const validadoInfo = dcValidadoInfo(linha, linhas, (l) => Math.max(0, l.nivel), temFilhos[i]); + const observacoesDaLinha = dcObservacoesDe("dre", dcChaveObsLinha(linha)); const tr = document.createElement("tr"); tr.className = linha.totalizador ? "dc-dre-row dc-dre-row--totalizador" : "dc-dre-row"; @@ -1490,9 +1877,7 @@ document.addEventListener("DOMContentLoaded", async () => { - + ${dcObsBotaoHtml("dre", linha.id, observacoesDaLinha)} ${pidDcAlteradaBadgeHtml( linha.alterada_reprocessamento, linha.validado, @@ -1503,27 +1888,10 @@ document.addEventListener("DOMContentLoaded", async () => { `; body.appendChild(tr); - if (dcDreObsEditId === linha.id) { + if (dcObsPainelAberto.dre === linha.id) { const editRow = document.createElement("tr"); editRow.className = "dc-obs-edit-row"; - editRow.innerHTML = ` - -
- - - - -
- - `; + editRow.innerHTML = dcObsPainelHtml("dre", linha.id, observacoesDaLinha, 3, concluida); body.appendChild(editRow); } }); @@ -1564,43 +1932,7 @@ document.addEventListener("DOMContentLoaded", async () => { return; } - const obsBtn = event.target.closest("[data-dc-dre-observacao]"); - if (obsBtn) { - const id = Number(obsBtn.getAttribute("data-dc-dre-observacao")); - dcDreObsEditId = dcDreObsEditId === id ? null : id; - renderDre(); - return; - } - - const cancelBtn = event.target.closest("[data-dc-dre-obs-cancel]"); - if (cancelBtn) { - dcDreObsEditId = null; - renderDre(); - return; - } - - const saveBtn = event.target.closest("[data-dc-dre-obs-save]"); - if (saveBtn) { - const id = Number(saveBtn.getAttribute("data-dc-dre-obs-save")); - const errorEl = document.getElementById(`dc-dre-obs-error-${id}`); - errorEl.textContent = ""; - const texto = document.getElementById(`dc-dre-obs-texto-${id}`).value; - const mostrar = document.getElementById(`dc-dre-obs-mostrar-${id}`).checked; - saveBtn.disabled = true; - try { - const atualizada = await pidAtualizarObservacaoLinhaDreContabil(id, { - observacao: texto, - oculta_no_relatorio: !mostrar, - }); - const indice = apuracaoAtual.linhas_dre.findIndex((l) => l.id === id); - apuracaoAtual.linhas_dre[indice] = atualizada; - dcDreObsEditId = null; - renderDre(); - } catch (e) { - errorEl.textContent = e.message; - saveBtn.disabled = false; - } - } + await dcTrataCliqueObservacao(event, "dre"); }); // Cabeçalho de coluna da Análise Vertical é montado em JS (não hardcoded @@ -1662,6 +1994,7 @@ document.addEventListener("DOMContentLoaded", async () => { : ``; const validadoInfo = dcValidadoInfo(linha, linhas, nivelFn, temFilhos[i]); + const observacoesDaLinha = dcObservacoesDe("analise_vertical", dcChaveObsLinha(linha)); const colunasMensais = linha.valores .map( (v) => @@ -1683,36 +2016,17 @@ document.addEventListener("DOMContentLoaded", async () => { - + ${dcObsBotaoHtml("analise_vertical", linha.id, observacoesDaLinha)} ${pidDcAlteradaBadgeHtml(linha.alterada_reprocessamento, linha.validado, pidDcValorAnteriorAvTexto(linha))} `; body.appendChild(tr); - if (dcAvObsEditId === linha.id) { + if (dcObsPainelAberto.analise_vertical === linha.id) { const editRow = document.createElement("tr"); editRow.className = "dc-obs-edit-row"; - editRow.innerHTML = ` - -
- - - - -
- - `; + editRow.innerHTML = dcObsPainelHtml("analise_vertical", linha.id, observacoesDaLinha, colspan, concluida); body.appendChild(editRow); } }); @@ -1753,43 +2067,7 @@ document.addEventListener("DOMContentLoaded", async () => { return; } - const obsBtn = event.target.closest("[data-dc-av-observacao]"); - if (obsBtn) { - const id = Number(obsBtn.getAttribute("data-dc-av-observacao")); - dcAvObsEditId = dcAvObsEditId === id ? null : id; - renderAnaliseVertical(); - return; - } - - const cancelBtn = event.target.closest("[data-dc-av-obs-cancel]"); - if (cancelBtn) { - dcAvObsEditId = null; - renderAnaliseVertical(); - return; - } - - const saveBtn = event.target.closest("[data-dc-av-obs-save]"); - if (saveBtn) { - const id = Number(saveBtn.getAttribute("data-dc-av-obs-save")); - const errorEl = document.getElementById(`dc-av-obs-error-${id}`); - errorEl.textContent = ""; - const texto = document.getElementById(`dc-av-obs-texto-${id}`).value; - const mostrar = document.getElementById(`dc-av-obs-mostrar-${id}`).checked; - saveBtn.disabled = true; - try { - const atualizada = await pidAtualizarObservacaoLinhaAnaliseVerticalContabil(id, { - observacao: texto, - oculta_no_relatorio: !mostrar, - }); - const indice = apuracaoAtual.linhas_analise_vertical.findIndex((l) => l.id === id); - apuracaoAtual.linhas_analise_vertical[indice] = atualizada; - dcAvObsEditId = null; - renderAnaliseVertical(); - } catch (e) { - errorEl.textContent = e.message; - saveBtn.disabled = false; - } - } + await dcTrataCliqueObservacao(event, "analise_vertical"); }); // ---- Tabs ---- @@ -2632,236 +2910,149 @@ document.addEventListener("DOMContentLoaded", async () => { } }); + // Lista consolidada da aba "Dashboard": as observações vigentes das três + // tabelas (Balancete/DRE/Análise Vertical) mais as justificativas de + // auditoria — pré-visualização do que vai (ou não) pro relatório do + // cliente. Observação segue o histórico (`ContabilObservacao`), auditoria + // continua presa à apuração (`ContabilAchado.oculto_no_relatorio`), então + // os dois tipos de item têm ações diferentes. 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, - })), + const observacoes = dcObsAplicaFiltro(dcObservacoes); + const achados = apuracaoAtual.achados.filter((achado) => { + if (!achado.observacao_contador) return false; + if (dcObsFiltro === "cliente") return !achado.oculto_no_relatorio; + if (dcObsFiltro === "internas") return achado.oculto_no_relatorio; + return true; + }); + + const html = [ + ...observacoes.map((obs) => dcObsResumoItemHtml(obs, PID_DC_OBS_ORIGENS[obs.alvo_tipo], concluida)), + ...achados.map( + (achado) => ` +
+
+ Auditoria + ${pidDcEscapeHtml(achado.titulo)} +

${pidDcEscapeHtml(achado.observacao_contador)}

+
+ +
+ ` + ), ]; - empty.hidden = itens.length > 0; - - itens.forEach((item) => { - const linha = document.createElement("div"); - linha.className = `dc-dash-obs${item.oculto ? " is-oculto" : ""}`; - linha.innerHTML = ` -
- ${pidDcEscapeHtml(item.origem)} - ${pidDcEscapeHtml(item.referencia)} -

${pidDcEscapeHtml(item.texto)}

-
- - `; - lista.appendChild(linha); - }); + empty.hidden = html.length > 0; + lista.innerHTML = html.join(""); } 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 achadoBtn = event.target.closest("[data-dc-dash-achado-toggle]"); + if (achadoBtn) { + if (achadoBtn.disabled) return; + const id = Number(achadoBtn.getAttribute("data-dc-dash-achado-toggle")); + const achado = apuracaoAtual.achados.find((a) => a.id === id); + achadoBtn.disabled = true; + try { 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) { + achadoBtn.disabled = false; + await pidAlert(e.message); } - renderDashboardObservacoes(); - } catch (e) { - btn.disabled = false; - await pidAlert(e.message); + return; } + await dcTrataCliqueObservacao(event, "conta"); }); - // ---- Resumo de observações no final do Balancete/DRE ---- - // Pedido explícito do usuário: além da lista combinada (Balancete+DRE+ - // Auditoria) que já existia na aba "Dashboard", cada aba (Balancete/DRE) - // ganha seu próprio resumo — quantidade + as observações já registradas - // ali, com o mesmo botão de olho pra mostrar/ocultar do relatório do - // cliente, sem precisar trocar de aba pra isso. Reaproveita a marcação - // visual `.dc-dash-obs*` já usada pela lista combinada. + // ---- Resumo de observações no final de cada aba ---- + // Cada aba (Balancete/DRE/Análise Vertical) lista as observações vigentes + // daquela seção — as escritas nesta competência e as herdadas do histórico + // —, com a assinatura de quem escreveu, o botão de olho (mostrar ao + // cliente) e o de encerrar/reativar, sem precisar abrir conta por conta. + // Os chips "Todas / Visíveis ao cliente / Internas" filtram as quatro + // listas ao mesmo tempo (ver dcObsFiltro). - function pidDcObsItemHtml(item, concluida, toggleAttr) { + function dcObsResumoItemHtml(obs, origem, concluida) { + const selos = []; + if (obs.historica) selos.push('Histórico'); + if (obs.encerrada) { + selos.push( + `Encerrada em ${pidDcFormatCompetencia(obs.encerrada_em_competencia)}` + ); + } + const acaoEncerrar = concluida + ? "" + : obs.encerrada + ? `` + : ``; return ` -
+
- ${pidDcEscapeHtml(item.referencia)} -

${pidDcEscapeHtml(item.texto)}

+ ${origem ? `${pidDcEscapeHtml(origem)}` : ""} + ${pidDcEscapeHtml(obs.alvo_rotulo || obs.alvo_chave)} +

${pidDcEscapeHtml(obs.texto)}

+ ${pidDcEscapeHtml(obs.criado_por_nome || "Usuário removido")} · ${pidDcFormatData(obs.criado_em)} · competência ${pidDcFormatCompetencia(obs.competencia_origem)} + ${selos.length ? `${selos.join("")}` : ""} +
+
+ + ${acaoEncerrar}
-
`; } - function renderContasObsResumo() { - const lista = document.getElementById("dc-contas-obs-list"); - const empty = document.getElementById("dc-contas-obs-empty"); - const count = document.getElementById("dc-contas-obs-count"); + function dcRenderObsResumo(tipo, idLista, idVazio, idContador) { + const lista = document.getElementById(idLista); + const empty = document.getElementById(idVazio); + const count = document.getElementById(idContador); const concluida = apuracaoAtual.status === "concluida"; - const itens = apuracaoAtual.contas.filter((conta) => conta.observacao); + const itens = dcObsAplicaFiltro(dcObservacoesDoTipo(tipo)); count.textContent = String(itens.length); empty.hidden = itens.length > 0; - lista.innerHTML = itens - .map((conta) => - pidDcObsItemHtml( - { id: conta.id, referencia: `${conta.codigo} — ${conta.descricao}`, texto: conta.observacao, oculto: conta.oculta_no_relatorio }, - concluida, - "data-dc-contas-resumo-toggle" - ) - ) - .join(""); + lista.innerHTML = itens.map((obs) => dcObsResumoItemHtml(obs, null, concluida)).join(""); } - document.getElementById("dc-contas-obs-list").addEventListener("click", async (event) => { - const btn = event.target.closest("[data-dc-contas-resumo-toggle]"); - if (!btn || btn.disabled) return; - const id = Number(btn.getAttribute("data-dc-contas-resumo-toggle")); - const conta = apuracaoAtual.contas.find((c) => c.id === id); - btn.disabled = true; - try { - const atualizada = await pidAlternarOcultoContaContabil(id, !conta.oculta_no_relatorio); - const indice = apuracaoAtual.contas.findIndex((c) => c.id === id); - apuracaoAtual.contas[indice] = atualizada; - renderContasObsResumo(); - } catch (e) { - btn.disabled = false; - await pidAlert(e.message); - } - }); + function renderContasObsResumo() { + dcRenderObsResumo("conta", "dc-contas-obs-list", "dc-contas-obs-empty", "dc-contas-obs-count"); + } function renderDreObsResumo() { - const lista = document.getElementById("dc-dre-obs-list"); - const empty = document.getElementById("dc-dre-obs-empty"); - const count = document.getElementById("dc-dre-obs-count"); - const concluida = apuracaoAtual.status === "concluida"; - const itens = apuracaoAtual.linhas_dre.filter((linha) => linha.observacao); - count.textContent = String(itens.length); - empty.hidden = itens.length > 0; - lista.innerHTML = itens - .map((linha) => - pidDcObsItemHtml( - { id: linha.id, referencia: linha.descricao, texto: linha.observacao, oculto: linha.oculta_no_relatorio }, - concluida, - "data-dc-dre-resumo-toggle" - ) - ) - .join(""); + dcRenderObsResumo("dre", "dc-dre-obs-list", "dc-dre-obs-empty", "dc-dre-obs-count"); } - document.getElementById("dc-dre-obs-list").addEventListener("click", async (event) => { - const btn = event.target.closest("[data-dc-dre-resumo-toggle]"); - if (!btn || btn.disabled) return; - const id = Number(btn.getAttribute("data-dc-dre-resumo-toggle")); - const linha = apuracaoAtual.linhas_dre.find((l) => l.id === id); - btn.disabled = true; - try { - const atualizada = await pidAlternarOcultoLinhaDreContabil(id, !linha.oculta_no_relatorio); - const indice = apuracaoAtual.linhas_dre.findIndex((l) => l.id === id); - apuracaoAtual.linhas_dre[indice] = atualizada; - renderDreObsResumo(); - } catch (e) { - btn.disabled = false; - await pidAlert(e.message); - } - }); - function renderAnaliseVerticalObsResumo() { - const lista = document.getElementById("dc-av-obs-list"); - const empty = document.getElementById("dc-av-obs-empty"); - const count = document.getElementById("dc-av-obs-count"); - const concluida = apuracaoAtual.status === "concluida"; - const itens = apuracaoAtual.linhas_analise_vertical.filter((linha) => linha.observacao); - count.textContent = String(itens.length); - empty.hidden = itens.length > 0; - lista.innerHTML = itens - .map((linha) => - pidDcObsItemHtml( - { id: linha.id, referencia: linha.descricao, texto: linha.observacao, oculto: linha.oculta_no_relatorio }, - concluida, - "data-dc-av-resumo-toggle" - ) - ) - .join(""); + dcRenderObsResumo("analise_vertical", "dc-av-obs-list", "dc-av-obs-empty", "dc-av-obs-count"); } - document.getElementById("dc-av-obs-list").addEventListener("click", async (event) => { - const btn = event.target.closest("[data-dc-av-resumo-toggle]"); - if (!btn || btn.disabled) return; - const id = Number(btn.getAttribute("data-dc-av-resumo-toggle")); - const linha = apuracaoAtual.linhas_analise_vertical.find((l) => l.id === id); - btn.disabled = true; - try { - const atualizada = await pidAlternarOcultoLinhaAnaliseVerticalContabil(id, !linha.oculta_no_relatorio); - const indice = apuracaoAtual.linhas_analise_vertical.findIndex((l) => l.id === id); - apuracaoAtual.linhas_analise_vertical[indice] = atualizada; - renderAnaliseVerticalObsResumo(); - } catch (e) { - btn.disabled = false; - await pidAlert(e.message); - } + ["dc-contas-obs-list", "dc-dre-obs-list", "dc-av-obs-list"].forEach((id) => { + document.getElementById(id).addEventListener("click", async (event) => { + await dcTrataCliqueObservacao(event, "conta"); + }); }); // ---- Modal de achado (tratar/ignorar) ---- diff --git a/templates/dashboard-contabil-relatorio.html b/templates/dashboard-contabil-relatorio.html index d9f3084..960da85 100644 --- a/templates/dashboard-contabil-relatorio.html +++ b/templates/dashboard-contabil-relatorio.html @@ -419,8 +419,9 @@ .dcr-toggle-spacer { display: inline-block; width: 18px; height: 18px; flex-shrink: 0; } /* Ícone de observação na tabela do Balancete/DRE (coluna "Observação") — - só aparece na linha quando há mesmo uma observação visível ao cliente - (ver `oculta_no_relatorio` no template); clicar abre `.dcr-obs-inline-row` + só aparece na linha quando há mesmo uma observação vigente marcada como + visível ao cliente (`item.observacoes`, já filtrado na view a partir de + `ContabilObservacao.mostrar_ao_cliente`); clicar abre `.dcr-obs-inline-row` logo abaixo, mesmo espírito do editor inline da tela de revisão do Portal (`dc-conta-observacao-btn`/`dc-obs-edit-row`, dashboard-contabil.js). */ .dcr-obs-btn { @@ -485,6 +486,9 @@ } .dcr-obs-item__titulo { font-weight: 700; font-size: 0.85rem; color: var(--roxo-escuro); } .dcr-obs-item__texto { font-size: 0.85rem; margin-top: 3px; white-space: pre-wrap; color: var(--texto); } + /* Assinatura do histórico: quem escreveu, quando e (quando vem de um mês + anterior) em que competência foi registrada — ver `ContabilObservacao`. */ + .dcr-obs-item__assinatura { font-size: 0.72rem; margin-top: 5px; color: var(--cinza-texto, #6b6b78); } .dcr-vazio { color: var(--texto-muted); font-size: 0.85rem; font-style: italic; } /* Resumo do Fechamento — texto rico escrito pelo contador na aba @@ -605,20 +609,22 @@ {{ item.conta.credito|moeda }} {{ item.conta.saldo_atual|moeda }} - {% if item.conta.observacao and not item.conta.oculta_no_relatorio %} + {% if item.observacoes %} {% endif %} - {% if item.conta.observacao and not item.conta.oculta_no_relatorio %} + {% if item.observacoes %} + {% for obs in item.observacoes %}
- Observação do contador -

{{ item.conta.observacao }}

+ Observação do contador · {{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · registrada na competência {{ obs.competencia_origem|competencia }}{% endif %} +

{{ obs.texto }}

+ {% endfor %} {% endif %} @@ -635,12 +641,13 @@ {% if observacoes_contas %}
    - {% for conta in observacoes_contas %} + {% for obs in observacoes_contas %}
  • -
    {{ conta.codigo }} — {{ conta.descricao }}
    -
    {{ conta.observacao }}
    +
    {{ obs.alvo_chave }} — {{ obs.alvo_rotulo }}
    +
    {{ obs.texto }}
    +
    {{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · competência {{ obs.competencia_origem|competencia }}{% endif %}
  • {% endfor %} @@ -681,20 +688,22 @@ {{ item.linha.valor|moeda }} - {% if item.linha.observacao and not item.linha.oculta_no_relatorio %} + {% if item.observacoes %} {% endif %} - {% if item.linha.observacao and not item.linha.oculta_no_relatorio %} + {% if item.observacoes %} + {% for obs in item.observacoes %}
    - Observação do contador -

    {{ item.linha.observacao }}

    + Observação do contador · {{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · registrada na competência {{ obs.competencia_origem|competencia }}{% endif %} +

    {{ obs.texto }}

    + {% endfor %} {% endif %} @@ -711,12 +720,13 @@ {% if observacoes_dre %}
      - {% for linha in observacoes_dre %} + {% for obs in observacoes_dre %}
    • -
      {{ linha.descricao }}
      -
      {{ linha.observacao }}
      +
      {{ obs.alvo_rotulo }}
      +
      {{ obs.texto }}
      +
      {{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · competência {{ obs.competencia_origem|competencia }}{% endif %}
    • {% endfor %} @@ -766,20 +776,22 @@ {{ v.percentual|percentual_av }} {% endfor %} - {% if item.linha.observacao and not item.linha.oculta_no_relatorio %} + {% if item.observacoes %} {% endif %} - {% if item.linha.observacao and not item.linha.oculta_no_relatorio %} + {% if item.observacoes %} + {% for obs in item.observacoes %}
      - Observação do contador -

      {{ item.linha.observacao }}

      + Observação do contador · {{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · registrada na competência {{ obs.competencia_origem|competencia }}{% endif %} +

      {{ obs.texto }}

      + {% endfor %} {% endif %} @@ -796,12 +808,13 @@ {% if observacoes_analise_vertical %}
        - {% for linha in observacoes_analise_vertical %} + {% for obs in observacoes_analise_vertical %}
      • -
        {{ linha.descricao }}
        -
        {{ linha.observacao }}
        +
        {{ obs.alvo_rotulo }}
        +
        {{ obs.texto }}
        +
        {{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · competência {{ obs.competencia_origem|competencia }}{% endif %}
      • {% endfor %} @@ -879,30 +892,33 @@ {% if observacoes_contas or observacoes_dre or observacoes_analise_vertical or achados_com_observacao %}
          - {% for conta in observacoes_contas %} + {% for obs in observacoes_contas %}
        • -
          Balancete — {{ conta.codigo }} {{ conta.descricao }}
          -
          {{ conta.observacao }}
          +
          Balancete — {{ obs.alvo_chave }} {{ obs.alvo_rotulo }}
          +
          {{ obs.texto }}
          +
          {{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · competência {{ obs.competencia_origem|competencia }}{% endif %}
        • {% endfor %} - {% for linha in observacoes_dre %} + {% for obs in observacoes_dre %}
        • -
          D.R.E. — {{ linha.descricao }}
          -
          {{ linha.observacao }}
          +
          D.R.E. — {{ obs.alvo_rotulo }}
          +
          {{ obs.texto }}
          +
          {{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · competência {{ obs.competencia_origem|competencia }}{% endif %}
        • {% endfor %} - {% for linha in observacoes_analise_vertical %} + {% for obs in observacoes_analise_vertical %}
        • -
          Análise Vertical — {{ linha.descricao }}
          -
          {{ linha.observacao }}
          +
          Análise Vertical — {{ obs.alvo_rotulo }}
          +
          {{ obs.texto }}
          +
          {{ obs.criado_por.nome|default:"Equipe contábil" }} · {{ obs.criado_em|date:"d/m/Y" }}{% if obs.competencia_origem != apuracao.competencia %} · competência {{ obs.competencia_origem|competencia }}{% endif %}
        • {% endfor %} @@ -1116,10 +1132,11 @@ } // ---- Balancete/D.R.E.: painel de observação inline ---- - // Ícone ao lado da descrição (só aparece quando a conta/linha tem uma - // observação que o contador marcou como visível ao cliente, ver - // `oculta_no_relatorio` — as ocultas nem chegam a este HTML) — clicar - // abre a linha logo abaixo com o texto, mesmo comportamento do editor + // Ícone ao lado da descrição (só aparece quando a conta/linha tem + // observação vigente marcada como visível ao cliente, ver + // `ContabilObservacao.mostrar_ao_cliente` — as internas nem chegam a + // este HTML) — clicar abre a linha logo abaixo com o texto (pode ter + // mais de uma, cada uma assinada), mesmo comportamento do painel // inline da tela de revisão do Portal. Fica escondida por padrão // (`hidden` no HTML) e some de novo se a linha-pai for escondida por // um ancestral colapsado (`sincroniza()`, chamada depois de qualquer diff --git a/templates/dashboard-contabil.html b/templates/dashboard-contabil.html index 42d829a..f407597 100644 --- a/templates/dashboard-contabil.html +++ b/templates/dashboard-contabil.html @@ -470,6 +470,11 @@

Observações do Balancete 0

+
+ + + +
@@ -496,6 +501,11 @@

Observações da DRE 0

+
+ + + +
@@ -514,6 +524,11 @@

Observações da Análise Vertical 0

+
+ + + +
@@ -546,6 +561,11 @@

Observações

+
+ + + +