Compare commits
No commits in common. "aa417661f05d2de93db50cb7e047906a26a37824" and "1f703455125deab280568cb9e6a8cbbeaa2ea636" have entirely different histories.
aa417661f0
...
1f70345512
@ -21,26 +21,7 @@
|
||||
"Bash(./.venv/Scripts/python.exe manage.py shell -c ' *)",
|
||||
"Edit(/.claude/skills/importacao-plano-saude/**)",
|
||||
"Bash(mkdir -p \"/c/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/81285690-7341-4d2b-b15c-58956b5faf21/scratchpad/manual-subportal\")",
|
||||
"Read(//c/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/81285690-7341-4d2b-b15c-58956b5faf21/scratchpad/**)",
|
||||
"Bash(\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" -c \"import pdfplumber; print\\(pdfplumber.__version__\\)\")",
|
||||
"Bash(\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\3716cfa9-a698-43f6-a74f-de0707c027aa\\\\scratchpad\\\\inspect_pdf.py\")",
|
||||
"Bash('C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe' -c ' *)",
|
||||
"Bash(\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\3716cfa9-a698-43f6-a74f-de0707c027aa\\\\scratchpad\\\\test_parser.py\")",
|
||||
"Bash(\"C:\\\\Users\\\\Depaula\\\\Documents\\\\Portal\\\\.venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\3716cfa9-a698-43f6-a74f-de0707c027aa\\\\scratchpad\\\\test_pipeline.py\")",
|
||||
"Bash(\".venv\\\\Scripts\\\\python.exe\" manage.py makemigrations portal_api)",
|
||||
"Bash(\".venv\\\\Scripts\\\\python.exe\" manage.py migrate portal_api)",
|
||||
"Bash(\".venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\3716cfa9-a698-43f6-a74f-de0707c027aa\\\\scratchpad\\\\test_e2e.py\")",
|
||||
"Bash(\".venv\\\\Scripts\\\\python.exe\" \"C:\\\\Users\\\\Depaula\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-Depaula-Documents-Portal\\\\3716cfa9-a698-43f6-a74f-de0707c027aa\\\\scratchpad\\\\test_templates.py\")",
|
||||
"Bash(\".venv\\\\Scripts\\\\python.exe\" manage.py check)",
|
||||
"Bash(.venv/Scripts/python.exe -c \"import playwright\")",
|
||||
"Bash(pip show *)",
|
||||
"Bash(where.exe npx *)",
|
||||
"Read(//c/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/3716cfa9-a698-43f6-a74f-de0707c027aa/scratchpad/**)",
|
||||
"Bash(cat)",
|
||||
"Bash(\"/c/Program Files/Google/Chrome/Application/chrome.exe\" --headless --disable-gpu --screenshot=\"/c/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/3716cfa9-a698-43f6-a74f-de0707c027aa/scratchpad/dc-header5.png\" --window-size=1680,300 \"file:///C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/3716cfa9-a698-43f6-a74f-de0707c027aa/scratchpad/test-dc-header5.html\")",
|
||||
"Bash(\"/c/Program Files/Google/Chrome/Application/chrome.exe\" --headless --disable-gpu --screenshot=\"/c/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/3716cfa9-a698-43f6-a74f-de0707c027aa/scratchpad/dc-header6.png\" --window-size=1680,300 \"file:///C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/3716cfa9-a698-43f6-a74f-de0707c027aa/scratchpad/test-dc-header6.html\")",
|
||||
"Bash(sed 's/justify-content: space-between;/justify-content: space-between;\\\\n flex-wrap: wrap;/' test-dc-header2.html)",
|
||||
"Bash(\"/c/Program Files/Google/Chrome/Application/chrome.exe\" --headless --disable-gpu --screenshot=\"/c/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/3716cfa9-a698-43f6-a74f-de0707c027aa/scratchpad/dc-header7.png\" --window-size=1200,300 \"file:///C:/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/3716cfa9-a698-43f6-a74f-de0707c027aa/scratchpad/test-dc-header7.html\")"
|
||||
"Read(//c/Users/Depaula/AppData/Local/Temp/claude/c--Users-Depaula-Documents-Portal/81285690-7341-4d2b-b15c-58956b5faf21/scratchpad/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
11
CLAUDE.md
11
CLAUDE.md
@ -24,7 +24,6 @@ Aplicações com pacote Python próprio (`CLAUDE.md` **carregado automaticamente
|
||||
| Indicador de Desempenho | `portal_api/indicadores/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
|
||||
| Simulação de Custo de Contratação | `portal_api/custo_contratacao/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
|
||||
| Não Conformidades | `portal_api/nao_conformidades/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
|
||||
| Dashboard Contábil | `portal_api/dashboard_contabil/` — `CLAUDE.md` (técnico), `README.md`, `CHANGELOG.md` |
|
||||
|
||||
Aplicações sem pacote Python dedicado (código ainda em `portal_api/models.py`/`views.py`/`serializers.py` — os arquivos em `docs/<app>/` **não** são carregados automaticamente, ler manualmente):
|
||||
|
||||
@ -216,7 +215,6 @@ Um único app, `portal_api/`:
|
||||
| `custo-contratacao.html` | Ferramenta de Geradoc: formulário de simulação de custo de contratação (Empregado CLT) + painel colapsável de parâmetros fiscais, gera um PDF (ver `portal_api/custo_contratacao/CLAUDE.md`). |
|
||||
| `indicador-desempenho.html` | Ferramenta de Geradoc: histórico de apurações + nova apuração (upload das 2 planilhas) + cadastro de critérios/percentuais + revisão/geração dos recibos em PDF do Indicador de Desempenho do Fiscontábil (ver `portal_api/indicadores/CLAUDE.md`). |
|
||||
| `nao-conformidades.html` | Aplicação de Relatórios > Qualidade: gestão contínua das ocorrências/ações do Sigsistem, com dashboard e status interno de tratativa da Qualidade (ver `portal_api/nao_conformidades/CLAUDE.md`). |
|
||||
| `dashboard-contabil.html` | Aplicação de Relatórios > Contabilidade: histórico de análises + nova análise (upload do PDF de Balancete + DRE) + revisão de achados de auditoria (com observações por conta) — v1 sem a etapa de geração do dashboard final (ver `portal_api/dashboard_contabil/CLAUDE.md`). |
|
||||
|
||||
O item "Calendário De Paula" no menu **não é uma página local** — é um `<a href="#" id="calendario-depaula-btn">` (continua favoritável, já que ainda é um `<a class="nav-item">` — ver `docs/favoritos/favoritos.md`) cujo clique é interceptado em `sidebar.js` (`PID_CALENDARIO_DEPAULA_URL`) pra abrir `https://depaula-tvcorporativa.lovable.app/calendario` num modal com `<iframe>` (`#calendario-depaula-modal`, presente em todo shell) em vez de navegar — mesmo padrão do modal "Novo Chamado" de Ramais (`.ram-chamado-*` em `ramais.css`/`ramais.js`), só que genérico o bastante (`.iframe-modal-*` em `components.css`) pra existir em todo shell, não só em `ramais.html`. Funciona porque esse host (mesmo domínio do "Novo Chamado") não bloqueia ser embutido via `X-Frame-Options`/CSP, ao contrário do Asana (ver `docs/solicitacoes/solicitacoes.md`) — só foi possível confirmar isso testando de fato, não é garantia geral por domínio. Não recriar um `calendario.html` interno sem confirmar com o usuário; o antigo foi removido de propósito.
|
||||
|
||||
@ -375,19 +373,13 @@ Gestão contínua das ocorrências e ações do Sistema de Gestão da Qualidade
|
||||
|
||||
Ver `portal_api/nao_conformidades/CLAUDE.md`.
|
||||
|
||||
## Dashboard Contábil (Relatórios > Contabilidade)
|
||||
|
||||
Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (`ITD-FISCO-7513`): o contador anexa o PDF de Balancete + DRE (modelo Questor, mesmo relatório hoje enviado ao cliente), a ferramenta extrai as contas e roda um motor de regras de auditoria (saldos negativos, contas transitórias/genéricas com saldo, contas que deveriam ficar zeradas, débito ≠ crédito, variações atípicas mês a mês contra o histórico já processado no Portal), com revisão de achados e observações por conta antes da conclusão. **v1: só execução/auditoria/análise** — a geração do dashboard final (HTML + exportação em XLSX, substituindo o BI Contábil) fica pra uma rodada futura.
|
||||
|
||||
Ver `portal_api/dashboard_contabil/CLAUDE.md`.
|
||||
|
||||
## CSS — organização entre arquivos
|
||||
|
||||
| Arquivo | Contém |
|
||||
|---|---|
|
||||
| `tokens.css` | Variáveis (`:root`, tema claro em `:root[data-theme="light"]`). |
|
||||
| `base.css` | Reset global, incluindo `[hidden] { display: none !important; }` — necessário porque vários componentes (`.no-access`, `.app-card`, `.notif-badge`) definem seu próprio `display`, o que sem o `!important` sobrescreveria o comportamento nativo de `hidden`. Também os `@keyframes` globais de animação (`pidFadeIn`/`pidFadeSlideUp`/`pidScaleIn`, ver "Animações" abaixo) e `.pid-icon-eye`/`pidIconBlink` (piscar de olho do ícone "P.I.D.", reaproveitado pela sidebar e pelo login — ver "Ícone do login e da sidebar são clicáveis" abaixo), já que é o único CSS carregado por **todas** as páginas sem exceção (inclusive `index.html`). |
|
||||
| `layout.css` | Casca do shell: `.app-shell`, `.sidebar*`, `.nav-*`, `.fav-toggle`, `.topbar*`. A sidebar usa tokens **congelados**, independentes de tema (fundo sempre escuro em claro/escuro) — não trocar por variáveis que espelham `:root[data-theme="light"]`. Exceção deliberada: `--sidebar-text-primary`/`--sidebar-text-secondary`/`--sidebar-text-muted` (texto/ícone do menu) *são* sobrescritas em `:root[data-theme="light"]` (`tokens.css`) pra branco puro — pedido explícito do usuário pra melhorar a legibilidade; só o fundo/borda da sidebar continuam frozen. Também `.page-content` (largura do conteúdo de cada página, `max-width:1200px` centralizado por padrão) + o modificador `.page-content--wide` (`max-width:1600px`) — `portal.html` ("Principal") é a única página que usa só `.page-content` puro (grade de favoritos fica mais confortável de leitura mais estreita); as outras 11 páginas (`perfis-acesso.html`, `usuarios.html`, `links-ferramentas.html`, `acessos-gerais.html`, `ramais.html`, `calendario-individual.html`, `importacao-plano-saude.html`, `custo-contratacao.html`, `indicador-desempenho.html`, `nao-conformidades.html`, `dashboard-contabil.html`) usam `class="page-content page-content--wide"` no `<main>`, decisão explícita do usuário pra aproveitar melhor o espaço entre a sidebar e a borda da tela em telas de tabela/formulário. Uma página nova que seja mais "aplicação" (tabela, formulário, CRUD) do que "dashboard" deve nascer já com `page-content--wide`. |
|
||||
| `layout.css` | Casca do shell: `.app-shell`, `.sidebar*`, `.nav-*`, `.fav-toggle`, `.topbar*`. A sidebar usa tokens **congelados**, independentes de tema (fundo sempre escuro em claro/escuro) — não trocar por variáveis que espelham `:root[data-theme="light"]`. Exceção deliberada: `--sidebar-text-primary`/`--sidebar-text-secondary`/`--sidebar-text-muted` (texto/ícone do menu) *são* sobrescritas em `:root[data-theme="light"]` (`tokens.css`) pra branco puro — pedido explícito do usuário pra melhorar a legibilidade; só o fundo/borda da sidebar continuam frozen. Também `.page-content` (largura do conteúdo de cada página, `max-width:1200px` centralizado por padrão) + o modificador `.page-content--wide` (`max-width:1600px`) — `portal.html` ("Principal") é a única página que usa só `.page-content` puro (grade de favoritos fica mais confortável de leitura mais estreita); as outras 10 páginas (`perfis-acesso.html`, `usuarios.html`, `links-ferramentas.html`, `acessos-gerais.html`, `ramais.html`, `calendario-individual.html`, `importacao-plano-saude.html`, `custo-contratacao.html`, `indicador-desempenho.html`, `nao-conformidades.html`) usam `class="page-content page-content--wide"` no `<main>`, decisão explícita do usuário pra aproveitar melhor o espaço entre a sidebar e a borda da tela em telas de tabela/formulário. Uma página nova que seja mais "aplicação" (tabela, formulário, CRUD) do que "dashboard" deve nascer já com `page-content--wide`. |
|
||||
| `components.css` | UI genérica reutilizável: `.btn-solid/.btn-outline/.btn-ghost/.btn-danger-outline`, **`.modal-overlay`/`.modal-card`** (moldura genérica de modal, + o modificador `.modal-card--wide` pra quando precisa de mais espaço horizontal) **e também** `.modal-field`/`.modal-field-row`/`.modal-checkbox`/`.modal-error`/`.modal-actions` (campos internos do modal — moldura e campos vivem juntos aqui, apesar do nome sugerir só a moldura, incluindo `select`/`textarea` dentro de `.modal-field`, com seta customizada via `background-image` porque o nativo do browser destoa do tema escuro), `.app-card*`, `.no-access`, `.checklist-box`/`.checklist-item`/`.checklist-item__info`/`.checklist-item__nome`/`.checklist-item__departamento`/`.checklist-empty`/`.checklist-search`/`.checklist-select-all` (lista com checkbox, segunda linha de detalhe e busca — usada nos checklists de Perfis de Acesso/Departamento em `usuarios.html`), `.dual-select`/`.dual-select__*` (vinculação em duas tabelas — não vinculados/vinculados, ver `docs/perfis-usuarios/perfis-usuarios.md` — usada em `usuarios.html` e no modal "Gerenciar Usuário" de todo shell), `.info-tooltip`/`.info-tooltip__*`/`.ajuda-modal__*` (botão "?" + tooltip + modal de "Mais informações", ver seção própria abaixo — usados por `static/js/ajuda-aplicacao.js`) e `.modal-overlay--top` (empilha um modal por cima de outro já aberto — usado só pelo modal de confirmação genérico, `static/js/confirm-modal.js`, ver "Modal de confirmação genérico" abaixo). |
|
||||
| `perfis-acesso.css` | `.pa-*` (tela de Perfis de Acesso), incluindo as seções (`.ua-section*`) e campos específicos (`.ua-inline-add`/`.ua-departamento-item`/`.ua-active-toggle`/`.ua-liderados-field`) do formulário de edição de `usuarios.html`. |
|
||||
| `calendario.css` | Só `.calendar-*` (grade mensal, células de dia, nav do mês) — os campos do modal de compromisso usam as classes genéricas `.modal-field`/`.modal-checkbox`/`.modal-error`/`.modal-actions` de `components.css`. |
|
||||
@ -401,7 +393,6 @@ Ver `portal_api/dashboard_contabil/CLAUDE.md`.
|
||||
| `custo-contratacao.css` | `.cc-*` — só usado em `custo-contratacao.html`. |
|
||||
| `indicador-desempenho.css` | `.ind-*` — só usado em `indicador-desempenho.html`; carrega `perfis-acesso.css` também, pelo mesmo motivo de `importacao-plano-saude.css` (tabela/abas de revisão). |
|
||||
| `nao-conformidades.css` | `.ncf-*` — só usado em `nao-conformidades.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-tabs`/`.status-pill` (modificadores `--ok`/`--danger`/`--warning`/`--neutral` novos, em cima do `.status-pill` já existente ali). |
|
||||
| `dashboard-contabil.css` | `.dc-*` — só usado em `dashboard-contabil.html`; carrega `perfis-acesso.css` também, pra reaproveitar `.pa-table`/`.pa-tabs` na revisão de achados/Balancete/DRE. |
|
||||
|
||||
Ao adicionar uma tela nova que precise de modal, reuse `.modal-overlay`/`.modal-card` de `components.css` e só crie estilos de campo próprios se o formulário não for um caso simples de texto/select (que já tem equivalente em `.pa-field` ou `.modal-field`).
|
||||
|
||||
|
||||
@ -39,10 +39,8 @@ Não há suíte de testes, lint ou build configurados neste projeto.
|
||||
| Importação de Plano de Saúde | Utilitários | [portal_api/planos_saude/](portal_api/planos_saude/README.md) |
|
||||
| Simulação de Custo de Contratação | Geradoc | [portal_api/custo_contratacao/](portal_api/custo_contratacao/README.md) |
|
||||
| Indicador de Desempenho | Geradoc | [portal_api/indicadores/](portal_api/indicadores/README.md) |
|
||||
| Não Conformidades | Relatórios > Qualidade | [portal_api/nao_conformidades/](portal_api/nao_conformidades/README.md) |
|
||||
| Dashboard Contábil | Relatórios > Contabilidade | [portal_api/dashboard_contabil/](portal_api/dashboard_contabil/README.md) |
|
||||
|
||||
Cada pasta acima tem um `README.md` (o que a aplicação faz, estado atual) e um `CHANGELOG.md` (histórico rodada a rodada, extraído de `plano.md`). As 5 aplicações com pacote Python próprio também têm um `CLAUDE.md` com o detalhamento técnico completo, carregado automaticamente pelo Claude Code; as demais têm esse detalhe no arquivo dentro da própria pasta em `docs/`.
|
||||
Cada pasta acima tem um `README.md` (o que a aplicação faz, estado atual) e um `CHANGELOG.md` (histórico rodada a rodada, extraído de `plano.md`). As 3 aplicações com pacote Python próprio também têm um `CLAUDE.md` com o detalhamento técnico completo, carregado automaticamente pelo Claude Code; as demais têm esse detalhe no arquivo dentro da própria pasta em `docs/`.
|
||||
|
||||
## Documentação transversal
|
||||
|
||||
|
||||
@ -26,7 +26,6 @@ PAGINAS_FRONTEND = [
|
||||
"custo-contratacao.html",
|
||||
"indicador-desempenho.html",
|
||||
"nao-conformidades.html",
|
||||
"dashboard-contabil.html",
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
Binary file not shown.
Binary file not shown.
10
plano.md
10
plano.md
@ -465,13 +465,3 @@ manter o card grid original (agora com 4 cards em vez de 2, cada um com o
|
||||
### 91. Não Conformidades — upload travando com arquivo real grande (upsert virou lote)
|
||||
|
||||
Usuário reportou "Erro ao processar a solicitação." ao reprocessar os mesmos dois arquivos "com finalizadas" da rodada 90, e perguntou se seria a extensão `.xls`/`.xlsx` ou incompatibilidade com o ambiente Linux de produção. Nenhum dos dois: reproduzido localmente com os arquivos reais, o upload funcionava, só levava ~60s — tempo o bastante pra estourar o timeout de um worker WSGI em produção (gunicorn, 30s por padrão) ou disparar o autoreload do `manage.py runserver` em dev, derrubando a conexão sem nenhum problema de dado por trás. Causa raiz: o upsert (rodada 88) fazia uma ida ao banco por item (`update_or_create`/`get_or_create`), aceitável nos ~300 registros da amostra inicial mas não nos ~1700 ocorrências/~3400 ações/~9000 acompanhamentos do export real (quase 28 mil idas ao banco). Reescrito pra lote (`bulk_create`/`bulk_update`) e pra pular quem não mudou nada desde a última importação (reimportação periódica traz de volta o histórico inteiro, não só o novo) — reimportar o mesmo arquivo sem mudança real caiu de ~60s pra ~4s. Nenhuma migração, nenhuma mudança de contrato de API. Detalhe completo em `portal_api/nao_conformidades/CHANGELOG.md` (rodada 91) e `portal_api/nao_conformidades/CLAUDE.md` (seção "Upsert").
|
||||
|
||||
### 92. Dashboard Contábil (Relatórios > Contabilidade) — v1: execução, auditoria e análise
|
||||
|
||||
Pedido: otimizar a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (processo descrito em `ITD-FISCO-7513`, "Roteiro de Conferência de Balancete"). Nova aplicação em Relatórios > Contabilidade: o contador anexa o PDF de Balancete + DRE (modelo Questor, mesmo relatório hoje enviado ao cliente), a ferramenta extrai as contas/linhas automaticamente e roda um motor de 10 regras de auditoria — só o que é derivável do próprio balancete/DRE, sem depender de sistemas externos ao Portal (Questor, extratos, folha): balanceamento Ativo x Passivo, débito ≠ crédito total, saldo negativo de caixa, contas transitórias/genéricas ("DIVERSOS") com saldo, contas que deveriam ficar zeradas (ex. adiantamento de salários), sinal de saldo invertido pela natureza da conta, variação atípica de saldo/DRE mês a mês e percentual custo/receita fora do padrão da própria empresa (comparado ao histórico dela mesma, nunca a um parâmetro geral entre empresas). O contador revisa os achados por severidade/status, registra observações por conta (mesmo em contas sem achado) e conclui a análise, travando edição.
|
||||
|
||||
Decisões confirmadas com o usuário: **entrada só em PDF** — o Questor também exporta Balancete/DRE em XLSX estruturado (levantado como alternativa mais confiável de extrair, já que evita parsing de texto), mas o usuário optou por manter só PDF, como pedido originalmente; **histórico de variação mês a mês fica no próprio banco do Portal** (`ContabilApuracao`, chave natural empresa+competência), não depende do relatório "Análise Comparativa Mensal"/"Acompanhamento Mensal" (só um relatório complementar, derivado do balancete, enviado ao cliente à parte — não é upload desta ferramenta); **nasce restrita ao perfil "Inovação"** (dado financeiro de cliente é sensível o bastante pra não ter visibilidade ampla por padrão, mesmo padrão de "Não Conformidades" — diferente das ferramentas de Geradoc, que nasceram amplas); o botão "Gerar Dashboard HTML" (que substituiria o BI Contábil por um relatório final ao administrador da empresa, com exportação em XLSX) fica **desabilitado** nesta rodada — "vamos primeiro estruturar a parte de execução, auditoria e análise", escopo de uma rodada futura.
|
||||
|
||||
Desafio técnico principal, resolvido antes de escrever o parser definitivo (mesmo cuidado de sempre rodar contra o arquivo real, nunca desenhar regex de texto colado): o relatório Questor de Balancete/DRE desenha cada caractere em posição própria (sem kerning) e inclui, por baixo do texto real, uma grade densa de caracteres de espaço cobrindo toda a largura de cada linha — um artefato do gerador de relatório. Isso quebra a extração padrão do pdfplumber (`extract_words`/`extract_text`), que trata esses espaços "de fundo" como separadores de palavra reais e fragmenta números em dígitos isolados. Resolvido reconstruindo cada linha direto de `page.chars`: ignora todo caractere de espaço literal e reinsere um único espaço só quando o vão horizontal entre caracteres reais indica uma quebra de campo/palavra de verdade — calibrado e validado rodando de fato contra `792 - balancete 072026.pdf` (arquivo de referência real cedido pelo usuário). Confirmado também, empiricamente contra o mesmo arquivo, que a identidade "débito total = crédito total" só fecha somando as contas-raiz de Ativo+Passivo (não cada conta isolada), já que a DRE deste relatório não tem colunas de débito/crédito próprias — a movimentação de Resultado transita pelas contas de Patrimônio Líquido do Passivo.
|
||||
|
||||
4 models novos (`ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilAchado`, migração `0055`, testados ponta a ponta contra o banco de produção com limpeza confirmada), pacote `portal_api/dashboard_contabil/` sem ORM (`parser.py`/`regras.py`/`pipeline.py`/`modelos.py`, mesmo padrão de `indicadores`/`planos_saude`). Subgrupo "Contabilidade" novo em `catalogo.py` dentro de `relatorios` (mesmo formato de "Qualidade"). `ContabilApuracaoViewSet.create()` tem uma particularidade: diferente das outras ferramentas com histórico, `codigo_empresa`/`competencia` (a chave natural) só são conhecidos depois de extrair o PDF, não vêm do formulário — o pipeline recebe uma função de busca de histórico (não uma lista pronta), injetada pela view, que só é chamada depois da extração. Nova página `dashboard-contabil.html` (3 sub-views: histórico/upload/revisão com abas Achados-Balancete-DRE, mesmo padrão de `indicador-desempenho.html`), item de menu adicionado às 12 páginas-shell existentes (mais a nova). Detalhe técnico completo em `portal_api/dashboard_contabil/CLAUDE.md`.
|
||||
|
||||
@ -6,10 +6,6 @@ from .models import (
|
||||
AcessoGeralSecao,
|
||||
AjudaAplicacao,
|
||||
CompromissoAgenda,
|
||||
ContabilAchado,
|
||||
ContabilApuracao,
|
||||
ContabilConta,
|
||||
ContabilLinhaDre,
|
||||
Departamento,
|
||||
EmpresaQuestor,
|
||||
Favorito,
|
||||
@ -243,30 +239,3 @@ class IndicadorApuracaoEmpresaAdmin(admin.ModelAdmin):
|
||||
class IndicadorApuracaoRespostaAdmin(admin.ModelAdmin):
|
||||
list_display = ("colaborador", "criterio", "valor", "ajustado_manualmente")
|
||||
list_filter = ("valor", "ajustado_manualmente")
|
||||
|
||||
|
||||
@admin.register(ContabilApuracao)
|
||||
class ContabilApuracaoAdmin(admin.ModelAdmin):
|
||||
list_display = ("codigo_empresa", "nome_empresa", "competencia", "status", "criado_por", "criado_em")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("codigo_empresa", "nome_empresa", "cnpj")
|
||||
ordering = ("-competencia",)
|
||||
|
||||
|
||||
@admin.register(ContabilConta)
|
||||
class ContabilContaAdmin(admin.ModelAdmin):
|
||||
list_display = ("apuracao", "codigo", "descricao", "tipo", "saldo_atual")
|
||||
search_fields = ("codigo", "descricao")
|
||||
|
||||
|
||||
@admin.register(ContabilLinhaDre)
|
||||
class ContabilLinhaDreAdmin(admin.ModelAdmin):
|
||||
list_display = ("apuracao", "ordem", "descricao", "valor", "totalizador")
|
||||
search_fields = ("descricao",)
|
||||
|
||||
|
||||
@admin.register(ContabilAchado)
|
||||
class ContabilAchadoAdmin(admin.ModelAdmin):
|
||||
list_display = ("apuracao", "regra", "severidade", "status", "titulo")
|
||||
list_filter = ("regra", "severidade", "status")
|
||||
search_fields = ("titulo", "mensagem")
|
||||
|
||||
@ -55,15 +55,6 @@ MODULE_APPS = {
|
||||
"label": "Qualidade",
|
||||
"tools": [{"key": "nao-conformidades", "label": "Não Conformidades"}],
|
||||
},
|
||||
# Mesmo padrão de "qualidade" acima — subgrupo com uma única tool
|
||||
# (toggle único). Restrita por padrão: ver o override em
|
||||
# seed_portal.py (só "Integração e Inovação" nasce com acesso), já
|
||||
# que a ferramenta expõe balancete/DRE completos dos clientes.
|
||||
{
|
||||
"key": "contabilidade",
|
||||
"label": "Contabilidade",
|
||||
"tools": [{"key": "dashboard-contabil", "label": "Dashboard Contábil"}],
|
||||
},
|
||||
],
|
||||
"relatorios-gerenciais": [
|
||||
{"key": "visao-diretoria", "label": "Visão Diretoria"},
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
# Changelog — Dashboard Contábil
|
||||
|
||||
> Histórico específico desta aplicação, extraído de `plano.md` (mesma numeração de rodada usada lá, para referência cruzada).
|
||||
|
||||
### 92. Dashboard Contábil (Relatórios > Contabilidade) — v1: execução, auditoria e análise
|
||||
|
||||
Pedido: otimizar a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (`ITD-FISCO-7513`). Nova aplicação em Relatórios > Contabilidade: o contador anexa o PDF de Balancete + DRE (modelo Questor, mesmo relatório hoje enviado ao cliente), a ferramenta extrai as contas e roda um motor de 10 regras de auditoria (balanceamento Ativo x Passivo, débito ≠ crédito, saldo negativo de caixa, contas transitórias/genéricas com saldo, contas que deveriam ficar zeradas, sinal de saldo invertido, variação atípica de saldo/DRE mês a mês, percentual custo/receita fora do padrão da própria empresa), apresentando os achados numa tela de revisão com observações por conta e conclusão da análise.
|
||||
|
||||
Decisões confirmadas com o usuário: entrada só em PDF (levantada a alternativa de XLSX estruturado do Questor, mais confiável de extrair, mas mantido PDF como pedido originalmente); histórico de variação mês a mês fica no próprio banco do Portal, não depende de nenhum relatório complementar anexado; nasce restrita ao perfil "Inovação" (dado financeiro de cliente sensível, mesmo padrão de "Não Conformidades"); o botão "Gerar Dashboard HTML" (que substituiria o BI Contábil por um relatório final ao administrador da empresa, com exportação em XLSX) fica **desabilitado** nesta rodada, escopo de uma rodada futura.
|
||||
|
||||
Desafio técnico principal: o relatório Questor de Balancete/DRE desenha cada caractere em posição própria e inclui, por baixo do texto real, uma grade densa de caracteres de espaço cobrindo toda a linha — isso quebra a extração padrão do pdfplumber (`extract_words`/`extract_text`), que trata esses espaços como separadores reais e fragmenta números em dígitos isolados. Resolvido reconstruindo cada linha direto de `page.chars`, ignorando espaços literais e reinserindo um só quando o vão horizontal indica uma quebra de campo real — validado rodando de fato contra o PDF real de referência do usuário antes de escrever o parser definitivo. 4 models novos (`ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilAchado`, migração `0055`), pacote `portal_api/dashboard_contabil/` sem ORM (`parser.py`/`regras.py`/`pipeline.py`/`modelos.py`), subgrupo "Contabilidade" novo em `catalogo.py` dentro de `relatorios`. Detalhe técnico completo no `CLAUDE.md` desta pasta.
|
||||
@ -1,66 +0,0 @@
|
||||
# Dashboard Contábil (Relatórios > Contabilidade)
|
||||
|
||||
> Movido do `CLAUDE.md` da raiz — este arquivo é carregado automaticamente ao trabalhar dentro de `portal_api/dashboard_contabil/`. Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.).
|
||||
|
||||
Ferramenta que otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil (processo descrito em `ITD-FISCO-7513`, "Roteiro de Conferência de Balancete"): o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor hoje enviado ao cliente), a ferramenta extrai as contas/linhas e roda um conjunto de checagens automáticas de auditoria, apresentando os achados numa tela de revisão onde o contador analisa, registra observações e conclui a análise. Permissão de **toggle único** (`apps["dashboard-contabil"]` em `permissoes["relatorios"]`, subgrupo "Contabilidade"), checada via `PermissaoApp("relatorios", "dashboard-contabil")` em todos os `ModelViewSet` relacionados. **Nasce restrita só ao perfil "Inovação"** (código 8) — mesmo padrão de "Não Conformidades" (ver override em `seed_portal.py`), já que expõe balancete/DRE completos dos clientes.
|
||||
|
||||
**v1: só execução, auditoria e análise.** O botão "Gerar Dashboard HTML" (que geraria o relatório final em HTML para o administrador da empresa, com exportação em XLSX) existe na tela mas fica **desabilitado** — decisão explícita do usuário, "vamos primeiro estruturar a parte de execução, auditoria e análise". Sem endpoint de backend correspondente ainda.
|
||||
|
||||
## Decisões de escopo (confirmadas com o usuário)
|
||||
|
||||
- **Entrada: só PDF.** O Questor também exporta Balancete/DRE em XLSX estruturado (mais confiável de extrair, sem o risco de parsing de texto), levantado como alternativa — o usuário optou por manter só PDF, como pedido originalmente. Não há suporte a XLSX nesta ferramenta.
|
||||
- **Histórico para variação mês a mês fica no próprio Portal**, não depende do relatório "Análise Comparativa Mensal"/"Acompanhamento Mensal" (que é só um relatório complementar, derivado do balancete, enviado ao cliente separadamente — não é upload desta ferramenta). Cada apuração processada fica salva (`ContabilApuracao`, chave natural `codigo_empresa`+`competencia`), e as regras de variação comparam contra as apurações anteriores da mesma empresa já no banco.
|
||||
- **Escopo das regras**: só o que é derivável do próprio balancete/DRE anexado — nenhuma checagem do ITD que dependa de sistemas externos (Questor, extratos bancários, folha de pagamento, PID legado). A ferramenta é analítica ("Auditoria de Balancetes" em Auditorias > Fisco/Contábil, hoje só um placeholder `href="#"` no menu, referencia o antigo sistema PID legado — **não confundir com este Dashboard Contábil**, são coisas diferentes), não substitui as etapas operacionais do roteiro (zeramento de saldos etc.).
|
||||
|
||||
## Extração do PDF (`parser.py`)
|
||||
|
||||
O relatório Questor de Balancete + DRE tem uma particularidade de renderização que quebra a extração padrão do pdfplumber: **cada caractere do texto real é desenhado em posição própria** (sem kerning) e, por baixo dele, **o PDF inclui uma grade densa de caracteres de espaço cobrindo toda a largura de cada linha** — um artefato do gerador de relatório, não intencional para leitura. Isso faz `page.extract_words()`/`page.extract_text()` do pdfplumber tratarem esses espaços "de fundo" como separadores de palavra reais, quebrando números em dígitos isolados (ex.: "34.245.469,57" vira uma sequência de tokens `'3'`, `'4'`, `'.'`, `'2'`...).
|
||||
|
||||
`_reconstroi_linhas()` contorna isso trabalhando direto com `page.chars`: ignora todo caractere de texto igual a `" "` e reconstrói cada linha a partir da posição real (`x0`/`x1`) dos caracteres não-espaço, reinserindo um único espaço só quando o vão horizontal entre dois caracteres consecutivos ultrapassa `_GAP_ESPACO` (0.8pt) — calibrado contra `792 - balancete 072026.pdf` (arquivo de referência do usuário, salvo fora do repositório em `Projetos\Balancetes`): o vão dentro de uma palavra/número é ~0, entre duas palavras da mesma descrição é ~1.7-1.9pt, e entre campos da tabela (conta → flag S/A → código → descrição, ou entre colunas de valor) é sempre ≥5pt. Essa reconstrução foi validada rodando de fato contra o PDF real antes de escrever o parser definitivo (nunca desenhar regex só de texto colado — mesmo cuidado documentado na skill `importacao-plano-saude`).
|
||||
|
||||
- **Balancete**: cada linha casa com `_RE_LINHA_BALANCETE` (`^(conta)\s+(S)?\s*(código)\s+(resto)$`), e os últimos 4 tokens monetários de `resto` (via `_RE_MONETARIO`) são Saldo Anterior/Débito/Crédito/Saldo Atual, nessa ordem — o texto antes deles é a descrição. `tipo` é `"S"` (sintética) quando o flag aparece, `"A"` (analítica) quando não.
|
||||
- **DRE**: cada linha é descrição + um único valor final (sem código de classificação, diferente do Balancete). `nivel` (indentação) é derivado do `x0` do primeiro caractere da linha, em relação ao menor `x0` visto na seção (a raiz, nível 0); `totalizador` é `True` quando algum caractere da linha usa fonte em negrito (`fontname` contendo `"bold"`, case-insensitive) — confirmado contra o PDF real: linhas como "RECEITA OPERACIONAL BRUTA"/"(-) CUSTOS TOTAIS"/"(=) LUCRO BRUTO" usam `Times-Bold`, as demais `Times-Roman`.
|
||||
- **Extração para no início da seção "Demonstração Mensal (Análise Vertical)"** (páginas finais do mesmo PDF, quando presentes) — de propósito: o histórico próprio do Portal cobre a mesma necessidade de forma mais confiável (qualquer competência anterior já processada, não só as últimas 3 meses que aquele relatório mostra).
|
||||
- `LINHA_DRE_RECEITA_LIQUIDA`/`LINHA_DRE_CUSTOS_TOTAIS` (constantes em `parser.py`) guardam o texto exato dessas duas linhas totalizadoras (**com** o prefixo `"(=) "`/`"(-) "` que o Questor imprime) — usadas por `regra_percentual_custo_receita_atipico` pra achar a linha certa por texto, mais robusto a pequenas variações de nível/indentação entre empresas do que confiar só no negrito.
|
||||
- `extrai_balancete_dre(origem)` aceita tanto um caminho em disco quanto um arquivo já aberto em memória (`io.BytesIO`) — a view chama isto **antes** de salvar qualquer coisa no banco, já que `codigo_empresa`/`competencia` (a chave natural da apuração) só são conhecidos depois de ler o PDF, não informados pelo usuário no upload (diferente de `IndicadorApuracao`, que recebe a competência como campo do formulário).
|
||||
|
||||
## Regras de auditoria v1 (`regras.py`)
|
||||
|
||||
Cada `regra_*` é uma função pura: `(ResultadoExtracao da apuração atual, list[SnapshotHistorico]) -> list[AchadoDetectado]`. `historico` vem ordenado da apuração mais recente pra mais antiga (só as últimas 2 já persistidas da mesma empresa, resolvidas por `_contabil_monta_historico()` em `views.py`).
|
||||
|
||||
1. **`balanceamento_ativo_passivo`** (alta) — soma do grupo Ativo (`codigo="1"`) deve fechar com a do Passivo (`codigo="2"`, já vem negativo no relatório).
|
||||
2. **`debito_credito_divergente`** (alta) — soma de Débito das contas-raiz (`codigo` sem ponto, ou seja só "1" e "2") deve bater com a soma de Crédito. **Não é uma checagem trivial de "todo balancete sempre bate"**: como a DRE (Resultado) não tem colunas de débito/crédito próprias neste relatório (só um valor líquido por linha), a identidade só fecha porque a movimentação de Resultado também transita pelas contas de Patrimônio Líquido do Passivo (ex.: "LUCROS/PREJUÍZOS DO EXERCÍCIO") — confirmado empiricamente contra `792 - balancete 072026.pdf` (débito total = crédito total = R$ 416.271.243,32 nas contas-raiz).
|
||||
3. **`saldo_negativo_caixa`** (alta) — conta com `codigo` começando em `1.01.01.001` (grupo Caixa) e `saldo_atual < 0`.
|
||||
4. **`conta_transitoria_com_saldo`** (média) — descrição contém "TRANSIT" (cobre "TRANSITÓRIA"/"TRANSITORIA") com `saldo_atual != 0`.
|
||||
5. **`conta_deveria_zerar`** (média) — `TRECHOS_CONTA_DEVERIA_ZERAR` (lista curta e deliberadamente restrita — só "ADIANTAMENTOS DE SALÁRIOS", que o ITD confirma dever ficar zerada todo mês; **não** inclui "Adiantamento de Férias"/"13º Salário", que legitimamente carregam saldo entre meses).
|
||||
6. **`saldo_sinal_invertido`** (média) — conta analítica (`tipo="A"`) do Ativo (`1.`) com saldo credor, ou do Passivo (`2.`) com saldo devedor, exceto contas redutoras (descrição começando com `"(-)"`, que são esperadas ter o sinal oposto ao grupo).
|
||||
7. **`descricao_generica`** (baixa) — descrição exatamente `"DIVERSOS"` com saldo relevante (o ITD cita esse caso especificamente: "o contador deverá realocar estes lançamentos a conta pertinente").
|
||||
8. **`variacao_atipica_saldo`** (média) — compara `saldo_atual` de cada conta analítica contra a apuração anterior da mesma empresa (por `codigo`), sinaliza quando a variação passa de `VARIACAO_LIMIAR_PERCENTUAL` (50%) **e** `VARIACAO_VALOR_MINIMO` (R$1.000, evita ruído em contas de valor irrisório). Só roda a partir da 2ª apuração de uma empresa.
|
||||
9. **`variacao_atipica_dre`** (média) — mesma ideia, mas isolando o mês: como a DRE do relatório é **acumulada desde janeiro**, `_isola_mes_dre()` subtrai o acumulado do mês anterior (`valor_ytd_atual - valor_ytd_anterior`), exceto em janeiro (onde o acumulado já é o próprio mês). Só roda quando há **2** apurações anteriores consecutivas (`_mes_consecutivo()`) — precisa isolar tanto o mês atual quanto o anterior pra comparar mês-contra-mês de verdade, não acumulado-contra-acumulado.
|
||||
10. **`percentual_custo_receita_atipico`** (média) — compara o percentual Custos/Receita Líquida do mês isolado atual contra o do mês isolado anterior (mesma técnica da regra 9) — o ITD é explícito que não existe parâmetro geral entre empresas pra essa relação, só comparação histórica da própria empresa.
|
||||
|
||||
Regras 8-10 não geram achado nenhum na primeira apuração de uma empresa (sem histórico ainda) — comportamento esperado, não bug.
|
||||
|
||||
## Models (`portal_api/models.py`)
|
||||
|
||||
Padrão cabeçalho → linhas de detalhe → achados (mesma filosofia de `IndicadorApuracao`/`IndicadorApuracaoColaborador`):
|
||||
|
||||
- **`ContabilApuracao`**: `codigo_empresa`/`nome_empresa`/`cnpj`/`competencia` (extraídos do PDF, não informados no upload) + `periodo_inicio`/`periodo_fim` + `arquivo` + `status` (`revisao`/`concluida`). `unique_together` em `codigo_empresa`+`competencia` — reprocessar a mesma competência de uma empresa exige excluir a apuração antiga primeiro (sem "reabrir"/reprocessar nesta v1, diferente de `ImportacaoPlanoSaude`).
|
||||
- **`ContabilConta`**: uma linha do Balancete. `observacao` (`TextField`, editável via PATCH em **qualquer** conta, tenha ela gerado achado ou não) — é o espaço de "análise" pedido pelo usuário, independente da auditoria automática.
|
||||
- **`ContabilLinhaDre`**: uma linha da DRE, sem código de classificação (o relatório não traz um pra DRE, diferente do Balancete).
|
||||
- **`ContabilAchado`**: achado de auditoria, nasce automático em `create()`, nunca é apagado — só muda de `status` (`pendente`/`tratado`/`ignorado`), sempre com `observacao_contador` obrigatória ao mudar de pendente (mesmo espírito de "histórico completo preservado" de `ImportacaoPlanoSaudeAuditoria`). `conta` é nullable — achados 1 e 2 (balanceamento/débito-crédito) são gerais, sem uma conta específica.
|
||||
|
||||
## `ContabilApuracaoViewSet.create()` — ordem de operações não-trivial
|
||||
|
||||
Diferente de `IndicadorApuracaoViewSet`/`ImportacaoPlanoSaudeViewSet` (onde a chave natural do registro, ex. `competencia`, vem do formulário do usuário), aqui `codigo_empresa`/`competencia` só são conhecidos **depois** de extrair o PDF. A ordem em `create()`:
|
||||
|
||||
1. Lê o arquivo inteiro pra memória (`arquivo.read()`) — nada em disco ainda.
|
||||
2. `dashboard_contabil_pipeline.processa_apuracao(io.BytesIO(conteudo), _contabil_monta_historico)` — extrai o cabeçalho/contas/DRE e, com o `codigo_empresa`/`competencia` já em mãos, chama `_contabil_monta_historico()` (função injetada, consulta o ORM) pra buscar até 2 apurações anteriores da mesma empresa, então roda as regras. Captura `ContabilExtracaoInvalidaError` → 400 genérico.
|
||||
3. Confere se já existe uma apuração pra essa empresa+competência (`.exists()`) → 400 com mensagem específica, **antes** de qualquer escrita (evita depender só do `IntegrityError` do banco, que devolveria um 500 cru).
|
||||
4. Só agora, dentro de `transaction.atomic()`, cria `ContabilApuracao` (grava o arquivo via `ContentFile(conteudo, ...)`) + `bulk_create` de contas/linhas DRE/achados. `except Exception` fora do `with` apaga o arquivo gravado se algo falhar no meio (upload de `FileField` não é transacional).
|
||||
|
||||
`pipeline.processa_apuracao(origem, busca_historico)` recebe `busca_historico` como uma função (não uma lista já pronta) exatamente por essa dependência: a chave de busca do histórico só existe depois da extração, então não dá pra pré-buscar antes de chamar o pipeline como as outras duas ferramentas fazem.
|
||||
|
||||
## Frontend
|
||||
|
||||
`templates/dashboard-contabil.html` (`page-content--wide`) segue o padrão de 3 sub-views de `indicador-desempenho.html`: `#dc-list-view` (histórico + botão "Nova Análise") / `#dc-form-view` (upload de um único PDF — sem campo de competência, é extraído do arquivo) / `#dc-review-view` (abas Achados/Balancete/DRE, via `.pa-tabs`/`.pa-tab-panel` de `perfis-acesso.css`). Achados têm filtro por severidade e por status (pendentes/todos); tratar/ignorar um achado abre um modal próprio (`#dc-achado-modal`) que exige observação não-vazia; observação de conta abre outro modal (`#dc-observacao-modal`), sem essa exigência (pode ficar em branco). Botão "Gerar Dashboard HTML" existe (`#dc-gerar-dashboard-btn`) mas fica `disabled` com `title="Em desenvolvimento"` — sem chamada de API nenhuma atrás dele nesta v1.
|
||||
@ -1,13 +0,0 @@
|
||||
# Dashboard Contábil (Relatórios > Contabilidade)
|
||||
|
||||
> Ver `CLAUDE.md` nesta mesma pasta para o detalhamento técnico (extração do PDF, regras de auditoria, models). Ver `CLAUDE.md` na raiz para arquitetura geral/transversal do Portal.
|
||||
|
||||
Otimiza a conferência de balancetes hoje feita manualmente pelo Fisco/Contábil: o contador anexa o PDF de Balancete + DRE de uma empresa (mesmo relatório modelo Questor hoje enviado ao cliente), a ferramenta extrai as contas automaticamente e roda um conjunto de checagens de auditoria (saldos negativos de caixa, contas transitórias/genéricas com saldo, contas que deveriam estar zeradas, diferença débito/crédito, variações atípicas mês a mês) — o contador revisa os achados, registra observações por conta e conclui a análise.
|
||||
|
||||
**Escopo atual: só a etapa de execução/auditoria/análise.** A geração do dashboard HTML final (para o administrador da empresa, com exportação em XLSX) fica para uma rodada futura — o botão já existe na tela, mas desabilitado.
|
||||
|
||||
## Onde mexer
|
||||
|
||||
- `portal_api/dashboard_contabil/` — `parser.py` (extração do PDF via `pdfplumber`), `regras.py` (motor de regras de auditoria), `pipeline.py` (orquestração), `modelos.py` (dataclasses).
|
||||
- `ContabilApuracao`/`ContabilConta`/`ContabilLinhaDre`/`ContabilAchado` (`portal_api/models.py`).
|
||||
- `dashboard-contabil.html` / `static/js/dashboard-contabil.js` / `static/css/dashboard-contabil.css`.
|
||||
@ -1,76 +0,0 @@
|
||||
"""Dataclasses puras (sem ORM) do Dashboard Contábil: o formato intermediário
|
||||
entre o PDF extraído (`parser.py`) e as regras de auditoria (`regras.py`).
|
||||
Mesmo padrão de `portal_api.planos_saude.modelos`/`portal_api.indicadores.pipeline`
|
||||
— quem persiste em model é a view, este módulo só representa dados em memória."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
@dataclass
|
||||
class CabecalhoExtraido:
|
||||
codigo_empresa: str
|
||||
nome_empresa: str
|
||||
cnpj: str
|
||||
periodo_inicio: date
|
||||
periodo_fim: date
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinhaBalanceteExtraida:
|
||||
conta_numero: int
|
||||
codigo: str
|
||||
descricao: str
|
||||
tipo: str # "S" (sintética) ou "A" (analítica)
|
||||
saldo_anterior: Decimal
|
||||
debito: Decimal
|
||||
credito: Decimal
|
||||
saldo_atual: Decimal
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinhaDreExtraida:
|
||||
ordem: int
|
||||
descricao: str
|
||||
nivel: int
|
||||
valor: Decimal
|
||||
totalizador: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResultadoExtracao:
|
||||
cabecalho: CabecalhoExtraido
|
||||
contas: list[LinhaBalanceteExtraida]
|
||||
linhas_dre: list[LinhaDreExtraida]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AchadoDetectado:
|
||||
regra: str
|
||||
severidade: str # "alta" | "media" | "baixa"
|
||||
titulo: str
|
||||
mensagem: str
|
||||
codigo_conta: str | None = None
|
||||
valor_referencia: Decimal | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapshotHistorico:
|
||||
"""Retrato mínimo de uma apuração anterior já concluída/em revisão da
|
||||
mesma empresa — usado pelas regras de variação mês a mês (ver
|
||||
`regras.py`). `contas`/`linhas_dre` são dicionários já resolvidos
|
||||
(codigo/descrição → valor) pra não precisar reconsultar o ORM dentro das
|
||||
regras, que são funções puras."""
|
||||
|
||||
competencia: date
|
||||
contas: dict[str, Decimal] = field(default_factory=dict)
|
||||
linhas_dre: dict[str, Decimal] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResultadoProcessamento:
|
||||
extracao: ResultadoExtracao
|
||||
achados: list[AchadoDetectado]
|
||||
@ -1,247 +0,0 @@
|
||||
"""Extração do PDF de Balancete + DRE (leiaute Questor, único leiaute-fonte —
|
||||
ao contrário de `portal_api.planos_saude`, que precisa de um parser por
|
||||
operadora, aqui o relatório é sempre gerado pelo mesmo sistema contábil).
|
||||
|
||||
O texto deste relatório é desenhado caractere a caractere (cada letra/número
|
||||
tem sua própria posição X, sem depender de kerning do PDF) e o próprio PDF
|
||||
inclui, por baixo do texto real, uma grade densa de caracteres de espaço
|
||||
cobrindo a largura inteira de cada linha — isso confunde tanto
|
||||
`page.extract_words()` quanto `page.extract_text()` do pdfplumber, que
|
||||
tratam esses espaços "de fundo" como separadores de palavra reais e acabam
|
||||
quebrando números em dígitos isolados. `_reconstroi_linhas()` contorna isso
|
||||
ignorando todo caractere de espaço literal do PDF e reconstruindo a linha
|
||||
a partir da posição real dos caracteres não-espaço, reinserindo um único
|
||||
espaço só quando o vão horizontal entre dois caracteres consecutivos indica
|
||||
uma quebra de campo/palavra de verdade (validado contra
|
||||
`792 - balancete 072026.pdf`, ver `plano.md`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import IO
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from .modelos import CabecalhoExtraido, LinhaBalanceteExtraida, LinhaDreExtraida, ResultadoExtracao
|
||||
|
||||
# Vão horizontal (em pontos) acima do qual dois caracteres consecutivos são
|
||||
# tratados como pertencendo a campos/palavras diferentes. Calibrado contra o
|
||||
# relatório real: o vão dentro de uma palavra/número é ~0, entre duas
|
||||
# palavras da mesma descrição é ~1.7-1.9, e entre campos da tabela (conta →
|
||||
# flag S/A → código → descrição, ou entre colunas de valor) é sempre >= 5.
|
||||
_GAP_ESPACO = 0.8
|
||||
|
||||
_TITULO_DRE = "DEMONSTRAÇÃO DO RESULTADO DO EXERCÍCIO"
|
||||
_TITULO_ANALISE_VERTICAL = "Demonstração Mensal (Análise Vertical)"
|
||||
|
||||
_RE_MONETARIO = re.compile(r"\(?-?[\d.]+,\d{2}\)?")
|
||||
_RE_LINHA_BALANCETE = re.compile(r"^(?P<conta>\d+)\s+(?P<tipo>S)?\s*(?P<codigo>[\d.]+)\s+(?P<resto>.+)$")
|
||||
_RE_CNPJ = re.compile(r"CNPJ:\s*([\d./-]+)")
|
||||
_RE_PERIODO = re.compile(r"Per[ií]odo:\s*(\d{2}/\d{2}/\d{4})\s*a\s*(\d{2}/\d{2}/\d{4})")
|
||||
_RE_CODIGO_NOME_EMPRESA = re.compile(r"^(?P<codigo>\d+)\s+(?P<nome>.+)$")
|
||||
|
||||
# Linhas totalizadoras conhecidas da DRE (usadas por regras que precisam de
|
||||
# uma linha específica, ex. razão custo/receita) — além do negrito (que já
|
||||
# identifica visualmente todo totalizador pro frontend), casar por texto é
|
||||
# mais robusto a pequenas variações de nível/indentação entre empresas. O
|
||||
# texto inclui o prefixo "(=)"/"(-)" tal como o Questor imprime (ver
|
||||
# `792 - balancete 072026.pdf`).
|
||||
LINHA_DRE_RECEITA_LIQUIDA = "(=) RECEITA OPERACIONAL LIQUIDA"
|
||||
LINHA_DRE_CUSTOS_TOTAIS = "(-) CUSTOS TOTAIS"
|
||||
|
||||
|
||||
class ExtracaoInvalidaError(Exception):
|
||||
"""PDF não tem o formato esperado (cabeçalho/tabela do Balancete/DRE não
|
||||
encontrados) — a view converte isso numa resposta 400 genérica, mesmo
|
||||
padrão de `planos_saude`/`indicadores`."""
|
||||
|
||||
|
||||
def _para_decimal(texto: str) -> Decimal:
|
||||
texto = texto.strip()
|
||||
# Algumas empresas têm linhas de DRE negativas no PDF com só o parêntese
|
||||
# de abertura ("(1.114.989,29", sem o "()" de fechamento) — confirmado
|
||||
# direto nos caracteres brutos do PDF (não é um artefato de
|
||||
# _reconstroi_linhas), então o "(" sozinho já basta pra marcar negativo.
|
||||
negativo = texto.startswith("(")
|
||||
if negativo:
|
||||
texto = texto[1:]
|
||||
if texto.endswith(")"):
|
||||
texto = texto[:-1]
|
||||
texto = texto.replace(".", "").replace(",", ".")
|
||||
try:
|
||||
valor = Decimal(texto)
|
||||
except InvalidOperation as exc:
|
||||
raise ExtracaoInvalidaError(f"Valor monetário inválido: {texto!r}") from exc
|
||||
return -valor if negativo else valor
|
||||
|
||||
|
||||
def _para_data(texto: str) -> date:
|
||||
dia, mes, ano = texto.split("/")
|
||||
return date(int(ano), int(mes), int(dia))
|
||||
|
||||
|
||||
def _reconstroi_linhas(pagina) -> list[dict]:
|
||||
caracteres = [c for c in pagina.chars if c["text"] != " "]
|
||||
linhas: dict[float, list] = {}
|
||||
for c in caracteres:
|
||||
chave = round(c["top"], 0)
|
||||
linhas.setdefault(chave, []).append(c)
|
||||
|
||||
resultado = []
|
||||
for topo in sorted(linhas.keys()):
|
||||
fileira = sorted(linhas[topo], key=lambda c: c["x0"])
|
||||
texto = ""
|
||||
anterior_x1 = None
|
||||
negrito = False
|
||||
for c in fileira:
|
||||
if anterior_x1 is not None and (c["x0"] - anterior_x1) > _GAP_ESPACO:
|
||||
texto += " "
|
||||
texto += c["text"]
|
||||
anterior_x1 = c["x1"]
|
||||
if "bold" in c["fontname"].lower():
|
||||
negrito = True
|
||||
resultado.append({"top": topo, "x0": fileira[0]["x0"], "texto": texto, "negrito": negrito})
|
||||
return resultado
|
||||
|
||||
|
||||
_RE_CARIMBO_DATA_PAGINA = re.compile(r"^\d{2}/\d{2}/\d{4}\s+\d{2}:\d{2}")
|
||||
|
||||
|
||||
def _extrai_cabecalho(primeiras_linhas: list[str]) -> CabecalhoExtraido:
|
||||
texto_completo = " ".join(primeiras_linhas)
|
||||
|
||||
match_cnpj = _RE_CNPJ.search(texto_completo)
|
||||
match_periodo = _RE_PERIODO.search(texto_completo)
|
||||
if not match_cnpj or not match_periodo:
|
||||
raise ExtracaoInvalidaError("Não foi possível localizar CNPJ/período no cabeçalho do PDF.")
|
||||
|
||||
# As linhas reconstruídas vêm ordenadas por posição vertical (`top`), não
|
||||
# por ordem de leitura — nesta página, o carimbo "data/hora Pág:NNNN" (que
|
||||
# fica alinhado à direita) cai *entre* as duas linhas do nome da empresa
|
||||
# (que pode quebrar em duas linhas quando é longo). Só as linhas antes de
|
||||
# "CNPJ:" que não são esse carimbo nem "Período:" compõem o nome.
|
||||
linhas_nome = []
|
||||
for linha in primeiras_linhas:
|
||||
if linha.startswith("CNPJ:"):
|
||||
break
|
||||
if _RE_CARIMBO_DATA_PAGINA.match(linha) or linha.startswith("Período:"):
|
||||
continue
|
||||
linhas_nome.append(linha)
|
||||
match_nome = _RE_CODIGO_NOME_EMPRESA.match(" ".join(linhas_nome).strip())
|
||||
if not match_nome:
|
||||
raise ExtracaoInvalidaError("Não foi possível localizar código/nome da empresa no cabeçalho do PDF.")
|
||||
|
||||
return CabecalhoExtraido(
|
||||
codigo_empresa=match_nome.group("codigo"),
|
||||
nome_empresa=match_nome.group("nome").strip(),
|
||||
cnpj=match_cnpj.group(1),
|
||||
periodo_inicio=_para_data(match_periodo.group(1)),
|
||||
periodo_fim=_para_data(match_periodo.group(2)),
|
||||
)
|
||||
|
||||
|
||||
def extrai_balancete_dre(origem: str | IO[bytes]) -> ResultadoExtracao:
|
||||
"""Lê o PDF combinado de Balancete + DRE (modelo Questor) e devolve tudo
|
||||
já estruturado. `origem` aceita tanto um caminho em disco quanto um
|
||||
arquivo já aberto em memória (`io.BytesIO`) — a view chama isto direto
|
||||
sobre o upload, antes de saber `codigo_empresa`/`competencia` (extraídos
|
||||
daqui), então ainda não há um caminho em disco definitivo nesse momento.
|
||||
As páginas finais de "Demonstração Mensal (Análise Vertical)", quando
|
||||
presentes, são ignoradas de propósito — o Dashboard Contábil calcula
|
||||
variação mês a mês a partir do próprio histórico de apurações já
|
||||
processadas no Portal, não a partir desse relatório complementar (ver
|
||||
CLAUDE.md/plano.md)."""
|
||||
|
||||
cabecalho: CabecalhoExtraido | None = None
|
||||
contas: list[LinhaBalanceteExtraida] = []
|
||||
linhas_dre: list[LinhaDreExtraida] = []
|
||||
|
||||
secao = "cabecalho"
|
||||
ordem_dre = 0
|
||||
base_x0_dre: float | None = None
|
||||
|
||||
with pdfplumber.open(origem) as pdf:
|
||||
if not pdf.pages:
|
||||
raise ExtracaoInvalidaError("O PDF não tem páginas.")
|
||||
|
||||
primeiras_linhas_pagina1 = [
|
||||
linha["texto"] for linha in _reconstroi_linhas(pdf.pages[0]) if linha["texto"].strip()
|
||||
][:8]
|
||||
cabecalho = _extrai_cabecalho(primeiras_linhas_pagina1)
|
||||
|
||||
for pagina in pdf.pages:
|
||||
for linha in _reconstroi_linhas(pagina):
|
||||
texto = linha["texto"].strip()
|
||||
if not texto:
|
||||
continue
|
||||
|
||||
if texto.startswith(_TITULO_ANALISE_VERTICAL):
|
||||
secao = "ignorar"
|
||||
break
|
||||
if secao != "dre" and texto.startswith(_TITULO_DRE):
|
||||
secao = "dre"
|
||||
base_x0_dre = None
|
||||
continue
|
||||
|
||||
if secao in ("cabecalho", "balancete"):
|
||||
match = _RE_LINHA_BALANCETE.match(texto)
|
||||
if not match:
|
||||
continue
|
||||
secao = "balancete"
|
||||
valores = _RE_MONETARIO.findall(match.group("resto"))
|
||||
if len(valores) < 4:
|
||||
continue
|
||||
saldo_anterior, debito, credito, saldo_atual = valores[-4:]
|
||||
# Descrição = tudo antes do início do bloco de 4 valores
|
||||
# (sempre exatamente 4 colunas por linha de balancete).
|
||||
posicao_valores = match.group("resto").find(saldo_anterior)
|
||||
if posicao_valores == -1:
|
||||
continue
|
||||
descricao = match.group("resto")[:posicao_valores].strip()
|
||||
contas.append(
|
||||
LinhaBalanceteExtraida(
|
||||
conta_numero=int(match.group("conta")),
|
||||
codigo=match.group("codigo"),
|
||||
descricao=descricao,
|
||||
tipo=match.group("tipo") or "A",
|
||||
saldo_anterior=_para_decimal(saldo_anterior),
|
||||
debito=_para_decimal(debito),
|
||||
credito=_para_decimal(credito),
|
||||
saldo_atual=_para_decimal(saldo_atual),
|
||||
)
|
||||
)
|
||||
elif secao == "dre":
|
||||
if texto.startswith("Valores expressos") or texto.startswith(_TITULO_DRE):
|
||||
continue
|
||||
valores = _RE_MONETARIO.findall(texto)
|
||||
if not valores:
|
||||
continue
|
||||
valor_texto = valores[-1]
|
||||
posicao_valor = texto.rfind(valor_texto)
|
||||
descricao = texto[:posicao_valor].strip()
|
||||
if not descricao:
|
||||
continue
|
||||
if base_x0_dre is None:
|
||||
base_x0_dre = linha["x0"]
|
||||
nivel = max(0, round((linha["x0"] - base_x0_dre) / 7.0))
|
||||
ordem_dre += 1
|
||||
linhas_dre.append(
|
||||
LinhaDreExtraida(
|
||||
ordem=ordem_dre,
|
||||
descricao=descricao,
|
||||
nivel=nivel,
|
||||
valor=_para_decimal(valor_texto),
|
||||
totalizador=linha["negrito"],
|
||||
)
|
||||
)
|
||||
if secao == "ignorar":
|
||||
break
|
||||
|
||||
if not contas:
|
||||
raise ExtracaoInvalidaError("Nenhuma linha de balancete encontrada no PDF.")
|
||||
if not linhas_dre:
|
||||
raise ExtracaoInvalidaError("Nenhuma linha de DRE encontrada no PDF.")
|
||||
|
||||
return ResultadoExtracao(cabecalho=cabecalho, contas=contas, linhas_dre=linhas_dre)
|
||||
@ -1,27 +0,0 @@
|
||||
"""Orquestração do Dashboard Contábil: extrai o PDF e roda o motor de regras
|
||||
sobre o resultado — só em memória (dataclasses), sem tocar no ORM diretamente.
|
||||
Mesmo padrão de `portal_api.indicadores.pipeline`/`portal_api.planos_saude.
|
||||
pipeline`; quem persiste é a view.
|
||||
|
||||
Diferente dos outros dois pacotes, aqui a chave de busca do histórico
|
||||
(`codigo_empresa`) só é conhecida **depois** de extrair o cabeçalho do PDF —
|
||||
por isso `processa_apuracao` recebe `busca_historico` como uma função
|
||||
(injetada pela view, que sabe consultar o ORM) em vez de já receber o
|
||||
histórico pronto."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import IO, Callable
|
||||
|
||||
from .modelos import CabecalhoExtraido, ResultadoProcessamento, SnapshotHistorico
|
||||
from .parser import extrai_balancete_dre
|
||||
from .regras import gera_achados
|
||||
|
||||
|
||||
def processa_apuracao(
|
||||
origem: str | IO[bytes], busca_historico: Callable[[CabecalhoExtraido], list[SnapshotHistorico]]
|
||||
) -> ResultadoProcessamento:
|
||||
extracao = extrai_balancete_dre(origem)
|
||||
historico = busca_historico(extracao.cabecalho)
|
||||
achados = gera_achados(extracao, historico)
|
||||
return ResultadoProcessamento(extracao=extracao, achados=achados)
|
||||
@ -1,339 +0,0 @@
|
||||
"""Motor de regras de auditoria do Dashboard Contábil — cada `regra_*` é uma
|
||||
função pura (`ResultadoExtracao` da apuração atual + histórico já persistido
|
||||
da mesma empresa → `list[AchadoDetectado]`), sem tocar no ORM. Cobrem só o
|
||||
que é derivável do próprio balancete/DRE anexado (ver CLAUDE.md do pacote) —
|
||||
qualquer checagem que dependa de sistemas externos (Questor, extratos,
|
||||
folha) fica fora de propósito, por decisão do usuário (ferramenta analítica,
|
||||
não operacional)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from .modelos import AchadoDetectado, LinhaBalanceteExtraida, ResultadoExtracao, SnapshotHistorico
|
||||
from .parser import LINHA_DRE_CUSTOS_TOTAIS, LINHA_DRE_RECEITA_LIQUIDA
|
||||
|
||||
SEVERIDADE_ALTA = "alta"
|
||||
SEVERIDADE_MEDIA = "media"
|
||||
SEVERIDADE_BAIXA = "baixa"
|
||||
|
||||
TOLERANCIA_CENTAVOS = Decimal("0.05")
|
||||
|
||||
# Trechos de descrição que, segundo o roteiro de conferência (ITD-FISCO-7513),
|
||||
# devem ficar zeradas todo mês — ex.: "o saldo de adiantamento de salários e
|
||||
# empréstimos a funcionários deve estar zerado todos os meses". Deliberadamente
|
||||
# restrito a contas cujo saldo residual é sempre um sinal de pendência (não
|
||||
# inclui, por exemplo, "ADIANTAMENTOS DE FÉRIAS"/"13º SALÁRIO", que legitimamente
|
||||
# carregam saldo entre um mês e o pagamento seguinte).
|
||||
TRECHOS_CONTA_DEVERIA_ZERAR = ["ADIANTAMENTOS DE SALÁRIOS", "ADIANTAMENTO DE SALÁRIOS"]
|
||||
|
||||
VARIACAO_LIMIAR_PERCENTUAL = Decimal("0.5") # 50%
|
||||
VARIACAO_VALOR_MINIMO = Decimal("1000") # ignora variações abaixo disso, mesmo que %-mente grandes
|
||||
|
||||
|
||||
def _mes_consecutivo(competencia_atual: date, competencia_anterior: date) -> bool:
|
||||
return (competencia_atual.year * 12 + competencia_atual.month) - (
|
||||
competencia_anterior.year * 12 + competencia_anterior.month
|
||||
) == 1
|
||||
|
||||
|
||||
def _isola_mes_dre(valor_ytd_atual: Decimal, valor_ytd_anterior: Decimal | None, competencia_atual: date) -> Decimal:
|
||||
"""A DRE do relatório é acumulada desde janeiro — isola o mês corrente
|
||||
subtraindo o acumulado do mês anterior, exceto em janeiro (onde o
|
||||
acumulado já é o próprio mês)."""
|
||||
if competencia_atual.month == 1 or valor_ytd_anterior is None:
|
||||
return valor_ytd_atual
|
||||
return valor_ytd_atual - valor_ytd_anterior
|
||||
|
||||
|
||||
def regra_balanceamento_ativo_passivo(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
|
||||
ativo = next((c for c in atual.contas if c.codigo == "1"), None)
|
||||
passivo = next((c for c in atual.contas if c.codigo == "2"), None)
|
||||
if ativo is None or passivo is None:
|
||||
return []
|
||||
diferenca = ativo.saldo_atual + passivo.saldo_atual # passivo já vem negativo no relatório
|
||||
if abs(diferenca) <= TOLERANCIA_CENTAVOS:
|
||||
return []
|
||||
return [
|
||||
AchadoDetectado(
|
||||
regra="balanceamento_ativo_passivo",
|
||||
severidade=SEVERIDADE_ALTA,
|
||||
titulo="Ativo não bate com Passivo",
|
||||
mensagem=(
|
||||
f"Saldo do Ativo (R$ {ativo.saldo_atual}) não coincide com o do Passivo "
|
||||
f"(R$ {-passivo.saldo_atual}) — diferença de R$ {abs(diferenca)}."
|
||||
),
|
||||
valor_referencia=diferenca,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def regra_debito_credito_divergente(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
|
||||
raizes = [c for c in atual.contas if "." not in c.codigo]
|
||||
soma_debito = sum((c.debito for c in raizes), Decimal(0))
|
||||
soma_credito = sum((c.credito for c in raizes), Decimal(0))
|
||||
diferenca = soma_debito - soma_credito
|
||||
if abs(diferenca) <= TOLERANCIA_CENTAVOS:
|
||||
return []
|
||||
return [
|
||||
AchadoDetectado(
|
||||
regra="debito_credito_divergente",
|
||||
severidade=SEVERIDADE_ALTA,
|
||||
titulo="Débito total diferente do crédito total",
|
||||
mensagem=(
|
||||
f"Soma de débitos do período (R$ {soma_debito}) não bate com a soma de "
|
||||
f"créditos (R$ {soma_credito}) — diferença de R$ {abs(diferenca)}."
|
||||
),
|
||||
valor_referencia=diferenca,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def regra_saldo_negativo_caixa(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
|
||||
achados = []
|
||||
for conta in atual.contas:
|
||||
if conta.codigo.startswith("1.01.01.001") and conta.saldo_atual < 0:
|
||||
achados.append(
|
||||
AchadoDetectado(
|
||||
regra="saldo_negativo_caixa",
|
||||
severidade=SEVERIDADE_ALTA,
|
||||
titulo="Saldo de caixa negativo",
|
||||
mensagem=f'Conta "{conta.descricao}" ({conta.codigo}) com saldo negativo de R$ {conta.saldo_atual}.',
|
||||
codigo_conta=conta.codigo,
|
||||
valor_referencia=conta.saldo_atual,
|
||||
)
|
||||
)
|
||||
return achados
|
||||
|
||||
|
||||
def regra_conta_transitoria_com_saldo(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
|
||||
achados = []
|
||||
for conta in atual.contas:
|
||||
descricao_normalizada = conta.descricao.upper()
|
||||
if ("TRANSIT" in descricao_normalizada) and conta.saldo_atual != 0:
|
||||
achados.append(
|
||||
AchadoDetectado(
|
||||
regra="conta_transitoria_com_saldo",
|
||||
severidade=SEVERIDADE_MEDIA,
|
||||
titulo="Conta transitória com saldo",
|
||||
mensagem=f'Conta transitória "{conta.descricao}" ({conta.codigo}) deveria estar zerada e está com R$ {conta.saldo_atual}.',
|
||||
codigo_conta=conta.codigo,
|
||||
valor_referencia=conta.saldo_atual,
|
||||
)
|
||||
)
|
||||
return achados
|
||||
|
||||
|
||||
def regra_conta_deveria_zerar(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
|
||||
achados = []
|
||||
for conta in atual.contas:
|
||||
descricao_normalizada = conta.descricao.upper()
|
||||
if conta.saldo_atual == 0:
|
||||
continue
|
||||
if any(trecho in descricao_normalizada for trecho in TRECHOS_CONTA_DEVERIA_ZERAR):
|
||||
achados.append(
|
||||
AchadoDetectado(
|
||||
regra="conta_deveria_zerar",
|
||||
severidade=SEVERIDADE_MEDIA,
|
||||
titulo="Conta que deveria estar zerada",
|
||||
mensagem=f'Conta "{conta.descricao}" ({conta.codigo}) normalmente fica zerada todo mês e está com saldo de R$ {conta.saldo_atual}.',
|
||||
codigo_conta=conta.codigo,
|
||||
valor_referencia=conta.saldo_atual,
|
||||
)
|
||||
)
|
||||
return achados
|
||||
|
||||
|
||||
def _eh_conta_redutora(conta: LinhaBalanceteExtraida) -> bool:
|
||||
return conta.descricao.strip().startswith("(-)")
|
||||
|
||||
|
||||
def regra_saldo_sinal_invertido(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
|
||||
achados = []
|
||||
for conta in atual.contas:
|
||||
if conta.tipo != "A" or conta.saldo_atual == 0 or _eh_conta_redutora(conta):
|
||||
continue
|
||||
if conta.codigo.startswith("1.") and conta.saldo_atual < 0:
|
||||
achados.append(
|
||||
AchadoDetectado(
|
||||
regra="saldo_sinal_invertido",
|
||||
severidade=SEVERIDADE_MEDIA,
|
||||
titulo="Conta do Ativo com saldo credor",
|
||||
mensagem=f'Conta do Ativo "{conta.descricao}" ({conta.codigo}) está com saldo credor de R$ {-conta.saldo_atual}.',
|
||||
codigo_conta=conta.codigo,
|
||||
valor_referencia=conta.saldo_atual,
|
||||
)
|
||||
)
|
||||
elif conta.codigo.startswith("2.") and conta.saldo_atual > 0:
|
||||
achados.append(
|
||||
AchadoDetectado(
|
||||
regra="saldo_sinal_invertido",
|
||||
severidade=SEVERIDADE_MEDIA,
|
||||
titulo="Conta do Passivo com saldo devedor",
|
||||
mensagem=f'Conta do Passivo "{conta.descricao}" ({conta.codigo}) está com saldo devedor de R$ {conta.saldo_atual}.',
|
||||
codigo_conta=conta.codigo,
|
||||
valor_referencia=conta.saldo_atual,
|
||||
)
|
||||
)
|
||||
return achados
|
||||
|
||||
|
||||
def regra_descricao_generica(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
|
||||
achados = []
|
||||
for conta in atual.contas:
|
||||
if conta.descricao.strip().upper() == "DIVERSOS" and conta.saldo_atual != 0:
|
||||
achados.append(
|
||||
AchadoDetectado(
|
||||
regra="descricao_generica",
|
||||
severidade=SEVERIDADE_BAIXA,
|
||||
titulo='Conta com descrição genérica ("DIVERSOS")',
|
||||
mensagem=f'Conta {conta.codigo} está descrita apenas como "DIVERSOS", com saldo de R$ {conta.saldo_atual} — recomenda-se detalhar em conta analítica própria.',
|
||||
codigo_conta=conta.codigo,
|
||||
valor_referencia=conta.saldo_atual,
|
||||
)
|
||||
)
|
||||
return achados
|
||||
|
||||
|
||||
def regra_variacao_atipica_saldo(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
|
||||
if not historico:
|
||||
return []
|
||||
anterior = historico[0]
|
||||
achados = []
|
||||
for conta in atual.contas:
|
||||
if conta.tipo != "A":
|
||||
continue
|
||||
saldo_anterior = anterior.contas.get(conta.codigo)
|
||||
if saldo_anterior is None or saldo_anterior == 0:
|
||||
continue
|
||||
diferenca = conta.saldo_atual - saldo_anterior
|
||||
if abs(diferenca) < VARIACAO_VALOR_MINIMO:
|
||||
continue
|
||||
variacao_percentual = abs(diferenca) / abs(saldo_anterior)
|
||||
if variacao_percentual < VARIACAO_LIMIAR_PERCENTUAL:
|
||||
continue
|
||||
achados.append(
|
||||
AchadoDetectado(
|
||||
regra="variacao_atipica_saldo",
|
||||
severidade=SEVERIDADE_MEDIA,
|
||||
titulo="Variação atípica de saldo",
|
||||
mensagem=(
|
||||
f'Conta "{conta.descricao}" ({conta.codigo}) variou {variacao_percentual:.0%} em relação a '
|
||||
f"{anterior.competencia:%m/%Y} (de R$ {saldo_anterior} para R$ {conta.saldo_atual})."
|
||||
),
|
||||
codigo_conta=conta.codigo,
|
||||
valor_referencia=diferenca,
|
||||
)
|
||||
)
|
||||
return achados
|
||||
|
||||
|
||||
def regra_variacao_atipica_dre(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
|
||||
if len(historico) < 2:
|
||||
return []
|
||||
anterior, retrasado = historico[0], historico[1]
|
||||
if not _mes_consecutivo(anterior.competencia, retrasado.competencia):
|
||||
return []
|
||||
achados = []
|
||||
for linha in atual.linhas_dre:
|
||||
valor_anterior_ytd = anterior.linhas_dre.get(linha.descricao)
|
||||
valor_retrasado_ytd = retrasado.linhas_dre.get(linha.descricao)
|
||||
if valor_anterior_ytd is None:
|
||||
continue
|
||||
mes_atual = _isola_mes_dre(linha.valor, valor_anterior_ytd, atual.cabecalho.periodo_fim)
|
||||
mes_anterior = _isola_mes_dre(valor_anterior_ytd, valor_retrasado_ytd, anterior.competencia)
|
||||
if mes_anterior == 0:
|
||||
continue
|
||||
diferenca = mes_atual - mes_anterior
|
||||
if abs(diferenca) < VARIACAO_VALOR_MINIMO:
|
||||
continue
|
||||
variacao_percentual = abs(diferenca) / abs(mes_anterior)
|
||||
if variacao_percentual < VARIACAO_LIMIAR_PERCENTUAL:
|
||||
continue
|
||||
achados.append(
|
||||
AchadoDetectado(
|
||||
regra="variacao_atipica_dre",
|
||||
severidade=SEVERIDADE_MEDIA,
|
||||
titulo="Variação atípica na DRE",
|
||||
mensagem=(
|
||||
f'Linha "{linha.descricao}" da DRE variou {variacao_percentual:.0%} em relação ao mês anterior '
|
||||
f"(de R$ {mes_anterior} para R$ {mes_atual})."
|
||||
),
|
||||
valor_referencia=diferenca,
|
||||
)
|
||||
)
|
||||
return achados
|
||||
|
||||
|
||||
def regra_percentual_custo_receita_atipico(
|
||||
atual: ResultadoExtracao, historico: list[SnapshotHistorico]
|
||||
) -> list[AchadoDetectado]:
|
||||
if len(historico) < 2:
|
||||
return []
|
||||
anterior, retrasado = historico[0], historico[1]
|
||||
if not _mes_consecutivo(anterior.competencia, retrasado.competencia):
|
||||
return []
|
||||
|
||||
def _linha(resultado_linhas: dict[str, Decimal], nome: str) -> Decimal | None:
|
||||
return resultado_linhas.get(nome)
|
||||
|
||||
linhas_atual = {l.descricao: l.valor for l in atual.linhas_dre}
|
||||
receita_atual_ytd = _linha(linhas_atual, LINHA_DRE_RECEITA_LIQUIDA)
|
||||
custos_atual_ytd = _linha(linhas_atual, LINHA_DRE_CUSTOS_TOTAIS)
|
||||
receita_anterior_ytd = _linha(anterior.linhas_dre, LINHA_DRE_RECEITA_LIQUIDA)
|
||||
custos_anterior_ytd = _linha(anterior.linhas_dre, LINHA_DRE_CUSTOS_TOTAIS)
|
||||
receita_retrasado_ytd = _linha(retrasado.linhas_dre, LINHA_DRE_RECEITA_LIQUIDA)
|
||||
custos_retrasado_ytd = _linha(retrasado.linhas_dre, LINHA_DRE_CUSTOS_TOTAIS)
|
||||
if None in (receita_atual_ytd, custos_atual_ytd, receita_anterior_ytd, custos_anterior_ytd):
|
||||
return []
|
||||
|
||||
receita_mes_atual = _isola_mes_dre(receita_atual_ytd, receita_anterior_ytd, atual.cabecalho.periodo_fim)
|
||||
custos_mes_atual = _isola_mes_dre(custos_atual_ytd, custos_anterior_ytd, atual.cabecalho.periodo_fim)
|
||||
receita_mes_anterior = _isola_mes_dre(receita_anterior_ytd, receita_retrasado_ytd, anterior.competencia)
|
||||
custos_mes_anterior = _isola_mes_dre(custos_anterior_ytd, custos_retrasado_ytd, anterior.competencia)
|
||||
if receita_mes_atual == 0 or receita_mes_anterior == 0:
|
||||
return []
|
||||
|
||||
pct_atual = abs(custos_mes_atual) / abs(receita_mes_atual)
|
||||
pct_anterior = abs(custos_mes_anterior) / abs(receita_mes_anterior)
|
||||
if pct_anterior == 0:
|
||||
return []
|
||||
variacao_percentual = abs(pct_atual - pct_anterior) / pct_anterior
|
||||
if variacao_percentual < VARIACAO_LIMIAR_PERCENTUAL:
|
||||
return []
|
||||
return [
|
||||
AchadoDetectado(
|
||||
regra="percentual_custo_receita_atipico",
|
||||
severidade=SEVERIDADE_MEDIA,
|
||||
titulo="Percentual de custo sobre receita fora do padrão da empresa",
|
||||
mensagem=(
|
||||
f"O custo representou {pct_atual:.1%} da receita líquida do mês, contra {pct_anterior:.1%} no mês "
|
||||
f"anterior — variação de {variacao_percentual:.0%} em relação ao padrão histórico desta empresa."
|
||||
),
|
||||
valor_referencia=pct_atual - pct_anterior,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
REGRAS = [
|
||||
regra_balanceamento_ativo_passivo,
|
||||
regra_debito_credito_divergente,
|
||||
regra_saldo_negativo_caixa,
|
||||
regra_conta_transitoria_com_saldo,
|
||||
regra_conta_deveria_zerar,
|
||||
regra_saldo_sinal_invertido,
|
||||
regra_descricao_generica,
|
||||
regra_variacao_atipica_saldo,
|
||||
regra_variacao_atipica_dre,
|
||||
regra_percentual_custo_receita_atipico,
|
||||
]
|
||||
|
||||
|
||||
def gera_achados(atual: ResultadoExtracao, historico: list[SnapshotHistorico]) -> list[AchadoDetectado]:
|
||||
"""`historico` deve vir ordenado da apuração mais recente pra mais antiga
|
||||
(mesma empresa, competências anteriores à de `atual`)."""
|
||||
achados: list[AchadoDetectado] = []
|
||||
for regra in REGRAS:
|
||||
achados.extend(regra(atual, historico))
|
||||
return achados
|
||||
@ -173,11 +173,6 @@ class Command(BaseCommand):
|
||||
# tem acesso por padrão, liberado manualmente pra outros perfis depois
|
||||
# pela tela de Perfis de Acesso.
|
||||
permissoes["relatorios"]["apps"]["nao-conformidades"] = False
|
||||
# "dashboard-contabil" expõe balancete/DRE completos dos
|
||||
# clientes (dado financeiro sensível) — mesmo padrão de
|
||||
# "Não Conformidades" acima, nasce restrita só a Integração e
|
||||
# Inovação.
|
||||
permissoes["relatorios"]["apps"]["dashboard-contabil"] = False
|
||||
# "utilitarios" está em SECTORAL_KEYS — "importacao-plano-saude-de-paula"
|
||||
# lida com dado pessoal de colaborador do próprio escritório (plano de
|
||||
# saúde), não dado de cliente como o resto de Utilitários, então nasce
|
||||
|
||||
@ -1,102 +0,0 @@
|
||||
# Generated by Django 6.0.7 on 2026-09-08 17:46
|
||||
|
||||
import django.db.models.deletion
|
||||
import portal_api.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('portal_api', '0054_importacaoplanosaudedepaula_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ContabilApuracao',
|
||||
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')),
|
||||
('nome_empresa', models.CharField(max_length=255, verbose_name='Nome da empresa')),
|
||||
('cnpj', models.CharField(blank=True, max_length=20, verbose_name='CNPJ')),
|
||||
('competencia', models.DateField(verbose_name='Competência')),
|
||||
('periodo_inicio', models.DateField(verbose_name='Início do período')),
|
||||
('periodo_fim', models.DateField(verbose_name='Fim do período')),
|
||||
('arquivo', models.FileField(max_length=255, upload_to='contabil/apuracoes/', validators=[portal_api.models.validar_tamanho_arquivo_contabil], verbose_name='Arquivo do balancete (PDF)')),
|
||||
('status', models.CharField(choices=[('revisao', 'Em revisão'), ('concluida', 'Concluída')], default='revisao', max_length=20, verbose_name='Status')),
|
||||
('criado_em', models.DateTimeField(auto_now_add=True)),
|
||||
('concluida_em', models.DateTimeField(blank=True, null=True, verbose_name='Concluída em')),
|
||||
('criado_por', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apuracoes_contabeis', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Apuração do Dashboard Contábil',
|
||||
'verbose_name_plural': 'Apurações do Dashboard Contábil',
|
||||
'ordering': ['-competencia', '-criado_em'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContabilConta',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('conta_numero', models.IntegerField(verbose_name='Número da conta')),
|
||||
('codigo', models.CharField(max_length=60, verbose_name='Classificação')),
|
||||
('descricao', models.CharField(max_length=255, verbose_name='Descrição')),
|
||||
('tipo', models.CharField(max_length=1, verbose_name='Tipo')),
|
||||
('saldo_anterior', models.DecimalField(decimal_places=2, max_digits=16)),
|
||||
('debito', models.DecimalField(decimal_places=2, max_digits=16)),
|
||||
('credito', models.DecimalField(decimal_places=2, max_digits=16)),
|
||||
('saldo_atual', models.DecimalField(decimal_places=2, max_digits=16)),
|
||||
('observacao', models.TextField(blank=True, verbose_name='Observação')),
|
||||
('apuracao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contas', to='portal_api.contabilapuracao')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Conta do Balancete (Dashboard Contábil)',
|
||||
'verbose_name_plural': 'Contas do Balancete (Dashboard Contábil)',
|
||||
'ordering': ['conta_numero'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContabilAchado',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('regra', models.CharField(max_length=60, verbose_name='Regra')),
|
||||
('severidade', models.CharField(choices=[('alta', 'Alta'), ('media', 'Média'), ('baixa', 'Baixa')], max_length=10, verbose_name='Severidade')),
|
||||
('titulo', models.CharField(max_length=255, verbose_name='Título')),
|
||||
('mensagem', models.TextField(verbose_name='Mensagem')),
|
||||
('valor_referencia', models.DecimalField(blank=True, decimal_places=2, max_digits=16, null=True)),
|
||||
('status', models.CharField(choices=[('pendente', 'Pendente'), ('tratado', 'Tratado'), ('ignorado', 'Ignorado')], default='pendente', max_length=10, verbose_name='Status')),
|
||||
('observacao_contador', models.TextField(blank=True, verbose_name='Observação do contador')),
|
||||
('tratado_em', models.DateTimeField(blank=True, null=True, verbose_name='Tratado em')),
|
||||
('tratado_por', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='achados_contabeis_tratados', to=settings.AUTH_USER_MODEL)),
|
||||
('apuracao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='achados', to='portal_api.contabilapuracao')),
|
||||
('conta', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='achados', to='portal_api.contabilconta')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Achado do Dashboard Contábil',
|
||||
'verbose_name_plural': 'Achados do Dashboard Contábil',
|
||||
'ordering': ['-severidade', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContabilLinhaDre',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ordem', models.IntegerField(verbose_name='Ordem')),
|
||||
('descricao', models.CharField(max_length=255, verbose_name='Descrição')),
|
||||
('nivel', models.IntegerField(default=0, verbose_name='Nível de indentação')),
|
||||
('valor', models.DecimalField(decimal_places=2, max_digits=16)),
|
||||
('totalizador', models.BooleanField(default=False, verbose_name='Linha totalizadora')),
|
||||
('apuracao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='linhas_dre', to='portal_api.contabilapuracao')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Linha da DRE (Dashboard Contábil)',
|
||||
'verbose_name_plural': 'Linhas da DRE (Dashboard Contábil)',
|
||||
'ordering': ['ordem'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contabilapuracao',
|
||||
constraint=models.UniqueConstraint(fields=('codigo_empresa', 'competencia'), name='contabil_apuracao_unica'),
|
||||
),
|
||||
]
|
||||
@ -1,22 +0,0 @@
|
||||
# Generated by Django 6.0.7 on 2026-09-08 20:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('portal_api', '0055_contabilapuracao_contabilconta_contabilachado_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='contabilconta',
|
||||
options={'ordering': ['ordem'], 'verbose_name': 'Conta do Balancete (Dashboard Contábil)', 'verbose_name_plural': 'Contas do Balancete (Dashboard Contábil)'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contabilconta',
|
||||
name='ordem',
|
||||
field=models.IntegerField(default=0, verbose_name='Ordem'),
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.7 on 2026-09-08 20:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('portal_api', '0056_alter_contabilconta_options_contabilconta_ordem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contabillinhadre',
|
||||
name='observacao',
|
||||
field=models.TextField(blank=True, verbose_name='Observação'),
|
||||
),
|
||||
]
|
||||
@ -1932,166 +1932,3 @@ class NCAcompanhamento(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.acao} — {self.data:%d/%m/%Y}"
|
||||
|
||||
|
||||
CONTABIL_ARQUIVO_MAX_BYTES = 15 * 1024 * 1024
|
||||
|
||||
|
||||
def validar_tamanho_arquivo_contabil(arquivo: File) -> None:
|
||||
if arquivo.size > CONTABIL_ARQUIVO_MAX_BYTES:
|
||||
raise ValidationError("O arquivo deve ter no máximo 15MB.")
|
||||
|
||||
|
||||
class ContabilApuracao(models.Model):
|
||||
"""Uma análise do Dashboard Contábil (Relatórios > Contabilidade): o
|
||||
contador anexa o PDF de Balancete + DRE (leiaute Questor, mesmo relatório
|
||||
hoje enviado ao cliente) e o pipeline (`portal_api.dashboard_contabil.
|
||||
pipeline.processa_apuracao`) extrai as contas/linhas da DRE e roda o
|
||||
motor de regras de auditoria (`dashboard_contabil.regras`) contra o
|
||||
histórico já processado desta mesma empresa — ver
|
||||
`ContabilApuracaoViewSet.create()` em views.py. `unique_together` evita
|
||||
duplicar sem querer a mesma competência de uma empresa (reprocessar exige
|
||||
excluir a apuração antiga primeiro)."""
|
||||
|
||||
STATUS_REVISAO = "revisao"
|
||||
STATUS_CONCLUIDA = "concluida"
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_REVISAO, "Em revisão"),
|
||||
(STATUS_CONCLUIDA, "Concluída"),
|
||||
]
|
||||
|
||||
codigo_empresa = models.CharField("Código da empresa", max_length=20)
|
||||
nome_empresa = models.CharField("Nome da empresa", max_length=255)
|
||||
cnpj = models.CharField("CNPJ", max_length=20, blank=True)
|
||||
competencia = models.DateField("Competência")
|
||||
periodo_inicio = models.DateField("Início do período")
|
||||
periodo_fim = models.DateField("Fim do período")
|
||||
arquivo = models.FileField(
|
||||
"Arquivo do balancete (PDF)",
|
||||
upload_to="contabil/apuracoes/",
|
||||
max_length=255,
|
||||
validators=[validar_tamanho_arquivo_contabil],
|
||||
)
|
||||
status = models.CharField("Status", max_length=20, choices=STATUS_CHOICES, default=STATUS_REVISAO)
|
||||
criado_por = models.ForeignKey(Usuario, on_delete=models.SET_NULL, null=True, related_name="apuracoes_contabeis")
|
||||
criado_em = models.DateTimeField(auto_now_add=True)
|
||||
concluida_em = models.DateTimeField("Concluída em", null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Apuração do Dashboard Contábil"
|
||||
verbose_name_plural = "Apurações do Dashboard Contábil"
|
||||
ordering = ["-competencia", "-criado_em"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["codigo_empresa", "competencia"], name="contabil_apuracao_unica")
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.codigo_empresa} — {self.competencia:%m/%Y}"
|
||||
|
||||
|
||||
class ContabilConta(models.Model):
|
||||
"""Uma linha do Balancete extraída do PDF (grupo Ativo/Passivo, sintética
|
||||
ou analítica). `observacao` é livre e editável pelo contador via PATCH em
|
||||
qualquer conta, tenha ela gerado um `ContabilAchado` ou não — é o espaço
|
||||
de "análise" pedido pelo usuário, independente do que a auditoria
|
||||
automática encontrou."""
|
||||
|
||||
apuracao = models.ForeignKey(ContabilApuracao, on_delete=models.CASCADE, related_name="contas")
|
||||
# `conta_numero` (a numeração interna do Questor, coluna "Conta" do PDF)
|
||||
# NÃO corresponde à ordem real de impressão do balancete — o Questor
|
||||
# reaproveita numeração de contas antigas/canceladas, então o mesmo
|
||||
# intervalo de números pode ter contas de Ativo e Passivo intercaladas.
|
||||
# `ordem` é a posição de leitura real do PDF (atribuída pela view na
|
||||
# criação, mesmo papel de ContabilLinhaDre.ordem) e é o que garante que
|
||||
# a árvore do Balancete (ver dashboard-contabil.js) não misture grupos.
|
||||
ordem = models.IntegerField("Ordem", default=0)
|
||||
conta_numero = models.IntegerField("Número da conta")
|
||||
codigo = models.CharField("Classificação", max_length=60)
|
||||
descricao = models.CharField("Descrição", max_length=255)
|
||||
tipo = models.CharField("Tipo", max_length=1) # "S" sintética | "A" analítica
|
||||
saldo_anterior = models.DecimalField(max_digits=16, decimal_places=2)
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Conta do Balancete (Dashboard Contábil)"
|
||||
verbose_name_plural = "Contas do Balancete (Dashboard Contábil)"
|
||||
ordering = ["ordem"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.codigo} {self.descricao}"
|
||||
|
||||
|
||||
class ContabilLinhaDre(models.Model):
|
||||
"""Uma linha da Demonstração do Resultado do Exercício extraída do PDF —
|
||||
sem código de classificação (o relatório da DRE não traz, diferente do
|
||||
Balancete), só descrição/nível/valor na ordem em que aparecem."""
|
||||
|
||||
apuracao = models.ForeignKey(ContabilApuracao, on_delete=models.CASCADE, related_name="linhas_dre")
|
||||
ordem = models.IntegerField("Ordem")
|
||||
descricao = models.CharField("Descrição", max_length=255)
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Linha da DRE (Dashboard Contábil)"
|
||||
verbose_name_plural = "Linhas da DRE (Dashboard Contábil)"
|
||||
ordering = ["ordem"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.descricao}"
|
||||
|
||||
|
||||
class ContabilAchado(models.Model):
|
||||
"""Um achado gerado automaticamente pelo motor de regras
|
||||
(`dashboard_contabil.regras`) ao criar a apuração — nunca é apagado, só
|
||||
muda de `status` conforme o contador revisa (mesmo espírito de
|
||||
`ImportacaoPlanoSaudeAuditoria`: histórico completo preservado). `conta`
|
||||
é opcional porque alguns achados são gerais (ex.: desbalanceamento
|
||||
Ativo x Passivo), sem uma única conta associada."""
|
||||
|
||||
SEVERIDADE_ALTA = "alta"
|
||||
SEVERIDADE_MEDIA = "media"
|
||||
SEVERIDADE_BAIXA = "baixa"
|
||||
SEVERIDADE_CHOICES = [
|
||||
(SEVERIDADE_ALTA, "Alta"),
|
||||
(SEVERIDADE_MEDIA, "Média"),
|
||||
(SEVERIDADE_BAIXA, "Baixa"),
|
||||
]
|
||||
|
||||
STATUS_PENDENTE = "pendente"
|
||||
STATUS_TRATADO = "tratado"
|
||||
STATUS_IGNORADO = "ignorado"
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_PENDENTE, "Pendente"),
|
||||
(STATUS_TRATADO, "Tratado"),
|
||||
(STATUS_IGNORADO, "Ignorado"),
|
||||
]
|
||||
|
||||
apuracao = models.ForeignKey(ContabilApuracao, on_delete=models.CASCADE, related_name="achados")
|
||||
conta = models.ForeignKey(
|
||||
ContabilConta, on_delete=models.SET_NULL, null=True, blank=True, related_name="achados"
|
||||
)
|
||||
regra = models.CharField("Regra", max_length=60)
|
||||
severidade = models.CharField("Severidade", max_length=10, choices=SEVERIDADE_CHOICES)
|
||||
titulo = models.CharField("Título", max_length=255)
|
||||
mensagem = models.TextField("Mensagem")
|
||||
valor_referencia = models.DecimalField(max_digits=16, decimal_places=2, null=True, blank=True)
|
||||
status = models.CharField("Status", max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDENTE)
|
||||
observacao_contador = models.TextField("Observação do contador", blank=True)
|
||||
tratado_por = models.ForeignKey(
|
||||
Usuario, on_delete=models.SET_NULL, null=True, blank=True, related_name="achados_contabeis_tratados"
|
||||
)
|
||||
tratado_em = models.DateTimeField("Tratado em", null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Achado do Dashboard Contábil"
|
||||
verbose_name_plural = "Achados do Dashboard Contábil"
|
||||
ordering = ["-severidade", "id"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.titulo} ({self.get_status_display()})"
|
||||
|
||||
@ -16,10 +16,6 @@ from .models import (
|
||||
AjudaAplicacao,
|
||||
CategoriaEvento,
|
||||
CompromissoAgenda,
|
||||
ContabilAchado,
|
||||
ContabilApuracao,
|
||||
ContabilConta,
|
||||
ContabilLinhaDre,
|
||||
Departamento,
|
||||
EmpresaQuestor,
|
||||
Favorito,
|
||||
@ -2219,130 +2215,3 @@ class NaoConformidadeImportacaoSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = NaoConformidadeImportacao
|
||||
fields = ["id", "criado_por_nome", "criado_em", "resumo", "avisos"]
|
||||
|
||||
|
||||
class ContabilApuracaoCreateSerializer(serializers.Serializer):
|
||||
"""Entrada de "Nova Análise" (multipart): só o PDF de Balancete + DRE —
|
||||
empresa/competência/período são extraídos do próprio arquivo pelo
|
||||
pipeline, não informados pelo usuário."""
|
||||
|
||||
arquivo = serializers.FileField()
|
||||
|
||||
|
||||
class ContabilContaSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ContabilConta
|
||||
fields = [
|
||||
"id",
|
||||
"conta_numero",
|
||||
"codigo",
|
||||
"descricao",
|
||||
"tipo",
|
||||
"saldo_anterior",
|
||||
"debito",
|
||||
"credito",
|
||||
"saldo_atual",
|
||||
"observacao",
|
||||
]
|
||||
read_only_fields = ["id", "conta_numero", "codigo", "descricao", "tipo", "saldo_anterior", "debito", "credito", "saldo_atual"]
|
||||
|
||||
|
||||
class ContabilLinhaDreSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ContabilLinhaDre
|
||||
fields = ["id", "ordem", "descricao", "nivel", "valor", "totalizador", "observacao"]
|
||||
read_only_fields = ["id", "ordem", "descricao", "nivel", "valor", "totalizador"]
|
||||
|
||||
|
||||
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)
|
||||
tratado_por_nome = serializers.CharField(source="tratado_por.nome", read_only=True, default=None)
|
||||
|
||||
class Meta:
|
||||
model = ContabilAchado
|
||||
fields = [
|
||||
"id",
|
||||
"conta",
|
||||
"conta_codigo",
|
||||
"conta_descricao",
|
||||
"regra",
|
||||
"severidade",
|
||||
"titulo",
|
||||
"mensagem",
|
||||
"valor_referencia",
|
||||
"status",
|
||||
"observacao_contador",
|
||||
"tratado_por_nome",
|
||||
"tratado_em",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"conta",
|
||||
"conta_codigo",
|
||||
"conta_descricao",
|
||||
"regra",
|
||||
"severidade",
|
||||
"titulo",
|
||||
"mensagem",
|
||||
"valor_referencia",
|
||||
"tratado_por_nome",
|
||||
"tratado_em",
|
||||
]
|
||||
|
||||
|
||||
class ContabilAchadoAjusteSerializer(serializers.Serializer):
|
||||
"""PATCH de um achado — mudar status sempre exige uma observação
|
||||
justificando (tratado ou ignorado), ver `ContabilAchadoViewSet`."""
|
||||
|
||||
status = serializers.ChoiceField(choices=[ContabilAchado.STATUS_TRATADO, ContabilAchado.STATUS_IGNORADO])
|
||||
observacao_contador = serializers.CharField(allow_blank=False)
|
||||
|
||||
|
||||
class ContabilApuracaoListSerializer(serializers.ModelSerializer):
|
||||
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
|
||||
total_achados_pendentes = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ContabilApuracao
|
||||
fields = [
|
||||
"id",
|
||||
"codigo_empresa",
|
||||
"nome_empresa",
|
||||
"cnpj",
|
||||
"competencia",
|
||||
"status",
|
||||
"criado_por_nome",
|
||||
"criado_em",
|
||||
"concluida_em",
|
||||
"total_achados_pendentes",
|
||||
]
|
||||
|
||||
def get_total_achados_pendentes(self, obj: ContabilApuracao) -> int:
|
||||
return sum(1 for achado in obj.achados.all() if achado.status == ContabilAchado.STATUS_PENDENTE)
|
||||
|
||||
|
||||
class ContabilApuracaoDetailSerializer(serializers.ModelSerializer):
|
||||
criado_por_nome = serializers.CharField(source="criado_por.nome", read_only=True, default=None)
|
||||
contas = ContabilContaSerializer(many=True, read_only=True)
|
||||
linhas_dre = ContabilLinhaDreSerializer(many=True, read_only=True)
|
||||
achados = ContabilAchadoSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ContabilApuracao
|
||||
fields = [
|
||||
"id",
|
||||
"codigo_empresa",
|
||||
"nome_empresa",
|
||||
"cnpj",
|
||||
"competencia",
|
||||
"periodo_inicio",
|
||||
"periodo_fim",
|
||||
"status",
|
||||
"criado_por_nome",
|
||||
"criado_em",
|
||||
"concluida_em",
|
||||
"contas",
|
||||
"linhas_dre",
|
||||
"achados",
|
||||
]
|
||||
|
||||
@ -95,10 +95,6 @@ router.register(
|
||||
)
|
||||
router.register("nao-conformidades/ocorrencias", views.NCOcorrenciaViewSet, basename="nc-ocorrencia")
|
||||
router.register("nao-conformidades/acoes", views.NCAcaoViewSet, basename="nc-acao")
|
||||
router.register("contabil-apuracoes", views.ContabilApuracaoViewSet, basename="contabil-apuracao")
|
||||
router.register("contabil-contas", views.ContabilContaViewSet, basename="contabil-conta")
|
||||
router.register("contabil-linhas-dre", views.ContabilLinhaDreViewSet, basename="contabil-linha-dre")
|
||||
router.register("contabil-achados", views.ContabilAchadoViewSet, basename="contabil-achado")
|
||||
|
||||
urlpatterns = [
|
||||
path("auth/csrf/", views.csrf_view, name="auth-csrf"),
|
||||
|
||||
@ -32,20 +32,12 @@ from rest_framework.serializers import ModelSerializer
|
||||
from . import catalogo
|
||||
from .custo_contratacao.calculo import calcula_custo_empregado
|
||||
from .custo_contratacao.pdf import gera_pdf_simulacao
|
||||
from .dashboard_contabil import pipeline as dashboard_contabil_pipeline
|
||||
from .dashboard_contabil.modelos import CabecalhoExtraido as ContabilCabecalhoExtraido
|
||||
from .dashboard_contabil.modelos import SnapshotHistorico as ContabilSnapshotHistorico
|
||||
from .dashboard_contabil.parser import ExtracaoInvalidaError as ContabilExtracaoInvalidaError
|
||||
from .models import (
|
||||
AcessoGeral,
|
||||
AcessoGeralSecao,
|
||||
AjudaAplicacao,
|
||||
CategoriaEvento,
|
||||
CompromissoAgenda,
|
||||
ContabilAchado,
|
||||
ContabilApuracao,
|
||||
ContabilConta,
|
||||
ContabilLinhaDre,
|
||||
Departamento,
|
||||
Favorito,
|
||||
FuncaoTelefonia,
|
||||
@ -111,13 +103,6 @@ from .serializers import (
|
||||
AjudaAplicacaoSerializer,
|
||||
CategoriaEventoSerializer,
|
||||
CompromissoAgendaSerializer,
|
||||
ContabilAchadoAjusteSerializer,
|
||||
ContabilAchadoSerializer,
|
||||
ContabilApuracaoCreateSerializer,
|
||||
ContabilApuracaoDetailSerializer,
|
||||
ContabilApuracaoListSerializer,
|
||||
ContabilContaSerializer,
|
||||
ContabilLinhaDreSerializer,
|
||||
DepartamentoSerializer,
|
||||
FavoritoSerializer,
|
||||
FuncaoTelefoniaSerializer,
|
||||
@ -3275,238 +3260,3 @@ def nao_conformidades_dashboard_view(request: Request) -> Response:
|
||||
"periodo": {"data_inicio": data_inicio.isoformat(), "data_fim": data_fim.isoformat()},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _contabil_monta_historico(cabecalho: ContabilCabecalhoExtraido) -> list[ContabilSnapshotHistorico]:
|
||||
"""Histórico da mesma empresa (até 2 apurações anteriores, mais recente
|
||||
primeiro) já persistidas — usado pelas regras de variação mês a mês
|
||||
(`dashboard_contabil.regras`). Só uma consulta simples ao ORM, sem
|
||||
depender de nenhum relatório complementar anexado (ver CLAUDE.md do
|
||||
pacote)."""
|
||||
competencia_atual = cabecalho.periodo_fim.replace(day=1)
|
||||
anteriores = (
|
||||
ContabilApuracao.objects.filter(codigo_empresa=cabecalho.codigo_empresa, competencia__lt=competencia_atual)
|
||||
.order_by("-competencia")
|
||||
.prefetch_related("contas", "linhas_dre")[:2]
|
||||
)
|
||||
return [
|
||||
ContabilSnapshotHistorico(
|
||||
competencia=apuracao.competencia,
|
||||
contas={conta.codigo: conta.saldo_atual for conta in apuracao.contas.all()},
|
||||
linhas_dre={linha.descricao: linha.valor for linha in apuracao.linhas_dre.all()},
|
||||
)
|
||||
for apuracao in anteriores
|
||||
]
|
||||
|
||||
|
||||
class ContabilApuracaoViewSet(viewsets.ModelViewSet):
|
||||
"""Dashboard Contábil (Relatórios > Contabilidade) — permissão de toggle
|
||||
único, mesmo espírito de ImportacaoPlanoSaudeViewSet/IndicadorApuracaoViewSet.
|
||||
`create()` roda `portal_api.dashboard_contabil.pipeline` de forma síncrona
|
||||
sobre o PDF anexado: extrai o cabeçalho/contas/DRE, busca o histórico já
|
||||
persistido da mesma empresa e roda o motor de regras — tudo isso **antes**
|
||||
de gravar qualquer coisa no banco, já que `codigo_empresa`/`competencia`
|
||||
(a chave natural da apuração) só são conhecidos depois de ler o PDF, não
|
||||
informados pelo usuário no upload (diferente de IndicadorApuracao)."""
|
||||
|
||||
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||
queryset = ContabilApuracao.objects.all()
|
||||
|
||||
def get_permissions(self) -> list[BasePermission]:
|
||||
return [PermissaoApp("relatorios", "dashboard-contabil")]
|
||||
|
||||
def get_queryset(self) -> QuerySet[ContabilApuracao]:
|
||||
queryset = super().get_queryset()
|
||||
if self.action == "retrieve":
|
||||
queryset = queryset.prefetch_related("contas", "linhas_dre", "achados__conta")
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self) -> type[ModelSerializer]:
|
||||
if self.action == "list":
|
||||
return ContabilApuracaoListSerializer
|
||||
return ContabilApuracaoDetailSerializer
|
||||
|
||||
def perform_destroy(self, instance: ContabilApuracao) -> None:
|
||||
instance.arquivo.delete(save=False)
|
||||
instance.delete()
|
||||
|
||||
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||
entrada = ContabilApuracaoCreateSerializer(data=request.data)
|
||||
entrada.is_valid(raise_exception=True)
|
||||
arquivo = entrada.validated_data["arquivo"]
|
||||
conteudo = arquivo.read()
|
||||
|
||||
try:
|
||||
resultado = dashboard_contabil_pipeline.processa_apuracao(
|
||||
io.BytesIO(conteudo), _contabil_monta_historico
|
||||
)
|
||||
except ContabilExtracaoInvalidaError:
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
"O arquivo não está no formato esperado (Balancete + DRE do Questor). "
|
||||
"Contate a Integração e Inovação."
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
cabecalho = resultado.extracao.cabecalho
|
||||
competencia = cabecalho.periodo_fim.replace(day=1)
|
||||
if ContabilApuracao.objects.filter(codigo_empresa=cabecalho.codigo_empresa, competencia=competencia).exists():
|
||||
return Response(
|
||||
{"detail": f"Já existe uma análise para {cabecalho.nome_empresa} na competência {competencia:%m/%Y}."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
apuracao: ContabilApuracao | None = None
|
||||
try:
|
||||
with transaction.atomic():
|
||||
apuracao = ContabilApuracao.objects.create(
|
||||
codigo_empresa=cabecalho.codigo_empresa,
|
||||
nome_empresa=cabecalho.nome_empresa,
|
||||
cnpj=cabecalho.cnpj,
|
||||
competencia=competencia,
|
||||
periodo_inicio=cabecalho.periodo_inicio,
|
||||
periodo_fim=cabecalho.periodo_fim,
|
||||
arquivo=ContentFile(conteudo, name=arquivo.name),
|
||||
criado_por=request.user,
|
||||
)
|
||||
ContabilConta.objects.bulk_create(
|
||||
[
|
||||
ContabilConta(
|
||||
apuracao=apuracao,
|
||||
ordem=indice,
|
||||
conta_numero=conta.conta_numero,
|
||||
codigo=conta.codigo,
|
||||
descricao=conta.descricao,
|
||||
tipo=conta.tipo,
|
||||
saldo_anterior=conta.saldo_anterior,
|
||||
debito=conta.debito,
|
||||
credito=conta.credito,
|
||||
saldo_atual=conta.saldo_atual,
|
||||
)
|
||||
for indice, conta in enumerate(resultado.extracao.contas)
|
||||
]
|
||||
)
|
||||
ContabilLinhaDre.objects.bulk_create(
|
||||
[
|
||||
ContabilLinhaDre(
|
||||
apuracao=apuracao,
|
||||
ordem=linha.ordem,
|
||||
descricao=linha.descricao,
|
||||
nivel=linha.nivel,
|
||||
valor=linha.valor,
|
||||
totalizador=linha.totalizador,
|
||||
)
|
||||
for linha in resultado.extracao.linhas_dre
|
||||
]
|
||||
)
|
||||
contas_por_codigo = {conta.codigo: conta for conta in apuracao.contas.all()}
|
||||
ContabilAchado.objects.bulk_create(
|
||||
[
|
||||
ContabilAchado(
|
||||
apuracao=apuracao,
|
||||
conta=contas_por_codigo.get(achado.codigo_conta) if achado.codigo_conta else None,
|
||||
regra=achado.regra,
|
||||
severidade=achado.severidade,
|
||||
titulo=achado.titulo,
|
||||
mensagem=achado.mensagem,
|
||||
valor_referencia=achado.valor_referencia,
|
||||
)
|
||||
for achado in resultado.achados
|
||||
]
|
||||
)
|
||||
except Exception:
|
||||
if apuracao is not None:
|
||||
apuracao.arquivo.delete(save=False)
|
||||
return Response(
|
||||
{"detail": "Não foi possível salvar a análise. Contate a Integração e Inovação."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = ContabilApuracaoDetailSerializer(apuracao)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def concluir(self, request: Request, pk: str | None = None) -> Response:
|
||||
"""Trava a apuração pra edição (observações/achados) — mesmo espírito
|
||||
de `ImportacaoPlanoSaude.status="concluida"`. Não há "reabrir" nesta
|
||||
v1 (diferente de planos_saude): reprocessar exige excluir e reanexar
|
||||
o PDF."""
|
||||
apuracao = self.get_object()
|
||||
apuracao.status = ContabilApuracao.STATUS_CONCLUIDA
|
||||
apuracao.concluida_em = timezone.now()
|
||||
apuracao.save(update_fields=["status", "concluida_em"])
|
||||
return Response(ContabilApuracaoDetailSerializer(apuracao).data)
|
||||
|
||||
|
||||
def _contabil_garante_em_revisao(apuracao: ContabilApuracao) -> None:
|
||||
if apuracao.status == ContabilApuracao.STATUS_CONCLUIDA:
|
||||
raise ValidationError(
|
||||
{"detail": "Esta análise já foi concluída — não é mais possível editar observações ou achados."}
|
||||
)
|
||||
|
||||
|
||||
class ContabilContaViewSet(viewsets.ModelViewSet):
|
||||
"""Contas do balancete de uma apuração — nascem todas juntas em
|
||||
`ContabilApuracaoViewSet.create()`, então só GET/PATCH (observação do
|
||||
contador) aqui."""
|
||||
|
||||
http_method_names = ["get", "patch", "head", "options"]
|
||||
queryset = ContabilConta.objects.all()
|
||||
serializer_class = ContabilContaSerializer
|
||||
|
||||
def get_permissions(self) -> list[BasePermission]:
|
||||
return [PermissaoApp("relatorios", "dashboard-contabil")]
|
||||
|
||||
def perform_update(self, serializer: ContabilContaSerializer) -> None:
|
||||
_contabil_garante_em_revisao(serializer.instance.apuracao)
|
||||
serializer.save()
|
||||
|
||||
|
||||
class ContabilLinhaDreViewSet(viewsets.ModelViewSet):
|
||||
"""Linhas da DRE de uma apuração — nascem todas juntas em
|
||||
`ContabilApuracaoViewSet.create()`, então só GET/PATCH (observação do
|
||||
contador) aqui, mesmo espírito de `ContabilContaViewSet`."""
|
||||
|
||||
http_method_names = ["get", "patch", "head", "options"]
|
||||
queryset = ContabilLinhaDre.objects.all()
|
||||
serializer_class = ContabilLinhaDreSerializer
|
||||
|
||||
def get_permissions(self) -> list[BasePermission]:
|
||||
return [PermissaoApp("relatorios", "dashboard-contabil")]
|
||||
|
||||
def perform_update(self, serializer: ContabilLinhaDreSerializer) -> None:
|
||||
_contabil_garante_em_revisao(serializer.instance.apuracao)
|
||||
serializer.save()
|
||||
|
||||
|
||||
class ContabilAchadoViewSet(viewsets.ModelViewSet):
|
||||
"""Achados de auditoria gerados automaticamente na criação da apuração —
|
||||
só GET/PATCH (marcar como tratado/ignorado, sempre com justificativa)."""
|
||||
|
||||
http_method_names = ["get", "patch", "head", "options"]
|
||||
queryset = ContabilAchado.objects.all()
|
||||
|
||||
def get_permissions(self) -> list[BasePermission]:
|
||||
return [PermissaoApp("relatorios", "dashboard-contabil")]
|
||||
|
||||
def get_serializer_class(self) -> type[ModelSerializer]:
|
||||
if self.action in ("update", "partial_update"):
|
||||
return ContabilAchadoAjusteSerializer
|
||||
return ContabilAchadoSerializer
|
||||
|
||||
def perform_update(self, serializer: ContabilAchadoAjusteSerializer) -> None:
|
||||
achado: ContabilAchado = self.get_object()
|
||||
_contabil_garante_em_revisao(achado.apuracao)
|
||||
achado.status = serializer.validated_data["status"]
|
||||
achado.observacao_contador = serializer.validated_data["observacao_contador"]
|
||||
achado.tratado_por = self.request.user
|
||||
achado.tratado_em = timezone.now()
|
||||
achado.save(update_fields=["status", "observacao_contador", "tratado_por", "tratado_em"])
|
||||
|
||||
def update(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||
super().update(request, *args, **kwargs)
|
||||
achado = self.get_object()
|
||||
return Response(ContabilAchadoSerializer(achado).data)
|
||||
|
||||
@ -1,383 +0,0 @@
|
||||
/* .dc-* — só usado em dashboard-contabil.html. Reaproveita perfis-acesso.css
|
||||
(.pa-table/.pa-tabs/.pa-table-wrap) e components.css (.modal-* / .btn-*),
|
||||
mesmo padrão de importacao-plano-saude.css/indicador-desempenho.css. */
|
||||
|
||||
.dc-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dc-header h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* flex-basis:auto (o padrão) mede o bloco de título+subtítulo pela largura
|
||||
que o parágrafo teria numa única linha só, bem maior que o espaço
|
||||
realmente disponível — o flex-wrap decide quebrar linha com base nessa
|
||||
largura "hipotética" de conteúdo, não na largura já quebrada em várias
|
||||
linhas, então o botão era empurrado pra uma linha própria mesmo sobrando
|
||||
espaço na tela. flex-basis:0% (via "flex: 1 1 0%") tira o conteúdo dessa
|
||||
conta: o bloco passa a ocupar só o espaço que sobra depois do botão. */
|
||||
.dc-header > *:first-child {
|
||||
min-width: 0;
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
/* flex-shrink:0 mantém o botão/ações no tamanho natural (nunca é ele que
|
||||
cede espaço); margin-left:auto é só uma rede de segurança pro caso raro
|
||||
de a tela ser estreita demais mesmo assim e a linha quebrar de verdade —
|
||||
sem isso, um item sozinho numa linha própria fica alinhado à esquerda
|
||||
mesmo com justify-content:space-between (não sobra outro item pra
|
||||
"espaçar" contra). */
|
||||
.dc-header > *:last-child {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.dc-header-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dc-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.dc-review-empresa {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dc-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
padding: var(--space-6);
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.dc-concluida-aviso {
|
||||
font-size: 0.85rem;
|
||||
color: var(--gold);
|
||||
background: rgba(var(--gold-rgb), 0.12);
|
||||
border: 1px solid rgba(var(--gold-rgb), 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.dc-form {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
/* .modal-actions (components.css) nasce alinhado à direita, pensado pro
|
||||
rodapé de um modal estreito — aqui "Nova Análise" é a própria página, não
|
||||
um modal, e os botões devem ficar junto do campo de arquivo acima deles,
|
||||
à esquerda, não soltos do outro lado da página. */
|
||||
.dc-form .modal-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.dc-file-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dc-file-field__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dc-file-field__input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.dc-file-field__input:focus-visible + .dc-file-field__name {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.dc-file-field__name {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.dc-file-field__remove {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Achados */
|
||||
|
||||
.dc-achados-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.dc-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.dc-chip {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-surface);
|
||||
transition: border-color var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.dc-chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.dc-chip.is-active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.dc-achados-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.dc-achado-card {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dc-achado-card--alta {
|
||||
border-left-color: var(--danger);
|
||||
}
|
||||
|
||||
.dc-achado-card--media {
|
||||
border-left-color: var(--gold);
|
||||
}
|
||||
|
||||
.dc-achado-card--baixa {
|
||||
border-left-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dc-achado-card__main {
|
||||
flex: 1 1 320px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dc-achado-card__titulo {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dc-achado-card__mensagem {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.dc-achado-card__observacao {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--space-2);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dc-achado-card__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Tabelas de Balancete/DRE — reaproveitam .pa-table, só ajustes de alinhamento
|
||||
numérico e da célula de observação (que embute um botão). */
|
||||
|
||||
.dc-contas-table td:nth-child(n + 3):nth-child(-n + 6),
|
||||
.dc-dre-table td:nth-child(2) {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dc-contas-table td:nth-child(1) {
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* Linha sintética (grupo do plano de contas, ex. "1.01 CIRCULANTE") — em
|
||||
negrito pra se destacar das analíticas que ela agrupa, mesmo padrão visual
|
||||
do PDF original (coluna "S/A"). */
|
||||
.dc-conta-row--sintetica {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dc-conta-row--sintetica td {
|
||||
background: var(--bg-surface-raised);
|
||||
}
|
||||
|
||||
.dc-conta-desc-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Recolhe/expande os filhos de uma conta sintética (árvore construída a
|
||||
partir do nível de indentação, ver dcContaNivel em dashboard-contabil.js)
|
||||
— mesmo espírito de uma matriz do Power BI, pedido explícito do usuário
|
||||
pra facilitar a conferência de um balancete com muitas contas. */
|
||||
.dc-conta-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.dc-conta-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface-raised);
|
||||
}
|
||||
|
||||
.dc-conta-toggle.is-expanded svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.dc-conta-toggle svg {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.dc-conta-toggle-spacer {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dc-conta-observacao-btn {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dc-conta-observacao-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dc-dre-row--totalizador td {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dc-dre-row td:first-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Selos de severidade/status do achado — pequenos o bastante pra ficar ao
|
||||
lado do título do card, sem depender de .status-pill (definida em
|
||||
nao-conformidades.css, não carregado nesta página). */
|
||||
|
||||
.dc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.dc-badge--alta {
|
||||
color: var(--danger);
|
||||
background: rgba(var(--danger-rgb), 0.14);
|
||||
}
|
||||
|
||||
.dc-badge--media {
|
||||
color: var(--gold);
|
||||
background: rgba(var(--gold-rgb), 0.18);
|
||||
}
|
||||
|
||||
.dc-badge--baixa {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface-raised);
|
||||
}
|
||||
|
||||
.dc-badge--tratado {
|
||||
color: #2f9e6e;
|
||||
background: rgba(47, 158, 110, 0.14);
|
||||
}
|
||||
|
||||
.dc-badge--ignorado {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface-raised);
|
||||
}
|
||||
|
||||
.dc-badge--pendente {
|
||||
color: var(--accent);
|
||||
background: rgba(var(--accent-rgb), 0.14);
|
||||
}
|
||||
@ -1,517 +0,0 @@
|
||||
const PID_DC_STATUS_LABELS = { revisao: "Em revisão", concluida: "Concluída" };
|
||||
const PID_DC_SEVERIDADE_LABELS = { alta: "Alta", media: "Média", baixa: "Baixa" };
|
||||
const PID_DC_ACHADO_STATUS_LABELS = { pendente: "Pendente", tratado: "Tratado", ignorado: "Ignorado" };
|
||||
|
||||
function pidDcFormatMoeda(valor) {
|
||||
const numero = Number(valor || 0);
|
||||
return numero.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
|
||||
}
|
||||
|
||||
function pidDcFormatCompetencia(iso) {
|
||||
if (!iso) return "";
|
||||
const [ano, mes] = iso.split("-");
|
||||
return `${mes}/${ano}`;
|
||||
}
|
||||
|
||||
function pidDcFormatData(iso) {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleDateString("pt-BR");
|
||||
}
|
||||
|
||||
function pidDcEscapeHtml(texto) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = texto == null ? "" : String(texto);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function pidFetchApuracoesContabil() {
|
||||
return pidApiRequest("/contabil-apuracoes/");
|
||||
}
|
||||
|
||||
async function pidFetchApuracaoContabil(id) {
|
||||
return pidApiRequest(`/contabil-apuracoes/${id}/`);
|
||||
}
|
||||
|
||||
async function pidCriarApuracaoContabil(formData) {
|
||||
return pidApiRequest("/contabil-apuracoes/", { method: "POST", body: formData });
|
||||
}
|
||||
|
||||
async function pidExcluirApuracaoContabil(id) {
|
||||
return pidApiRequest(`/contabil-apuracoes/${id}/`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async function pidConcluirApuracaoContabil(id) {
|
||||
return pidApiRequest(`/contabil-apuracoes/${id}/concluir/`, { method: "POST" });
|
||||
}
|
||||
|
||||
async function pidAtualizarObservacaoContaContabil(id, observacao) {
|
||||
return pidApiRequest(`/contabil-contas/${id}/`, { method: "PATCH", body: { observacao } });
|
||||
}
|
||||
|
||||
async function pidAtualizarObservacaoLinhaDreContabil(id, observacao) {
|
||||
return pidApiRequest(`/contabil-linhas-dre/${id}/`, { method: "PATCH", body: { observacao } });
|
||||
}
|
||||
|
||||
async function pidAtualizarAchadoContabil(id, status, observacaoContador) {
|
||||
return pidApiRequest(`/contabil-achados/${id}/`, {
|
||||
method: "PATCH",
|
||||
body: { status, observacao_contador: observacaoContador },
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const listView = document.getElementById("dc-list-view");
|
||||
if (!listView) return;
|
||||
|
||||
const me = await pidGetMe();
|
||||
if (!me) return;
|
||||
|
||||
const relatoriosPerms = (me.permissoes_efetivas && me.permissoes_efetivas["relatorios"]) || {};
|
||||
const canUse = !!(relatoriosPerms.enabled && relatoriosPerms.apps && relatoriosPerms.apps["dashboard-contabil"]);
|
||||
|
||||
const noAccessEl = document.getElementById("dc-no-access");
|
||||
const contentEl = document.getElementById("dc-content");
|
||||
if (noAccessEl) noAccessEl.hidden = canUse;
|
||||
if (contentEl) contentEl.hidden = !canUse;
|
||||
if (!canUse) return;
|
||||
|
||||
const formView = document.getElementById("dc-form-view");
|
||||
const reviewView = document.getElementById("dc-review-view");
|
||||
|
||||
let apuracaoAtual = null;
|
||||
let filtroSeveridade = "todas";
|
||||
let filtroStatusAchado = "pendente";
|
||||
let achadoEmEdicao = null;
|
||||
// Ids de ContabilConta com filhos recolhidos na árvore do Balancete —
|
||||
// reiniciado toda vez que uma apuração é aberta/criada (ver renderRevisao).
|
||||
let dcContasColapsadas = new Set();
|
||||
|
||||
function showView(view) {
|
||||
listView.hidden = view !== "list";
|
||||
formView.hidden = view !== "form";
|
||||
reviewView.hidden = view !== "review";
|
||||
}
|
||||
|
||||
// ---- Histórico ----
|
||||
|
||||
async function carregarLista() {
|
||||
const apuracoes = await pidFetchApuracoesContabil();
|
||||
const body = document.getElementById("dc-list-body");
|
||||
const empty = document.getElementById("dc-list-empty");
|
||||
body.innerHTML = "";
|
||||
empty.hidden = apuracoes.length > 0;
|
||||
|
||||
apuracoes.forEach((apuracao) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${pidDcEscapeHtml(apuracao.nome_empresa)}</td>
|
||||
<td>${pidDcFormatCompetencia(apuracao.competencia)}</td>
|
||||
<td>${apuracao.total_achados_pendentes}</td>
|
||||
<td><span class="status-pill ${apuracao.status === "concluida" ? "status-pill--ativo" : "status-pill--inativo"}">${PID_DC_STATUS_LABELS[apuracao.status] || apuracao.status}</span></td>
|
||||
<td>${pidDcEscapeHtml(apuracao.criado_por_nome || "-")}</td>
|
||||
<td>${pidDcFormatData(apuracao.criado_em)}</td>
|
||||
<td class="pa-row-actions">
|
||||
<button type="button" class="btn-outline" data-dc-abrir="${apuracao.id}">Abrir</button>
|
||||
<button type="button" class="icon-btn" data-dc-excluir="${apuracao.id}" aria-label="Excluir" title="Excluir">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("dc-list-body").addEventListener("click", async (event) => {
|
||||
const abrirBtn = event.target.closest("[data-dc-abrir]");
|
||||
if (abrirBtn) {
|
||||
await abrirRevisao(Number(abrirBtn.getAttribute("data-dc-abrir")));
|
||||
return;
|
||||
}
|
||||
const excluirBtn = event.target.closest("[data-dc-excluir]");
|
||||
if (excluirBtn) {
|
||||
if (!(await pidConfirm("Excluir esta análise? Essa ação não pode ser desfeita.", { perigoso: true }))) return;
|
||||
await pidExcluirApuracaoContabil(Number(excluirBtn.getAttribute("data-dc-excluir")));
|
||||
await carregarLista();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Nova Análise ----
|
||||
|
||||
function wireArquivoField(inputId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const nameEl = document.getElementById(`${inputId}-name`);
|
||||
const removeBtn = document.getElementById(`${inputId}-remove`);
|
||||
|
||||
function atualizar() {
|
||||
const arquivo = input.files[0];
|
||||
nameEl.textContent = arquivo ? arquivo.name : "Nenhum arquivo selecionado";
|
||||
removeBtn.hidden = !arquivo;
|
||||
}
|
||||
|
||||
input.addEventListener("change", atualizar);
|
||||
removeBtn.addEventListener("click", () => {
|
||||
input.value = "";
|
||||
atualizar();
|
||||
});
|
||||
|
||||
return atualizar;
|
||||
}
|
||||
|
||||
const resetArquivoField = wireArquivoField("dc-form-arquivo");
|
||||
|
||||
document.getElementById("dc-new-btn").addEventListener("click", () => {
|
||||
document.getElementById("dc-form-arquivo").value = "";
|
||||
resetArquivoField();
|
||||
document.getElementById("dc-form-error").textContent = "";
|
||||
showView("form");
|
||||
});
|
||||
|
||||
document.getElementById("dc-form-back-btn").addEventListener("click", () => showView("list"));
|
||||
document.getElementById("dc-form-cancel-btn").addEventListener("click", () => showView("list"));
|
||||
|
||||
document.getElementById("dc-form-submit-btn").addEventListener("click", async () => {
|
||||
const errorEl = document.getElementById("dc-form-error");
|
||||
errorEl.textContent = "";
|
||||
|
||||
const arquivo = document.getElementById("dc-form-arquivo").files[0];
|
||||
if (!arquivo) {
|
||||
errorEl.textContent = "Anexe o PDF de Balancete + DRE.";
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("arquivo", arquivo);
|
||||
|
||||
const submitBtn = document.getElementById("dc-form-submit-btn");
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const apuracao = await pidCriarApuracaoContabil(formData);
|
||||
apuracaoAtual = apuracao;
|
||||
filtroSeveridade = "todas";
|
||||
filtroStatusAchado = "pendente";
|
||||
renderRevisao();
|
||||
showView("review");
|
||||
await carregarLista();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Revisão ----
|
||||
|
||||
async function abrirRevisao(id) {
|
||||
apuracaoAtual = await pidFetchApuracaoContabil(id);
|
||||
filtroSeveridade = "todas";
|
||||
filtroStatusAchado = "pendente";
|
||||
renderRevisao();
|
||||
showView("review");
|
||||
}
|
||||
|
||||
function renderRevisao() {
|
||||
const apuracao = apuracaoAtual;
|
||||
const concluida = apuracao.status === "concluida";
|
||||
|
||||
document.getElementById("dc-review-empresa").textContent = `— ${apuracao.nome_empresa}`;
|
||||
document.getElementById("dc-review-subtitle").textContent =
|
||||
`CNPJ ${apuracao.cnpj || "não informado"} · Competência ${pidDcFormatCompetencia(apuracao.competencia)} · ${apuracao.contas.length} conta(s)`;
|
||||
document.getElementById("dc-concluida-aviso").hidden = !concluida;
|
||||
document.getElementById("dc-concluir-btn").hidden = concluida;
|
||||
|
||||
dcContasColapsadas = new Set();
|
||||
renderAchados();
|
||||
renderContas();
|
||||
renderDre();
|
||||
}
|
||||
|
||||
function achadosFiltrados() {
|
||||
return apuracaoAtual.achados.filter((achado) => {
|
||||
if (filtroSeveridade !== "todas" && achado.severidade !== filtroSeveridade) return false;
|
||||
if (filtroStatusAchado === "pendente" && achado.status !== "pendente") return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderAchados() {
|
||||
const lista = document.getElementById("dc-achados-list");
|
||||
const empty = document.getElementById("dc-achados-empty");
|
||||
const achados = achadosFiltrados();
|
||||
lista.innerHTML = "";
|
||||
empty.hidden = achados.length > 0;
|
||||
|
||||
achados.forEach((achado) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = `dc-achado-card dc-achado-card--${achado.severidade}`;
|
||||
const podeEditar = apuracaoAtual.status !== "concluida" && achado.status === "pendente";
|
||||
card.innerHTML = `
|
||||
<div class="dc-achado-card__main">
|
||||
<div class="dc-achado-card__titulo">
|
||||
<span class="dc-badge dc-badge--${achado.severidade}">${PID_DC_SEVERIDADE_LABELS[achado.severidade] || achado.severidade}</span>
|
||||
<span class="dc-badge dc-badge--${achado.status}">${PID_DC_ACHADO_STATUS_LABELS[achado.status] || achado.status}</span>
|
||||
${pidDcEscapeHtml(achado.titulo)}
|
||||
</div>
|
||||
<div class="dc-achado-card__mensagem">${pidDcEscapeHtml(achado.mensagem)}</div>
|
||||
${achado.observacao_contador ? `<div class="dc-achado-card__observacao">${pidDcEscapeHtml(achado.observacao_contador)}${achado.tratado_por_nome ? ` — ${pidDcEscapeHtml(achado.tratado_por_nome)}` : ""}</div>` : ""}
|
||||
</div>
|
||||
<div class="dc-achado-card__actions">
|
||||
${podeEditar ? `<button type="button" class="btn-outline" data-dc-achado-editar="${achado.id}">Revisar</button>` : ""}
|
||||
</div>
|
||||
`;
|
||||
lista.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("dc-achados-list").addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-dc-achado-editar]");
|
||||
if (!btn) return;
|
||||
abrirModalAchado(Number(btn.getAttribute("data-dc-achado-editar")));
|
||||
});
|
||||
|
||||
document.querySelectorAll("#dc-achados-severidade-chips [data-dc-severidade]").forEach((chip) => {
|
||||
chip.addEventListener("click", () => {
|
||||
filtroSeveridade = chip.getAttribute("data-dc-severidade");
|
||||
document.querySelectorAll("#dc-achados-severidade-chips .dc-chip").forEach((c) => c.classList.toggle("is-active", c === chip));
|
||||
renderAchados();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("#dc-achados-status-chips [data-dc-status]").forEach((chip) => {
|
||||
chip.addEventListener("click", () => {
|
||||
filtroStatusAchado = chip.getAttribute("data-dc-status");
|
||||
document.querySelectorAll("#dc-achados-status-chips .dc-chip").forEach((c) => c.classList.toggle("is-active", c === chip));
|
||||
renderAchados();
|
||||
});
|
||||
});
|
||||
|
||||
// O código da conta (ex. "1.01.01.002.001") já chega do backend na mesma
|
||||
// ordem hierárquica/sequencial do PDF (uma sintética seguida das suas
|
||||
// analíticas, ver portal_api/dashboard_contabil/CLAUDE.md) — o nível de
|
||||
// indentação vem direto da contagem de segmentos separados por ".", sem
|
||||
// precisar reconstruir uma árvore por id/parent (o mesmo código pode se
|
||||
// repetir em várias linhas irmãs, ex. três bancos diferentes sob o mesmo
|
||||
// "1.01.01.002.001", então não dá pra usar o código como chave única).
|
||||
function dcContaNivel(conta) {
|
||||
return conta.codigo.split(".").length - 1;
|
||||
}
|
||||
|
||||
function renderContas() {
|
||||
const body = document.getElementById("dc-contas-body");
|
||||
body.innerHTML = "";
|
||||
const contas = apuracaoAtual.contas;
|
||||
const niveis = contas.map(dcContaNivel);
|
||||
const temFilhos = contas.map((_, i) => i + 1 < contas.length && niveis[i + 1] > niveis[i]);
|
||||
|
||||
// Pilha dos níveis de ancestrais recolhidos — enquanto uma linha tiver
|
||||
// algum ancestral na pilha, ela fica oculta (é descendente de um grupo
|
||||
// fechado), mesmo padrão de renderização de árvore a partir de uma
|
||||
// lista plana com profundidade já conhecida.
|
||||
const pilhaColapso = [];
|
||||
contas.forEach((conta, i) => {
|
||||
const nivel = niveis[i];
|
||||
while (pilhaColapso.length && pilhaColapso[pilhaColapso.length - 1] >= nivel) {
|
||||
pilhaColapso.pop();
|
||||
}
|
||||
const oculta = pilhaColapso.length > 0;
|
||||
if (temFilhos[i] && dcContasColapsadas.has(conta.id)) {
|
||||
pilhaColapso.push(nivel);
|
||||
}
|
||||
if (oculta) return;
|
||||
|
||||
const expandida = !dcContasColapsadas.has(conta.id);
|
||||
const toggle = temFilhos[i]
|
||||
? `<button type="button" class="dc-conta-toggle${expandida ? " is-expanded" : ""}" data-dc-conta-toggle="${conta.id}" aria-label="${expandida ? "Recolher" : "Expandir"} ${pidDcEscapeHtml(conta.descricao)}">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>`
|
||||
: `<span class="dc-conta-toggle-spacer"></span>`;
|
||||
|
||||
const tr = document.createElement("tr");
|
||||
if (conta.tipo === "S") tr.classList.add("dc-conta-row--sintetica");
|
||||
tr.innerHTML = `
|
||||
<td>${pidDcEscapeHtml(conta.codigo)}</td>
|
||||
<td style="padding-left: calc(var(--space-4) + ${nivel * 18}px)">
|
||||
<span class="dc-conta-desc-cell">${toggle}<span>${pidDcEscapeHtml(conta.descricao)}</span></span>
|
||||
</td>
|
||||
<td>${pidDcFormatMoeda(conta.saldo_anterior)}</td>
|
||||
<td>${pidDcFormatMoeda(conta.debito)}</td>
|
||||
<td>${pidDcFormatMoeda(conta.credito)}</td>
|
||||
<td>${pidDcFormatMoeda(conta.saldo_atual)}</td>
|
||||
<td>
|
||||
<button type="button" class="dc-conta-observacao-btn" data-dc-conta-observacao="${conta.id}" title="${pidDcEscapeHtml(conta.observacao || "Adicionar observação")}">
|
||||
${conta.observacao ? pidDcEscapeHtml(conta.observacao) : "+ Observação"}
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("dc-contas-body").addEventListener("click", (event) => {
|
||||
const toggleBtn = event.target.closest("[data-dc-conta-toggle]");
|
||||
if (toggleBtn) {
|
||||
const id = Number(toggleBtn.getAttribute("data-dc-conta-toggle"));
|
||||
if (dcContasColapsadas.has(id)) dcContasColapsadas.delete(id);
|
||||
else dcContasColapsadas.add(id);
|
||||
renderContas();
|
||||
return;
|
||||
}
|
||||
const btn = event.target.closest("[data-dc-conta-observacao]");
|
||||
if (!btn) return;
|
||||
abrirModalObservacao("conta", Number(btn.getAttribute("data-dc-conta-observacao")));
|
||||
});
|
||||
|
||||
function renderDre() {
|
||||
const body = document.getElementById("dc-dre-body");
|
||||
body.innerHTML = "";
|
||||
apuracaoAtual.linhas_dre.forEach((linha) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = linha.totalizador ? "dc-dre-row dc-dre-row--totalizador" : "dc-dre-row";
|
||||
const indentacao = Math.max(0, linha.nivel) * 16;
|
||||
tr.innerHTML = `
|
||||
<td style="padding-left: calc(var(--space-4) + ${indentacao}px)">${pidDcEscapeHtml(linha.descricao)}</td>
|
||||
<td>${pidDcFormatMoeda(linha.valor)}</td>
|
||||
<td>
|
||||
<button type="button" class="dc-conta-observacao-btn" data-dc-dre-observacao="${linha.id}" title="${pidDcEscapeHtml(linha.observacao || "Adicionar observação")}">
|
||||
${linha.observacao ? pidDcEscapeHtml(linha.observacao) : "+ Observação"}
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("dc-dre-body").addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-dc-dre-observacao]");
|
||||
if (!btn) return;
|
||||
abrirModalObservacao("dre", Number(btn.getAttribute("data-dc-dre-observacao")));
|
||||
});
|
||||
|
||||
// ---- Tabs ----
|
||||
|
||||
document.getElementById("dc-tabs").addEventListener("click", (event) => {
|
||||
const tabBtn = event.target.closest("[data-dc-tab]");
|
||||
if (!tabBtn) return;
|
||||
const tab = tabBtn.getAttribute("data-dc-tab");
|
||||
document.querySelectorAll("#dc-tabs .pa-tab").forEach((t) => t.classList.toggle("is-active", t === tabBtn));
|
||||
document.querySelectorAll("[data-dc-panel]").forEach((panel) => {
|
||||
panel.hidden = panel.getAttribute("data-dc-panel") !== tab;
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Modal de observação (reaproveitado por conta do Balancete e linha da DRE) ----
|
||||
|
||||
const observacaoModal = document.getElementById("dc-observacao-modal");
|
||||
let observacaoEmEdicaoTipo = null; // "conta" | "dre"
|
||||
let observacaoEmEdicaoId = null;
|
||||
|
||||
function abrirModalObservacao(tipo, id) {
|
||||
const lista = tipo === "dre" ? apuracaoAtual.linhas_dre : apuracaoAtual.contas;
|
||||
const item = lista.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
observacaoEmEdicaoTipo = tipo;
|
||||
observacaoEmEdicaoId = id;
|
||||
document.getElementById("dc-observacao-modal-conta").textContent =
|
||||
tipo === "dre" ? item.descricao : `${item.codigo} — ${item.descricao}`;
|
||||
document.getElementById("dc-observacao-modal-texto").value = item.observacao || "";
|
||||
document.getElementById("dc-observacao-modal-error").textContent = "";
|
||||
document.getElementById("dc-observacao-modal-texto").disabled = apuracaoAtual.status === "concluida";
|
||||
document.getElementById("dc-observacao-modal-save-btn").hidden = apuracaoAtual.status === "concluida";
|
||||
observacaoModal.hidden = false;
|
||||
}
|
||||
|
||||
function fecharModalObservacao() {
|
||||
observacaoModal.hidden = true;
|
||||
observacaoEmEdicaoTipo = null;
|
||||
observacaoEmEdicaoId = null;
|
||||
}
|
||||
|
||||
document.getElementById("dc-observacao-modal-cancel-btn").addEventListener("click", fecharModalObservacao);
|
||||
observacaoModal.addEventListener("click", (event) => {
|
||||
if (event.target === observacaoModal) fecharModalObservacao();
|
||||
});
|
||||
|
||||
document.getElementById("dc-observacao-modal-save-btn").addEventListener("click", async () => {
|
||||
const errorEl = document.getElementById("dc-observacao-modal-error");
|
||||
errorEl.textContent = "";
|
||||
const texto = document.getElementById("dc-observacao-modal-texto").value;
|
||||
try {
|
||||
if (observacaoEmEdicaoTipo === "dre") {
|
||||
const linha = await pidAtualizarObservacaoLinhaDreContabil(observacaoEmEdicaoId, texto);
|
||||
const indice = apuracaoAtual.linhas_dre.findIndex((l) => l.id === observacaoEmEdicaoId);
|
||||
if (indice >= 0) apuracaoAtual.linhas_dre[indice] = linha;
|
||||
renderDre();
|
||||
} else {
|
||||
const conta = await pidAtualizarObservacaoContaContabil(observacaoEmEdicaoId, texto);
|
||||
const indice = apuracaoAtual.contas.findIndex((c) => c.id === observacaoEmEdicaoId);
|
||||
if (indice >= 0) apuracaoAtual.contas[indice] = conta;
|
||||
renderContas();
|
||||
}
|
||||
fecharModalObservacao();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Modal de achado (tratar/ignorar) ----
|
||||
|
||||
const achadoModal = document.getElementById("dc-achado-modal");
|
||||
|
||||
function abrirModalAchado(achadoId) {
|
||||
const achado = apuracaoAtual.achados.find((a) => a.id === achadoId);
|
||||
if (!achado) return;
|
||||
achadoEmEdicao = achadoId;
|
||||
document.getElementById("dc-achado-modal-titulo").textContent = achado.titulo;
|
||||
document.getElementById("dc-achado-modal-mensagem").textContent = achado.mensagem;
|
||||
document.getElementById("dc-achado-modal-texto").value = achado.observacao_contador || "";
|
||||
document.getElementById("dc-achado-modal-error").textContent = "";
|
||||
achadoModal.hidden = false;
|
||||
}
|
||||
|
||||
function fecharModalAchado() {
|
||||
achadoModal.hidden = true;
|
||||
achadoEmEdicao = null;
|
||||
}
|
||||
|
||||
document.getElementById("dc-achado-modal-cancel-btn").addEventListener("click", fecharModalAchado);
|
||||
achadoModal.addEventListener("click", (event) => {
|
||||
if (event.target === achadoModal) fecharModalAchado();
|
||||
});
|
||||
|
||||
async function salvarStatusAchado(novoStatus) {
|
||||
const errorEl = document.getElementById("dc-achado-modal-error");
|
||||
errorEl.textContent = "";
|
||||
const texto = document.getElementById("dc-achado-modal-texto").value.trim();
|
||||
if (!texto) {
|
||||
errorEl.textContent = "Informe uma justificativa para o tratamento desta observação.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const achado = await pidAtualizarAchadoContabil(achadoEmEdicao, novoStatus, texto);
|
||||
const indice = apuracaoAtual.achados.findIndex((a) => a.id === achadoEmEdicao);
|
||||
if (indice >= 0) apuracaoAtual.achados[indice] = achado;
|
||||
renderAchados();
|
||||
fecharModalAchado();
|
||||
await carregarLista();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("dc-achado-modal-tratar-btn").addEventListener("click", () => salvarStatusAchado("tratado"));
|
||||
document.getElementById("dc-achado-modal-ignorar-btn").addEventListener("click", () => salvarStatusAchado("ignorado"));
|
||||
|
||||
// ---- Concluir análise ----
|
||||
|
||||
document.getElementById("dc-concluir-btn").addEventListener("click", async () => {
|
||||
if (!(await pidConfirm("Concluir esta análise? Depois de concluída, as observações não podem mais ser editadas.")))
|
||||
return;
|
||||
apuracaoAtual = await pidConcluirApuracaoContabil(apuracaoAtual.id);
|
||||
renderRevisao();
|
||||
await carregarLista();
|
||||
});
|
||||
|
||||
document.getElementById("dc-review-back-btn").addEventListener("click", () => showView("list"));
|
||||
|
||||
await carregarLista();
|
||||
showView("list");
|
||||
});
|
||||
@ -127,20 +127,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Contabilidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="dashboard-contabil">
|
||||
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Dashboard Contábil
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
@ -128,20 +128,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Contabilidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="dashboard-contabil">
|
||||
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Dashboard Contábil
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
@ -127,20 +127,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Contabilidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="dashboard-contabil">
|
||||
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Dashboard Contábil
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
@ -1,633 +0,0 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>P.I.D. — Dashboard Contábil</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{% static 'img/pid-icone-escuro.svg' %}" />
|
||||
<link rel="stylesheet" href="{% static 'css/tokens.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'css/base.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'css/layout.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'css/components.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'css/perfis-acesso.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'css/dashboard-contabil.css' %}" />
|
||||
<script src="{% static 'js/theme.js' %}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell" id="app-shell">
|
||||
<aside class="sidebar">
|
||||
<a class="sidebar__brand" href="portal.html" id="sidebar-brand-link" aria-label="Ir para a tela Principal">
|
||||
<svg class="sidebar__logo sidebar__logo--full" viewBox="0 0 300 80" aria-hidden="true" focusable="false">
|
||||
<g transform="translate(8,8) scale(1)">
|
||||
<path d="M10 4 h34 l14 14 v40 a2 2 0 0 1 -2 2 H8 a2 2 0 0 1 -2 -2 V6 a2 2 0 0 1 2 -2 z" fill="#7B5BA8"></path>
|
||||
<rect x="22" y="4" width="20" height="16" fill="#C6A24A"></rect>
|
||||
<rect x="34" y="7" width="5" height="10" fill="#7B5BA8"></rect>
|
||||
<rect x="12" y="26" width="40" height="30" rx="2" fill="#241C33"></rect>
|
||||
<g class="pid-icon-eye">
|
||||
<circle cx="24" cy="38" r="5" fill="#F2EDE3"></circle>
|
||||
<circle cx="26" cy="36.2" r="1.6" fill="#241C33"></circle>
|
||||
</g>
|
||||
<g class="pid-icon-eye">
|
||||
<circle cx="40" cy="38" r="5" fill="#F2EDE3"></circle>
|
||||
<circle cx="42" cy="36.2" r="1.6" fill="#241C33"></circle>
|
||||
</g>
|
||||
<path d="M25 47 q7 6 14 0" stroke="#C6A24A" stroke-width="3" fill="none" stroke-linecap="round"></path>
|
||||
</g>
|
||||
<text x="86" y="42" font-family="'Space Grotesk',Helvetica,Arial,sans-serif" font-size="34" font-weight="700" letter-spacing="1.4" fill="#F2EDE3">P.I.D.</text>
|
||||
<text x="88" y="61" font-family="'DM Mono',ui-monospace,monospace" font-size="9.5" letter-spacing="2.6" fill="#C6A24A">PORTAL INTERNO DA DE PAULA</text>
|
||||
</svg>
|
||||
<svg class="sidebar__logo sidebar__logo--icon" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
|
||||
<path d="M10 4 h34 l14 14 v40 a2 2 0 0 1 -2 2 H8 a2 2 0 0 1 -2 -2 V6 a2 2 0 0 1 2 -2 z" fill="#7B5BA8"></path>
|
||||
<rect x="22" y="4" width="20" height="16" fill="#C6A24A"></rect>
|
||||
<rect x="34" y="7" width="5" height="10" fill="#7B5BA8"></rect>
|
||||
<rect x="12" y="26" width="40" height="30" rx="2" fill="#241C33"></rect>
|
||||
<g class="pid-icon-eye">
|
||||
<circle cx="24" cy="38" r="5" fill="#F2EDE3"></circle>
|
||||
<circle cx="26" cy="36.2" r="1.6" fill="#241C33"></circle>
|
||||
</g>
|
||||
<g class="pid-icon-eye">
|
||||
<circle cx="40" cy="38" r="5" fill="#F2EDE3"></circle>
|
||||
<circle cx="42" cy="36.2" r="1.6" fill="#241C33"></circle>
|
||||
</g>
|
||||
<path d="M25 47 q7 6 14 0" stroke="#C6A24A" stroke-width="3" fill="none" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<nav class="sidebar__nav">
|
||||
<ul class="nav-list">
|
||||
<li data-section="principal">
|
||||
<a href="portal.html" class="nav-item">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" stroke-linejoin="round"/></svg>
|
||||
<span class="nav-item__label">Principal</span>
|
||||
</a>
|
||||
</li>
|
||||
<li data-section="ramais">
|
||||
<a href="ramais.html" class="nav-item">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4c0 9 7 16 16 16l3-4-6-3-2 2c-3-1.5-5-3.5-6-6l2-2-3-6z" stroke-linejoin="round"/></svg>
|
||||
<span class="nav-item__label">Ramais</span>
|
||||
</a>
|
||||
</li>
|
||||
<li data-section="calendario">
|
||||
<a href="#" class="nav-item" id="calendario-depaula-btn">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 9h18M8 3v4M16 3v4" stroke-linecap="round"/></svg>
|
||||
<span class="nav-item__label">Calendário De Paula</span>
|
||||
</a>
|
||||
</li>
|
||||
<li data-section="calendario-individual">
|
||||
<a href="calendario-individual.html" class="nav-item">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 9h18M8 3v4M16 3v4" stroke-linecap="round"/><path d="M9 15l2 2 4-4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span class="nav-item__label">Calendário Individual</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li data-section="portais" class="nav-group">
|
||||
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
<span class="nav-item__label">Portais</span>
|
||||
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-group__list">
|
||||
<li data-app="portal-do-cliente"><a href="#" class="nav-subitem">Portal do Cliente</a></li>
|
||||
<li data-app="portal-fiscal"><a href="#" class="nav-subitem">Portal Fiscal</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li data-section="geradoc" class="nav-group">
|
||||
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v4h4" stroke-linejoin="round"/></svg>
|
||||
<span class="nav-item__label">Geradoc</span>
|
||||
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-group__list">
|
||||
<li data-app="simulacao-custo-contratacao"><a href="custo-contratacao.html" class="nav-subitem">Simulação de Custo de Contratação</a></li>
|
||||
<li data-app="indicador-desempenho"><a href="indicador-desempenho.html" class="nav-subitem">Indicador de Desempenho</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li data-section="relatorios" class="nav-group">
|
||||
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20V10M12 20V4M20 20v-7" stroke-linecap="round"/></svg>
|
||||
<span class="nav-item__label">Relatórios</span>
|
||||
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-group__list">
|
||||
<li data-app="relatorio-setorial"><a href="#" class="nav-subitem">Relatório Setorial</a></li>
|
||||
<li data-app="indicadores"><a href="#" class="nav-subitem">Indicadores</a></li>
|
||||
<li class="nav-subgroup" data-subgroup="qualidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Qualidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="nao-conformidades">
|
||||
<a href="nao-conformidades.html" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Não Conformidades
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Contabilidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="dashboard-contabil">
|
||||
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool is-active">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Dashboard Contábil
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li data-section="relatorios-gerenciais" class="nav-group">
|
||||
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18M7 15l4-5 3 3 5-7" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span class="nav-item__label">Relatórios Gerenciais</span>
|
||||
<span class="badge-restricted" data-restricted-badge hidden>Restrito</span>
|
||||
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-group__list">
|
||||
<li data-app="visao-diretoria"><a href="#" class="nav-subitem">Visão Diretoria</a></li>
|
||||
<li data-app="visao-gerencial"><a href="#" class="nav-subitem">Visão Gerencial</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li data-section="utilitarios" class="nav-group">
|
||||
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 7l3 3-8 8-4 1 1-4z" stroke-linejoin="round"/><path d="M12 9l3 3" stroke-linecap="round"/></svg>
|
||||
<span class="nav-item__label">Utilitários</span>
|
||||
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-group__list">
|
||||
<li data-app="importacao-plano-saude"><a href="importacao-plano-saude.html" class="nav-subitem">Importação de Plano de Saúde</a></li>
|
||||
<li data-app="importacao-plano-saude-de-paula"><a href="importacao-plano-saude-de-paula.html" class="nav-subitem">Importação de Plano de Saúde - De Paula</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li data-section="integracoes" class="nav-group">
|
||||
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="12" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M8.6 10.6L15.4 7.4M8.6 13.4l6.8 3.2" stroke-linecap="round"/></svg>
|
||||
<span class="nav-item__label">Integrações</span>
|
||||
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-group__list">
|
||||
<li data-app="questor"><a href="#" class="nav-subitem">Questor</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li data-section="auditorias" class="nav-group">
|
||||
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l7 4v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V7z" stroke-linejoin="round"/><path d="M9 12l2 2 4-4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span class="nav-item__label">Auditorias</span>
|
||||
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-group__list">
|
||||
<li class="nav-subgroup" data-subgroup="consultoria-tributaria">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Consultoria Tributária</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="controle-simples-nacional">
|
||||
<a href="#" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Controle Simples Nacional
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="fisco-contabil">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Fisco/Contábil</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="auditoria-de-balancetes">
|
||||
<a href="#" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Auditoria de Balancetes
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li data-section="links-ferramentas" class="nav-group">
|
||||
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>
|
||||
<span class="nav-item__label">Links & Ferramentas</span>
|
||||
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-group__list">
|
||||
<li data-app="links-ferramentas-visualizar"><a href="links-ferramentas.html" class="nav-subitem">Links & Ferramentas</a></li>
|
||||
<li data-app="acessos-gerais-visualizar"><a href="acessos-gerais.html" class="nav-subitem">Acessos Gerais</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li data-section="solicitacoes" class="nav-group">
|
||||
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 10h.01M12 10h.01M16 10h.01M21 12a9 9 0 1 1-4.4-7.7L21 3l-1.2 4.5A8.96 8.96 0 0 1 21 12z"/></svg>
|
||||
<span class="nav-item__label">Solicitações</span>
|
||||
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-group__list">
|
||||
<li data-app="controle-notificacoes"><a href="https://form.asana.com/?k=PcELrDLzN969CwpiRBLtFA&d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">Controle de Notificações</a></li>
|
||||
<li data-app="ordem-servico"><a href="#" class="nav-subitem">Ordem de Serviço</a></li>
|
||||
<li data-app="pimi"><a href="https://form.asana.com/?k=XfBww25lGUyPVrInNcLyuQ&d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">P.I.M.I.</a></li>
|
||||
<li data-app="programa-indicacoes"><a href="https://form.asana.com/?k=y0mTeaRTOAoC0r0XP8ee0Q&d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">Programa de Indicações</a></li>
|
||||
<li data-app="pos-vendas"><a href="https://form.asana.com/?k=jRAztLip-_BknWPsGmEcZA&d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">Pós-Vendas</a></li>
|
||||
<li data-app="sugestao-inovacao"><a href="https://form.asana.com/?k=Zn8cLeT97R7FTUrsNB8Yaw&d=1207308016523218" target="_blank" rel="noopener noreferrer" class="nav-subitem">Sugestão de Inovação</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li data-section="administracao" class="nav-group">
|
||||
<button type="button" class="nav-item nav-group__toggle" data-group-toggle>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" stroke-linejoin="round"/></svg>
|
||||
<span class="nav-item__label">Administração</span>
|
||||
<svg class="nav-item__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-group__list">
|
||||
<li data-section="perfis-acesso"><a href="perfis-acesso.html" class="nav-subitem">Perfis de Acesso</a></li>
|
||||
<li data-section="usuarios"><a href="usuarios.html" class="nav-subitem">Usuários</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
</aside>
|
||||
|
||||
<div class="main-content">
|
||||
<header class="topbar">
|
||||
<button type="button" class="icon-btn" id="sidebar-toggle" aria-label="Alternar menu">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<h1 class="topbar__title">Dashboard Contábil</h1>
|
||||
|
||||
<div class="topbar__actions">
|
||||
<button type="button" class="icon-btn" id="theme-toggle" aria-label="Ativar modo claro">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="notifications">
|
||||
<button type="button" class="icon-btn" id="notif-bell" aria-label="Notificações">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6" stroke-linejoin="round"/><path d="M9.5 20a2.5 2.5 0 0 0 5 0"/></svg>
|
||||
<span class="notif-badge" id="notif-badge" hidden>0</span>
|
||||
</button>
|
||||
<div class="notif-dropdown" id="notif-dropdown" hidden>
|
||||
<div class="notif-dropdown__header">
|
||||
<span>Notificações</span>
|
||||
<div class="notif-dropdown__header-actions">
|
||||
<button type="button" id="notif-history-btn">Histórico</button>
|
||||
<button type="button" id="notif-clear-all">Limpar tudo</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="notif-list" id="notif-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="account-menu">
|
||||
<button type="button" class="persona-chip" id="persona-chip">
|
||||
<span class="persona-chip__icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
</span>
|
||||
<span id="persona-chip-label">Usuário</span>
|
||||
</button>
|
||||
<div class="account-dropdown" id="account-dropdown" hidden>
|
||||
<div class="account-dropdown__header">
|
||||
<p class="account-dropdown__name" id="account-name"></p>
|
||||
<p class="account-dropdown__profile" id="account-profile"></p>
|
||||
</div>
|
||||
<button type="button" class="account-dropdown__item" id="account-manage-account-btn">Gerenciar Usuário</button>
|
||||
<a href="perfis-acesso.html" class="account-dropdown__item" id="account-manage-link" hidden>Gerenciar Perfis de Acesso</a>
|
||||
<div class="account-dropdown__section">
|
||||
<p class="account-dropdown__section-title">Tema de cores</p>
|
||||
<div class="theme-swatches" id="theme-swatches"></div>
|
||||
</div>
|
||||
<button type="button" class="account-dropdown__item account-dropdown__logout" data-logout>Sair</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page-content page-content--wide">
|
||||
<div id="dc-no-access" class="no-access" hidden>
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<h2>Sem acesso ao Dashboard Contábil</h2>
|
||||
<p>Seu perfil de acesso não tem permissão para visualizar esta tela. Solicite à Inovação a liberação em Perfis de Acesso.</p>
|
||||
</div>
|
||||
|
||||
<div id="dc-content">
|
||||
<div id="dc-list-view">
|
||||
<div class="dc-header">
|
||||
<div>
|
||||
<h2>Dashboard Contábil</h2>
|
||||
<p class="dc-subtitle">Anexe o balancete + DRE em PDF (modelo Questor) de uma empresa — a ferramenta extrai as contas, roda um conjunto de checagens automáticas e apresenta as observações para análise antes da conclusão.</p>
|
||||
</div>
|
||||
<button type="button" class="btn-solid" id="dc-new-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14" stroke-linecap="round"/></svg>
|
||||
Nova Análise
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pa-table-wrap">
|
||||
<table class="pa-table" id="dc-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Empresa</th>
|
||||
<th>Competência</th>
|
||||
<th>Observações pendentes</th>
|
||||
<th>Status</th>
|
||||
<th>Criado por</th>
|
||||
<th>Data</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dc-list-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="dc-empty" id="dc-list-empty" hidden>Nenhuma análise realizada ainda.</p>
|
||||
</div>
|
||||
|
||||
<div id="dc-form-view" hidden>
|
||||
<div class="dc-header">
|
||||
<div>
|
||||
<h2>Nova Análise</h2>
|
||||
<p class="dc-subtitle">Anexe o PDF de Balancete + DRE (mesmo relatório hoje enviado ao cliente) — empresa, CNPJ e competência são identificados automaticamente a partir do próprio arquivo.</p>
|
||||
</div>
|
||||
<button type="button" class="btn-outline" id="dc-form-back-btn">Voltar</button>
|
||||
</div>
|
||||
|
||||
<div class="dc-form">
|
||||
<div class="modal-field">
|
||||
<label>Balancete + DRE (PDF)</label>
|
||||
<div class="dc-file-field">
|
||||
<label class="btn-outline dc-file-field__btn" for="dc-form-arquivo">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M12 4l4 4M12 4L8 8" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Selecionar arquivo
|
||||
</label>
|
||||
<input type="file" id="dc-form-arquivo" accept=".pdf" class="dc-file-field__input" />
|
||||
<span class="dc-file-field__name" id="dc-form-arquivo-name">Nenhum arquivo selecionado</span>
|
||||
<button type="button" class="icon-btn dc-file-field__remove" id="dc-form-arquivo-remove" aria-label="Remover arquivo" title="Remover arquivo" hidden>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="modal-error" id="dc-form-error"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-outline" id="dc-form-cancel-btn">Cancelar</button>
|
||||
<button type="button" class="btn-solid" id="dc-form-submit-btn">Processar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dc-review-view" hidden>
|
||||
<div class="dc-header">
|
||||
<div>
|
||||
<h2>Revisão <span class="dc-review-empresa" id="dc-review-empresa"></span></h2>
|
||||
<p class="dc-subtitle" id="dc-review-subtitle"></p>
|
||||
</div>
|
||||
<div class="dc-header-actions">
|
||||
<button type="button" class="btn-outline" id="dc-review-back-btn">Voltar</button>
|
||||
<button type="button" class="btn-outline" id="dc-gerar-dashboard-btn" disabled title="Em desenvolvimento">Gerar Dashboard HTML</button>
|
||||
<button type="button" class="btn-solid" id="dc-concluir-btn">Concluir Análise</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="dc-concluida-aviso" id="dc-concluida-aviso" hidden>Esta análise já foi concluída — as observações não podem mais ser editadas.</p>
|
||||
|
||||
<div class="pa-tabs" id="dc-tabs">
|
||||
<button type="button" class="pa-tab is-active" data-dc-tab="achados">Observações</button>
|
||||
<button type="button" class="pa-tab" data-dc-tab="balancete">Balancete</button>
|
||||
<button type="button" class="pa-tab" data-dc-tab="dre">DRE</button>
|
||||
</div>
|
||||
|
||||
<div class="pa-tab-panel" data-dc-panel="achados">
|
||||
<div class="dc-achados-toolbar">
|
||||
<div class="dc-chip-row" id="dc-achados-severidade-chips">
|
||||
<button type="button" class="dc-chip is-active" data-dc-severidade="todas">Todas</button>
|
||||
<button type="button" class="dc-chip" data-dc-severidade="alta">Alta</button>
|
||||
<button type="button" class="dc-chip" data-dc-severidade="media">Média</button>
|
||||
<button type="button" class="dc-chip" data-dc-severidade="baixa">Baixa</button>
|
||||
</div>
|
||||
<div class="dc-chip-row" id="dc-achados-status-chips">
|
||||
<button type="button" class="dc-chip is-active" data-dc-status="pendente">Pendentes</button>
|
||||
<button type="button" class="dc-chip" data-dc-status="todos">Todos</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dc-achados-list" id="dc-achados-list"></div>
|
||||
<p class="dc-empty" id="dc-achados-empty" hidden>Nenhuma observação para este filtro.</p>
|
||||
</div>
|
||||
|
||||
<div class="pa-tab-panel" data-dc-panel="balancete" hidden>
|
||||
<div class="pa-table-wrap">
|
||||
<table class="pa-table dc-contas-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Conta</th>
|
||||
<th>Descrição</th>
|
||||
<th>Saldo Anterior</th>
|
||||
<th>Débito</th>
|
||||
<th>Crédito</th>
|
||||
<th>Saldo Atual</th>
|
||||
<th>Observação</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dc-contas-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pa-tab-panel" data-dc-panel="dre" hidden>
|
||||
<div class="pa-table-wrap">
|
||||
<table class="pa-table dc-dre-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Descrição</th>
|
||||
<th>Valor</th>
|
||||
<th>Observação</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dc-dre-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="dc-observacao-modal" hidden>
|
||||
<div class="modal-card">
|
||||
<h2 class="modal-card__title">Observação</h2>
|
||||
<p class="modal-card__subtitle" id="dc-observacao-modal-conta"></p>
|
||||
<div class="modal-field">
|
||||
<textarea id="dc-observacao-modal-texto" rows="6" placeholder="Anote sua análise..."></textarea>
|
||||
</div>
|
||||
<p class="modal-error" id="dc-observacao-modal-error"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-outline" id="dc-observacao-modal-cancel-btn">Cancelar</button>
|
||||
<button type="button" class="btn-solid" id="dc-observacao-modal-save-btn">Salvar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="dc-achado-modal" hidden>
|
||||
<div class="modal-card">
|
||||
<h2 class="modal-card__title" id="dc-achado-modal-titulo"></h2>
|
||||
<p class="modal-card__subtitle" id="dc-achado-modal-mensagem"></p>
|
||||
<div class="modal-field">
|
||||
<label for="dc-achado-modal-texto">Observação (obrigatória)</label>
|
||||
<textarea id="dc-achado-modal-texto" rows="5" placeholder="Justifique o tratamento desta observação..."></textarea>
|
||||
</div>
|
||||
<p class="modal-error" id="dc-achado-modal-error"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-outline" id="dc-achado-modal-cancel-btn">Cancelar</button>
|
||||
<button type="button" class="btn-danger-outline" id="dc-achado-modal-ignorar-btn">Ignorar</button>
|
||||
<button type="button" class="btn-solid" id="dc-achado-modal-tratar-btn">Marcar como tratado</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="manage-account-modal" hidden>
|
||||
<div class="modal-card" id="manage-account-modal-card">
|
||||
<h2 class="modal-card__title">Gerenciar Usuário</h2>
|
||||
<div class="modal-field">
|
||||
<button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button>
|
||||
</div>
|
||||
<div class="modal-field" id="manage-account-liderados-field" hidden>
|
||||
<label>Usuários sob sua liderança</label>
|
||||
<div class="dual-select">
|
||||
<div class="dual-select__panel">
|
||||
<div class="dual-select__panel-header">
|
||||
<button type="button" class="btn-solid" id="manage-account-liderados-available-link-btn" disabled>Vincular</button>
|
||||
</div>
|
||||
<div class="dual-select__table-wrap">
|
||||
<table class="dual-select__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="manage-account-liderados-available-select-all" aria-label="Selecionar todos" /></th>
|
||||
<th class="dual-select__sort" id="manage-account-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
|
||||
<th>Departamento</th>
|
||||
</tr>
|
||||
<tr class="dual-select__filters">
|
||||
<th></th>
|
||||
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
|
||||
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manage-account-liderados-available-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dual-select__footer" id="manage-account-liderados-available-footer"></div>
|
||||
</div>
|
||||
|
||||
<div class="dual-select__panel">
|
||||
<div class="dual-select__panel-header">
|
||||
<button type="button" class="btn-danger-outline" id="manage-account-liderados-linked-unlink-btn" disabled>Desvincular</button>
|
||||
</div>
|
||||
<div class="dual-select__table-wrap">
|
||||
<table class="dual-select__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="manage-account-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
|
||||
<th class="dual-select__sort" id="manage-account-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
|
||||
<th>Departamento</th>
|
||||
</tr>
|
||||
<tr class="dual-select__filters">
|
||||
<th></th>
|
||||
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
|
||||
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manage-account-liderados-linked-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dual-select__footer" id="manage-account-liderados-linked-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="modal-error" id="manage-account-liderados-error"></p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-outline" id="manage-account-close-btn">Fechar</button>
|
||||
<button type="button" class="btn-solid" id="manage-account-save-btn" hidden>Salvar liderados</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="password-modal" hidden>
|
||||
<div class="modal-card">
|
||||
<h2 class="modal-card__title">Alterar senha</h2>
|
||||
<div class="modal-field">
|
||||
<label for="pwd-current">Senha atual</label>
|
||||
<div class="password-field">
|
||||
<input type="password" id="pwd-current" autocomplete="current-password" />
|
||||
<button type="button" class="password-field__toggle" id="pwd-current-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label for="pwd-new">Nova senha</label>
|
||||
<div class="password-field">
|
||||
<input type="password" id="pwd-new" autocomplete="new-password" />
|
||||
<button type="button" class="password-field__toggle" id="pwd-new-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label for="pwd-confirm">Confirmar nova senha</label>
|
||||
<div class="password-field">
|
||||
<input type="password" id="pwd-confirm" autocomplete="new-password" />
|
||||
<button type="button" class="password-field__toggle" id="pwd-confirm-toggle" aria-label="Mostrar senha"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="modal-error" id="pwd-error"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-outline" id="pwd-cancel-btn">Cancelar</button>
|
||||
<button type="button" class="btn-solid" id="pwd-save-btn">Salvar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="notif-history-modal" hidden>
|
||||
<div class="modal-card">
|
||||
<h2 class="modal-card__title">Histórico de Notificações</h2>
|
||||
<ul class="notif-list" id="notif-history-list"></ul>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-outline" id="notif-history-close-btn">Fechar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="calendario-depaula-modal" hidden>
|
||||
<div class="modal-card iframe-modal-card">
|
||||
<div class="iframe-modal-header">
|
||||
<h2 class="modal-card__title">Calendário De Paula</h2>
|
||||
<button type="button" class="icon-btn" id="calendario-depaula-close-btn" aria-label="Fechar">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<iframe id="calendario-depaula-iframe" class="iframe-modal-iframe" title="Calendário De Paula"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{% static 'js/api.js' %}"></script>
|
||||
<script src="{% static 'js/confirm-modal.js' %}"></script>
|
||||
<script src="{% static 'js/sidebar-brand.js' %}"></script>
|
||||
<script src="{% static 'js/dual-select.js' %}"></script>
|
||||
<script src="{% static 'js/profiles.js' %}"></script>
|
||||
<script src="{% static 'js/auth.js' %}"></script>
|
||||
<script src="{% static 'js/access.js' %}"></script>
|
||||
<script src="{% static 'js/account.js' %}"></script>
|
||||
<script src="{% static 'js/favorites.js' %}"></script>
|
||||
<script src="{% static 'js/events.js' %}"></script>
|
||||
<script src="{% static 'js/sidebar.js' %}"></script>
|
||||
<script src="{% static 'js/notifications.js' %}"></script>
|
||||
<script src="{% static 'js/dashboard-contabil.js' %}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -128,20 +128,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Contabilidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="dashboard-contabil">
|
||||
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Dashboard Contábil
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
@ -128,20 +128,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Contabilidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="dashboard-contabil">
|
||||
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Dashboard Contábil
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
@ -128,20 +128,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Contabilidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="dashboard-contabil">
|
||||
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Dashboard Contábil
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
@ -128,20 +128,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Contabilidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="dashboard-contabil">
|
||||
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Dashboard Contábil
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
@ -128,20 +128,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Contabilidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="dashboard-contabil">
|
||||
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Dashboard Contábil
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
@ -127,20 +127,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Contabilidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="dashboard-contabil">
|
||||
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Dashboard Contábil
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
@ -129,20 +129,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Contabilidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="dashboard-contabil">
|
||||
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Dashboard Contábil
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
@ -128,20 +128,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Contabilidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="dashboard-contabil">
|
||||
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Dashboard Contábil
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
@ -127,20 +127,6 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-subgroup" data-subgroup="contabilidade">
|
||||
<button type="button" class="nav-subitem nav-subgroup__toggle" data-subgroup-toggle>
|
||||
<span>Contabilidade</span>
|
||||
<svg class="nav-item__chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<ul class="nav-subgroup__list">
|
||||
<li data-app="dashboard-contabil">
|
||||
<a href="dashboard-contabil.html" class="nav-subitem nav-subitem--tool">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h9l3 3v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M14 2v4h4" stroke-linejoin="round"/></svg>
|
||||
Dashboard Contábil
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user