feat(sgmp): API REST, app Next.js, ConfiguracaoSGMP e ajustes de permissões/serviços
- API JSON (auth, dashboard, colaboradores, solicitações) e app Next em frontend/ - Modelo ConfiguracaoSGMP, migrações e permissões (acesso, decorators, context) - Serviços/views/templates e integrações Winthor/SQL Server - Docs: MIGRACAO, ARQUITETURA_APROVACAO, README_PERMISSOES; Dockerfile/requirements - Testes: fluxo de desligamento alinhado a pareceres GG/Ctrl + Diretoria; criar_solicitacao_desligamento com tipo/aviso Made-with: Cursor
This commit is contained in:
parent
542b9d03ca
commit
81c3dfab1e
|
|
@ -11,15 +11,17 @@ Status: RASCUNHO
|
||||||
↓
|
↓
|
||||||
GESTOR envia para aprovação
|
GESTOR envia para aprovação
|
||||||
↓
|
↓
|
||||||
|
Status: AGUARDANDO_HEAD (quando há etapa de Head)
|
||||||
|
↓
|
||||||
|
HEAD analisa e APROVA/REPROVA (limitado aos gestores sob sua responsabilidade)
|
||||||
|
↓
|
||||||
Status: ENVIADA
|
Status: ENVIADA
|
||||||
↓
|
↓
|
||||||
GG analisa e APROVA/REPROVA
|
GG registra PARECER (não aprova/reprova)
|
||||||
↓
|
↓
|
||||||
Se aprovada → Status: APROVADA_GG
|
CONTROLADORIA registra PARECER (não aprova/reprova)
|
||||||
↓
|
↓
|
||||||
CONTROLADORIA analisa e APROVA/REPROVA
|
Status: AGUARDANDO_DIRETORIA
|
||||||
↓
|
|
||||||
Se aprovada → Status: APROVADA_CONTROLADORIA
|
|
||||||
↓
|
↓
|
||||||
DIRETORIA analisa e APROVA/REPROVA
|
DIRETORIA analisa e APROVA/REPROVA
|
||||||
↓
|
↓
|
||||||
|
|
@ -32,34 +34,57 @@ Se reprovada → Status: REPROVADA
|
||||||
#### 1. Modelos (`models.py`)
|
#### 1. Modelos (`models.py`)
|
||||||
|
|
||||||
- **`Solicitacao`**: Entidade central
|
- **`Solicitacao`**: Entidade central
|
||||||
- Método `etapa_atual()`: Retorna a etapa atual baseada no status
|
- Método `etapa_atual()`: Retorna a etapa atual baseada no status.
|
||||||
- Método `pode_aprovar(usuario)`: Verifica se o perfil do usuário corresponde à etapa atual
|
- Método `pode_aprovar(usuario)`: Verifica se o usuário pode aprovar na etapa atual (Head ou Diretoria).
|
||||||
|
- Método `pode_dar_parecer(usuario)`: Verifica se GG/Controladoria podem registrar parecer.
|
||||||
|
|
||||||
|
- **`Parecer`**: Registro de parecer técnico
|
||||||
|
- Emitido por GG ou CONTROLADORIA.
|
||||||
|
- Não altera o status diretamente; quando existem pareceres das duas etapas, a solicitação avança para `AGUARDANDO_DIRETORIA`.
|
||||||
|
|
||||||
- **`Aprovacao`**: Registra cada decisão (aprovar/reprovar) em cada etapa
|
- **`Aprovacao`**: Registra cada decisão (aprovar/reprovar) em cada etapa
|
||||||
- Campos: `solicitacao`, `etapa`, `decisao`, `usuario`, `justificativa`, `decidido_em`
|
- Campos: `solicitacao`, `etapa`, `decisao`, `usuario`, `justificativa`, `decidido_em`
|
||||||
- Unique constraint: `(solicitacao, etapa)` - garante uma aprovação por etapa
|
- Unique constraint: `(solicitacao, etapa)` - garante uma aprovação por etapa
|
||||||
|
|
||||||
- **`StatusSolicitacao`**: Enum com os status possíveis
|
- **`StatusSolicitacao`**: Enum com os status possíveis
|
||||||
- `RASCUNHO`, `ENVIADA`, `APROVADA_GG`, `APROVADA_CONTROLADORIA`, `APROVADA_DIRETORIA`, `FINALIZADA`, `REPROVADA`
|
- `RASCUNHO`, `AGUARDANDO_HEAD`, `ENVIADA`, `APROVADA_GG`, `APROVADA_CONTROLADORIA`, `APROVADA_DIRETORIA`, `AGUARDANDO_DIRETORIA`, `FINALIZADA`, `REPROVADA`
|
||||||
|
|
||||||
- **`EtapaAprovacao`**: Enum com as etapas
|
- **`EtapaAprovacao`**: Enum com as etapas
|
||||||
- `GG`, `CONTROLADORIA`, `DIRETORIA`
|
- `HEAD`, `GG`, `CONTROLADORIA`, `DIRETORIA`
|
||||||
|
|
||||||
|
- **`UsuarioSistema` e `UsuarioPerfilExtra`**:
|
||||||
|
- `UsuarioSistema.perfil` define o perfil principal.
|
||||||
|
- `UsuarioPerfilExtra` permite atribuir perfis adicionais (multi-perfis).
|
||||||
|
- Métodos como `tem_perfil` e `perfis_ativos` centralizam a checagem de perfis.
|
||||||
|
|
||||||
|
- **`HeadGestor`**:
|
||||||
|
- Vínculo Head → Gestores: define para quais gestores um Head pode aprovar solicitações.
|
||||||
|
|
||||||
#### 2. Services (`services.py`)
|
#### 2. Services (`services.py`)
|
||||||
|
|
||||||
- **`aprovar_reprovar_solicitacao()`**: Função principal que:
|
- **`aprovar_reprovar_por_head()`** – decisão da etapa HEAD:
|
||||||
1. Valida se a solicitação está em uma etapa válida
|
1. Valida se a solicitação está em `AGUARDANDO_HEAD`.
|
||||||
2. Valida se o perfil do usuário corresponde à etapa atual
|
2. Valida se o usuário tem perfil de Head (`tem_perfil('HEAD')`) e está vinculado como head do gestor solicitante.
|
||||||
3. Cria registro de `Aprovacao`
|
3. Cria `Aprovacao` na etapa HEAD.
|
||||||
4. Atualiza o status da solicitação conforme a decisão
|
4. Atualiza o status para a próxima etapa (tipicamente `ENVIADA`) ou `REPROVADA`.
|
||||||
5. Se reprovado, finaliza a solicitação
|
|
||||||
6. Se aprovado, avança para o próximo status
|
- **`registrar_parecer()`** – registro de parecer técnico (GG / Controladoria):
|
||||||
|
1. Valida se a solicitação está em `ENVIADA` e se o usuário pode dar parecer.
|
||||||
|
2. Cria `Parecer` para a etapa correspondente (GG ou CONTROLADORIA).
|
||||||
|
3. Quando existem pareceres das duas etapas, atualiza status para `AGUARDANDO_DIRETORIA`.
|
||||||
|
|
||||||
|
- **`aprovar_reprovar_solicitacao()`** – decisão final da Diretoria:
|
||||||
|
1. Valida se a solicitação está em `AGUARDANDO_DIRETORIA`.
|
||||||
|
2. Valida se o usuário tem perfil de Diretoria.
|
||||||
|
3. Cria `Aprovacao` na etapa DIRETORIA.
|
||||||
|
4. Atualiza o status para `FINALIZADA` ou `REPROVADA`.
|
||||||
|
|
||||||
#### 3. Views (`views.py`)
|
#### 3. Views (`views.py`)
|
||||||
|
|
||||||
- **`decidir_solicitacao()`**: View que recebe POST com decisão e justificativa
|
- **`decidir_solicitacao()`**: View que recebe POST com decisão e justificativa
|
||||||
- Decorator: `@requer_perfil(GG, CONTROLADORIA, DIRETORIA)`
|
- Decorator: `@requer_perfil(HEAD, DIRETORIA)`
|
||||||
- Chama `aprovar_reprovar_solicitacao()`
|
- Para status `AGUARDANDO_HEAD`, chama `aprovar_reprovar_por_head()`
|
||||||
|
- Para status `AGUARDANDO_DIRETORIA`, chama `aprovar_reprovar_solicitacao()`
|
||||||
|
|
||||||
- **`solicitacao_detalhe()`**: Exibe detalhes e botões de aprovação
|
- **`solicitacao_detalhe()`**: Exibe detalhes e botões de aprovação
|
||||||
- Calcula `pode_aprovar` usando `solicitacao.pode_aprovar(usuario)`
|
- Calcula `pode_aprovar` usando `solicitacao.pode_aprovar(usuario)`
|
||||||
|
|
@ -73,9 +98,9 @@ Se reprovada → Status: REPROVADA
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔄 Nova Arquitetura Proposta
|
## 🔄 Arquitetura Detalhada (pareceres + Diretoria)
|
||||||
|
|
||||||
### Novo Fluxo
|
### Fluxo com Head, pareceres e Diretoria
|
||||||
|
|
||||||
```
|
```
|
||||||
GESTOR cria solicitação
|
GESTOR cria solicitação
|
||||||
|
|
@ -84,6 +109,10 @@ Status: RASCUNHO
|
||||||
↓
|
↓
|
||||||
GESTOR envia para aprovação
|
GESTOR envia para aprovação
|
||||||
↓
|
↓
|
||||||
|
Status: AGUARDANDO_HEAD (quando aplicável; senão vai direto para ENVIADA)
|
||||||
|
↓
|
||||||
|
HEAD aprova/reprova (etapa HEAD)
|
||||||
|
↓
|
||||||
Status: ENVIADA
|
Status: ENVIADA
|
||||||
↓
|
↓
|
||||||
GG registra PARECER (não aprova/reprova)
|
GG registra PARECER (não aprova/reprova)
|
||||||
|
|
|
||||||
19
Dockerfile
19
Dockerfile
|
|
@ -12,7 +12,24 @@ RUN apt-get update && \
|
||||||
unzip \
|
unzip \
|
||||||
gcc \
|
gcc \
|
||||||
g++ \
|
g++ \
|
||||||
unixodbc-dev && \
|
unixodbc-dev \
|
||||||
|
# WeasyPrint dependencies (Cairo/Pango/GDK-pixbuf + basic image/font libs)
|
||||||
|
libcairo2 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangoft2-1.0-0 \
|
||||||
|
libharfbuzz0b \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libglib2.0-data \
|
||||||
|
libicu72 \
|
||||||
|
libgdk-pixbuf-2.0-0 \
|
||||||
|
libxml2 \
|
||||||
|
libxslt1.1 \
|
||||||
|
zlib1g \
|
||||||
|
libjpeg62-turbo \
|
||||||
|
libfontconfig1 \
|
||||||
|
shared-mime-info \
|
||||||
|
xdg-user-dirs \
|
||||||
|
fonts-dejavu && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# 2. Oracle Instant Client
|
# 2. Oracle Instant Client
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Migração gradual SGMP PROD → Next.js
|
||||||
|
|
||||||
|
Este documento descreve a proposta de migração das telas do Django para o frontend Next.js, feita de forma incremental.
|
||||||
|
|
||||||
|
## Status atual
|
||||||
|
|
||||||
|
### ✅ Migradas para Next.js
|
||||||
|
- **Login** (`/tela_login`) – autenticação via credenciais Winthor
|
||||||
|
- **Dashboard** (`/dashboard`) – métricas e listagem de solicitações
|
||||||
|
- **Home** (`/`) – página inicial com links para login e dashboard
|
||||||
|
|
||||||
|
### 🔄 Ainda no Django
|
||||||
|
- Detalhe de solicitação
|
||||||
|
- Formulários (desligamento, movimentação, admissão)
|
||||||
|
- Todas as solicitações
|
||||||
|
- Gerenciamento de permissões
|
||||||
|
|
||||||
|
## Como executar
|
||||||
|
|
||||||
|
### 1. Backend (Django)
|
||||||
|
```bash
|
||||||
|
cd SGMP_PROD
|
||||||
|
python manage.py runserver # porta 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Frontend (Next.js)
|
||||||
|
```bash
|
||||||
|
cd SGMP_PROD/frontend
|
||||||
|
cp .env.example .env # ajuste NEXT_PUBLIC_API_URL se necessário
|
||||||
|
npm run dev # porta 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Acesso
|
||||||
|
- **Next.js (novas telas)**: http://localhost:3000
|
||||||
|
- **Django (telas antigas)**: http://localhost:8000
|
||||||
|
|
||||||
|
## APIs REST (Django)
|
||||||
|
|
||||||
|
As seguintes APIs foram criadas para integração com o frontend:
|
||||||
|
|
||||||
|
| Endpoint | Método | Descrição |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| `/api/auth/login/` | POST | Login (JSON: username, password, next?) |
|
||||||
|
| `/api/auth/logout/` | POST | Logout |
|
||||||
|
| `/api/auth/me/` | GET | Dados do usuário autenticado |
|
||||||
|
| `/api/dashboard/` | GET | Dados do dashboard (métricas + solicitações) |
|
||||||
|
|
||||||
|
## Proxy e cookies
|
||||||
|
|
||||||
|
O Next.js usa um Route Handler em `/api/[...path]` que faz proxy para o Django. Isso garante que:
|
||||||
|
- Os cookies de sessão sejam repassados corretamente
|
||||||
|
- O frontend e o backend possam rodar em portas diferentes em dev
|
||||||
|
|
||||||
|
## Próximos passos
|
||||||
|
|
||||||
|
1. Migrar página de **detalhe da solicitação**
|
||||||
|
2. Migrar formulários de **nova solicitação** (wizard)
|
||||||
|
3. Migrar **todas as solicitações**
|
||||||
|
|
@ -77,56 +77,109 @@ Para criar usuários manualmente ou ajustar dados:
|
||||||
|
|
||||||
## 🎭 Perfis e Permissões
|
## 🎭 Perfis e Permissões
|
||||||
|
|
||||||
O sistema possui **4 perfis** com diferentes níveis de acesso:
|
O sistema possui perfis com diferentes níveis de acesso.
|
||||||
|
Hoje também existe suporte a **multi-perfis**: cada usuário tem um perfil principal e pode ter perfis adicionais.
|
||||||
|
|
||||||
|
### Perfis disponíveis
|
||||||
|
|
||||||
|
- `GESTOR`
|
||||||
|
- `ADMIN`
|
||||||
|
- `HEAD`
|
||||||
|
- `GG` (Gente e Gestão)
|
||||||
|
- `CONTROLADORIA`
|
||||||
|
- `DIRETORIA`
|
||||||
|
|
||||||
|
### Multi-perfis (`UsuarioPerfilExtra`)
|
||||||
|
|
||||||
|
- O campo `UsuarioSistema.perfil` guarda o **perfil principal**.
|
||||||
|
- A tabela `UsuarioPerfilExtra` registra **perfis adicionais**.
|
||||||
|
- O método `tem_perfil(perfil)` considera tanto o principal quanto os extras.
|
||||||
|
|
||||||
|
Exemplos práticos:
|
||||||
|
|
||||||
|
- Rosi como **GESTOR** (principal) + **HEAD** (extra):
|
||||||
|
- Ela continua podendo criar solicitações como Gestora.
|
||||||
|
- Também pode aprovar como Head para os gestores aos quais estiver vinculada (inclusive ela mesma, se for configurada como sua própria head).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 0. ADMIN
|
||||||
|
|
||||||
|
**Permissões principais:**
|
||||||
|
- ✅ Visão completa de todas as solicitações (`dashboard` e `todas as solicitações`)
|
||||||
|
- ✅ Criar solicitações (como super-perfil de negócio)
|
||||||
|
- ✅ Registrar parecer técnico quando houver etapa pendente (GG/Controladoria)
|
||||||
|
- ✅ Aprovar/reprovar em etapas decisórias válidas por status (`AGUARDANDO_HEAD` e `AGUARDANDO_DIRETORIA`)
|
||||||
|
- ✅ Gerenciar permissões de usuários
|
||||||
|
- ⚠️ Não ignora o fluxo: ações continuam bloqueadas quando o status não permite
|
||||||
|
|
||||||
|
**Uso:** Perfil administrativo de negócio com acesso transversal e governança total.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 1. GESTOR (Gestor)
|
### 1. GESTOR (Gestor)
|
||||||
|
|
||||||
**Permissões:**
|
**Permissões principais:**
|
||||||
- ✅ Criar solicitações (desligamento, admissão, movimentação)
|
- ✅ Criar solicitações (desligamento, admissão, movimentação)
|
||||||
- ✅ Visualizar suas próprias solicitações
|
- ✅ Visualizar e editar **suas próprias solicitações** em status `RASCUNHO`
|
||||||
- ✅ Editar solicitações em status "Rascunho"
|
- ✅ Enviar suas solicitações para aprovação
|
||||||
- ✅ Enviar solicitações para aprovação
|
- ✅ Acessar a página de permissões `/permissoes/` para alterar perfis
|
||||||
- ✅ Gerenciar permissões de todos os usuários
|
- ❌ Não aprova solicitações por padrão (a menos que também tenha outro perfil, como HEAD ou DIRETORIA)
|
||||||
- ❌ Não pode aprovar/reprovar solicitações
|
|
||||||
|
|
||||||
**Uso:** Gestores de equipe que iniciam processos de RH.
|
**Uso:** Gestores de equipe que iniciam processos de RH.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. GG (Gente e Gestão)
|
### 2. HEAD
|
||||||
|
|
||||||
**Permissões:**
|
**Permissões principais:**
|
||||||
- ✅ Visualizar solicitações com status "ENVIADA"
|
- ✅ Visualizar solicitações com status `AGUARDANDO_HEAD`
|
||||||
- ✅ Aprovar/reprovar solicitações na etapa GG
|
- ✅ Aprovar ou reprovar como Head quando:
|
||||||
- ✅ Visualizar detalhes completos das solicitações
|
- A solicitação está em `AGUARDANDO_HEAD`, e
|
||||||
- ✅ Gerenciar permissões de todos os usuários
|
- O solicitante é um Gestor vinculado a esse Head em `HeadGestor` (ou o próprio, se configurado)
|
||||||
- ❌ Não pode criar novas solicitações
|
- ✅ Acessar a página de permissões `/permissoes/` (mesmas regras gerais)
|
||||||
|
- ❌ Não cria solicitações apenas por ser Head (isso depende de também ter perfil de Gestor)
|
||||||
|
|
||||||
**Uso:** Primeira etapa de aprovação no fluxo.
|
**Uso:** Camada intermediária de aprovação antes de a solicitação seguir para GG/Controladoria.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. CONTROLADORIA
|
### 3. GG (Gente e Gestão)
|
||||||
|
|
||||||
**Permissões:**
|
**Permissões principais:**
|
||||||
- ✅ Visualizar solicitações aprovadas pela GG (status "APROVADA_GG")
|
- ✅ Visualizar solicitações com status `ENVIADA`
|
||||||
- ✅ Aprovar/reprovar solicitações na etapa Controladoria
|
- ✅ Registrar **parecer técnico** (modelo `Parecer`) na etapa GG
|
||||||
- ✅ Visualizar detalhes completos das solicitações
|
- ✅ Visualizar detalhes completos das solicitações
|
||||||
- ✅ Gerenciar permissões de todos os usuários
|
- ✅ Acessar a página de permissões `/permissoes/`
|
||||||
- ❌ Não pode criar novas solicitações
|
- ❌ Não cria novas solicitações apenas por ser GG
|
||||||
|
- ❌ Não aprova/reprova a solicitação (apenas emite parecer)
|
||||||
|
|
||||||
**Uso:** Segunda etapa de aprovação (análise financeira/orçamentária).
|
**Uso:** Primeira etapa de análise técnica (pessoas).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. DIRETORIA
|
### 4. CONTROLADORIA
|
||||||
|
|
||||||
**Permissões:**
|
**Permissões principais:**
|
||||||
- ✅ Visualizar solicitações aprovadas pela Controladoria (status "APROVADA_CONTROLADORIA")
|
- ✅ Visualizar solicitações com status `ENVIADA`
|
||||||
- ✅ Aprovar/reprovar solicitações na etapa Diretoria (aprovação final)
|
- ✅ Registrar **parecer técnico** na etapa Controladoria
|
||||||
- ✅ Visualizar detalhes completos das solicitações
|
- ✅ Visualizar detalhes completos das solicitações
|
||||||
- ✅ Gerenciar permissões de todos os usuários
|
- ✅ Acessar a página de permissões `/permissoes/`
|
||||||
- ❌ Não pode criar novas solicitações
|
- ❌ Não cria novas solicitações apenas por ser Controladoria
|
||||||
|
- ❌ Não aprova/reprova a solicitação (apenas emite parecer)
|
||||||
|
|
||||||
|
**Uso:** Análise financeira/orçamentária antes da decisão final.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. DIRETORIA
|
||||||
|
|
||||||
|
**Permissões principais:**
|
||||||
|
- ✅ Visualizar solicitações em status `AGUARDANDO_DIRETORIA`
|
||||||
|
- ✅ Aprovar ou reprovar solicitações na **etapa final**, alterando o status para `FINALIZADA` ou `REPROVADA`
|
||||||
|
- ✅ Visualizar detalhes completos das solicitações
|
||||||
|
- ✅ Acessar a página de permissões `/permissoes/`
|
||||||
|
- ❌ Não cria novas solicitações apenas por ser Diretoria
|
||||||
|
|
||||||
**Uso:** Aprovação final e decisão executiva.
|
**Uso:** Aprovação final e decisão executiva.
|
||||||
|
|
||||||
|
|
@ -146,16 +199,18 @@ A página de permissões permite:
|
||||||
|
|
||||||
- **Visualizar todos os usuários** do sistema
|
- **Visualizar todos os usuários** do sistema
|
||||||
- **Buscar usuários** por nome ou matrícula
|
- **Buscar usuários** por nome ou matrícula
|
||||||
- **Alterar o perfil** de qualquer usuário (incluindo o seu próprio)
|
- **Alterar o perfil principal** de qualquer usuário (incluindo o seu próprio)
|
||||||
|
- **Atribuir/remover perfis adicionais** (multi-perfis)
|
||||||
- **Ver o status** (ativo/inativo) de cada usuário
|
- **Ver o status** (ativo/inativo) de cada usuário
|
||||||
- **Identificar seu próprio perfil** (marcado como "Você")
|
- **Identificar seu próprio perfil** (marcado como "Você")
|
||||||
|
|
||||||
### Como Alterar um Perfil
|
### Como Alterar perfis (principal e adicionais)
|
||||||
|
|
||||||
1. Na tabela de usuários, localize o usuário desejado
|
1. Na tabela de usuários, localize o usuário desejado.
|
||||||
2. Na coluna "Alterar Perfil", selecione o novo perfil no dropdown
|
2. Na coluna **Alterar Perfil**, escolha o **perfil principal** no dropdown.
|
||||||
3. Clique em **"Atualizar"**
|
3. Logo abaixo, marque/desmarque as **checkboxes de Perfis adicionais** (HEAD, GG, CONTROLADORIA, DIRETORIA, etc.).
|
||||||
4. Uma mensagem de confirmação será exibida
|
4. Clique em **"Atualizar"**.
|
||||||
|
5. Uma mensagem de confirmação será exibida.
|
||||||
|
|
||||||
### Regras Importantes
|
### Regras Importantes
|
||||||
|
|
||||||
|
|
@ -173,18 +228,25 @@ A página de permissões permite:
|
||||||
O dashboard exibe diferentes informações conforme o perfil:
|
O dashboard exibe diferentes informações conforme o perfil:
|
||||||
|
|
||||||
#### GESTOR
|
#### GESTOR
|
||||||
- Vê apenas suas próprias solicitações
|
- Vê apenas **suas próprias solicitações**.
|
||||||
- Pode filtrar por status (Total, Pendentes, Aprovadas, Reprovadas)
|
- Pode filtrar por status (Total, Pendentes, Aprovadas, Reprovadas).
|
||||||
- Vê solicitações em todos os status (incluindo "Rascunho")
|
- Vê solicitações em todos os status (incluindo `RASCUNHO`).
|
||||||
|
|
||||||
#### GG, CONTROLADORIA, DIRETORIA
|
#### HEAD
|
||||||
- Vê apenas solicitações pendentes na sua etapa
|
- Vê solicitações em `AGUARDANDO_HEAD` cujo solicitante é um Gestor vinculado a ele em `HeadGestor`.
|
||||||
- Não vê solicitações em "Rascunho"
|
- Visualiza o contexto completo da solicitação para poder decidir.
|
||||||
- Não vê solicitações já finalizadas ou reprovadas
|
|
||||||
|
#### GG e CONTROLADORIA
|
||||||
|
- Veem solicitações em status `ENVIADA` onde ainda **não deram parecer**.
|
||||||
|
- Após registrar o parecer, essa solicitação deixa de aparecer para aquele usuário na tela principal de trabalho.
|
||||||
|
|
||||||
|
#### DIRETORIA
|
||||||
|
- Vê solicitações em status `AGUARDANDO_DIRETORIA` (aguardando decisão final).
|
||||||
|
- Não vê rascunhos nem solicitações já finalizadas / reprovadas como itens de trabalho.
|
||||||
|
|
||||||
### Criação de Solicitações
|
### Criação de Solicitações
|
||||||
|
|
||||||
- **Apenas GESTOR** pode criar solicitações
|
- **GESTOR e ADMIN** podem criar solicitações
|
||||||
- Outros perfis verão mensagem de erro se tentarem acessar formulários de criação
|
- Outros perfis verão mensagem de erro se tentarem acessar formulários de criação
|
||||||
|
|
||||||
### Visualização de Detalhes
|
### Visualização de Detalhes
|
||||||
|
|
@ -194,9 +256,11 @@ O dashboard exibe diferentes informações conforme o perfil:
|
||||||
|
|
||||||
### Aprovação/Reprovação
|
### Aprovação/Reprovação
|
||||||
|
|
||||||
- **GESTOR** não pode aprovar/reprovar suas próprias solicitações (após sair de "Rascunho")
|
- **HEAD** pode aprovar/reprovar solicitações em `AGUARDANDO_HEAD` desde que tenha perfil de Head (principal ou extra) e esteja vinculado ao gestor solicitante.
|
||||||
- Cada perfil só pode aprovar na sua etapa específica do fluxo
|
- **DIRETORIA** pode aprovar/reprovar solicitações em `AGUARDANDO_DIRETORIA`.
|
||||||
- O solicitante não pode aprovar sua própria solicitação
|
- **ADMIN** também pode aprovar/reprovar nas etapas válidas por status (sem burlar o estado atual).
|
||||||
|
- GG e CONTROLADORIA não aprovam; registram pareceres que alimentam a decisão da Diretoria.
|
||||||
|
- O solicitante **pode** aprovar se também tiver perfil de aprovação (por exemplo, for Head ou Diretoria e estiver autorizado pelas regras de vínculo).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -204,12 +268,13 @@ O dashboard exibe diferentes informações conforme o perfil:
|
||||||
|
|
||||||
### Estados da Solicitação
|
### Estados da Solicitação
|
||||||
|
|
||||||
1. **RASCUNHO**: Solicitação criada, ainda não enviada
|
1. **RASCUNHO**: Solicitação criada, ainda não enviada.
|
||||||
2. **ENVIADA**: Enviada para aprovação (visível para GG)
|
2. **AGUARDANDO_HEAD**: Aguardando decisão do Head (quando aplicável).
|
||||||
3. **APROVADA_GG**: Aprovada pela GG (visível para Controladoria)
|
3. **ENVIADA**: Enviada para parecer de GG/Controladoria.
|
||||||
4. **APROVADA_CONTROLADORIA**: Aprovada pela Controladoria (visível para Diretoria)
|
4. **APROVADA_GG** / **APROVADA_CONTROLADORIA**: estados intermediários registrados para histórico.
|
||||||
5. **FINALIZADA**: Aprovada pela Diretoria (processo concluído)
|
5. **AGUARDANDO_DIRETORIA**: Aguardando decisão final da Diretoria.
|
||||||
6. **REPROVADA**: Reprovação em qualquer etapa (processo encerrado)
|
6. **FINALIZADA**: Aprovada pela Diretoria (processo concluído).
|
||||||
|
7. **REPROVADA**: Reprovação em qualquer etapa (processo encerrado).
|
||||||
|
|
||||||
### Fluxo Completo
|
### Fluxo Completo
|
||||||
|
|
||||||
|
|
@ -220,27 +285,29 @@ Status: RASCUNHO
|
||||||
↓
|
↓
|
||||||
GESTOR envia para aprovação
|
GESTOR envia para aprovação
|
||||||
↓
|
↓
|
||||||
|
Status: AGUARDANDO_HEAD (quando há etapa de Head)
|
||||||
|
↓
|
||||||
|
HEAD analisa e aprova/reprova
|
||||||
|
↓
|
||||||
Status: ENVIADA
|
Status: ENVIADA
|
||||||
↓
|
↓
|
||||||
GG analisa e aprova/reprova
|
GG registra parecer
|
||||||
↓
|
↓
|
||||||
Se aprovada → Status: APROVADA_GG
|
CONTROLADORIA registra parecer
|
||||||
↓
|
↓
|
||||||
CONTROLADORIA analisa e aprova/reprova
|
Status: AGUARDANDO_DIRETORIA
|
||||||
↓
|
↓
|
||||||
Se aprovada → Status: APROVADA_CONTROLADORIA
|
DIRETORIA analisa pareceres e aprova/reprova
|
||||||
↓
|
|
||||||
DIRETORIA analisa e aprova/reprova
|
|
||||||
↓
|
↓
|
||||||
Se aprovada → Status: FINALIZADA
|
Se aprovada → Status: FINALIZADA
|
||||||
```
|
```
|
||||||
|
|
||||||
### Regras de Aprovação
|
### Regras de Aprovação
|
||||||
|
|
||||||
- Cada etapa só pode ser aprovada pelo perfil correspondente
|
- Apenas perfis com permissão de decisão podem aprovar (HEAD e DIRETORIA, conforme etapa/status).
|
||||||
- Uma reprovação em qualquer etapa encerra o processo
|
- GG e CONTROLADORIA registram pareceres, não aprovam.
|
||||||
- O solicitante não pode aprovar sua própria solicitação
|
- Uma reprovação em qualquer etapa de decisão encerra o processo.
|
||||||
- Solicitações em "RascUNHO" só são visíveis para o criador
|
- Solicitações em `RASCUNHO` só são visíveis para o criador.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from django.conf.urls.static import static
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
|
path("api/", include("solicitacoes.api_urls")),
|
||||||
path("", include("solicitacoes.urls")),
|
path("", include("solicitacoes.urls")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@AGENTS.md
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8888";
|
||||||
|
|
||||||
|
// #region agent log
|
||||||
|
function debugLog(hypothesisId: string, message: string, data: Record<string, unknown>) {
|
||||||
|
fetch("http://localhost:7687/ingest/ee56ea93-ad22-4673-ab77-595d83a9b3c5", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Debug-Session-Id": "6b638a",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: "6b638a",
|
||||||
|
runId: "login-400-debug",
|
||||||
|
hypothesisId,
|
||||||
|
location: "frontend/app/api/[...path]/route.ts:debugLog",
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxyRequest(req, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxyRequest(req, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxyRequest(req, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxyRequest(req, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxyRequest(req, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyRequest(
|
||||||
|
req: NextRequest,
|
||||||
|
ctx: { params: Promise<{ path: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await ctx.params;
|
||||||
|
const search = req.nextUrl.search || "";
|
||||||
|
const proxiedPathRaw = req.nextUrl.pathname.replace(/^\/api\//, "");
|
||||||
|
const normalizedPathRaw = proxiedPathRaw.startsWith("api/")
|
||||||
|
? proxiedPathRaw
|
||||||
|
: `api/${proxiedPathRaw}`;
|
||||||
|
const proxiedPath = normalizedPathRaw.endsWith("/") ? normalizedPathRaw : `${normalizedPathRaw}/`;
|
||||||
|
const target = `${API_BASE}/${proxiedPath}${search}`;
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H1_H2_H3", "proxy_request_start", {
|
||||||
|
method: req.method,
|
||||||
|
path,
|
||||||
|
proxiedPath,
|
||||||
|
originalPathname: req.nextUrl.pathname,
|
||||||
|
search,
|
||||||
|
apiBase: API_BASE,
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
const headers = new Headers(req.headers);
|
||||||
|
headers.delete("host");
|
||||||
|
headers.delete("content-length");
|
||||||
|
|
||||||
|
const hasBody = !["GET", "HEAD"].includes(req.method);
|
||||||
|
const contentType = headers.get("content-type");
|
||||||
|
const isMultipart = (contentType || "").toLowerCase().includes("multipart/form-data");
|
||||||
|
let clonedBodyText = "";
|
||||||
|
let requestBodyLength = 0;
|
||||||
|
let requestBodyJsonKeys: string[] = [];
|
||||||
|
let requestBodyReadError: string | null = null;
|
||||||
|
|
||||||
|
if (hasBody && !isMultipart) {
|
||||||
|
try {
|
||||||
|
clonedBodyText = await req.clone().text();
|
||||||
|
requestBodyLength = clonedBodyText.length;
|
||||||
|
if (contentType?.includes("application/json")) {
|
||||||
|
const parsed = JSON.parse(clonedBodyText || "{}");
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
requestBodyJsonKeys = Object.keys(parsed).sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
requestBodyReadError = error instanceof Error ? error.message : "unknown_error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H1_H3_H4", "proxy_request_body_probe", {
|
||||||
|
method: req.method,
|
||||||
|
target,
|
||||||
|
hasBody,
|
||||||
|
isMultipart,
|
||||||
|
contentType,
|
||||||
|
requestBodyLength,
|
||||||
|
requestBodyJsonKeys,
|
||||||
|
requestBodyReadError,
|
||||||
|
hasUsernameKey: requestBodyJsonKeys.includes("username"),
|
||||||
|
hasPasswordKey: requestBodyJsonKeys.includes("password"),
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
const init: RequestInit & { duplex?: "half" } = {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasBody) {
|
||||||
|
if (isMultipart) {
|
||||||
|
// Multipart precisa seguir como stream para preservar boundary e arquivos.
|
||||||
|
init.body = req.body;
|
||||||
|
init.duplex = "half";
|
||||||
|
debugLog("H9", "proxy_request_forward_mode", {
|
||||||
|
method: req.method,
|
||||||
|
target,
|
||||||
|
forwardMode: "stream_multipart",
|
||||||
|
contentType,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// JSON/urlencoded segue buffer textual para evitar perda de stream no proxy.
|
||||||
|
init.body = clonedBodyText;
|
||||||
|
debugLog("H9", "proxy_request_forward_mode", {
|
||||||
|
method: req.method,
|
||||||
|
target,
|
||||||
|
forwardMode: "buffered_text",
|
||||||
|
forwardedBodyLength: clonedBodyText.length,
|
||||||
|
contentType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(target, init);
|
||||||
|
} catch (error) {
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H2", "proxy_request_fetch_exception", {
|
||||||
|
target,
|
||||||
|
error: error instanceof Error ? error.message : "unknown_error",
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
return NextResponse.json({ message: "Falha de conexão no proxy." }, { status: 502 });
|
||||||
|
}
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H5", "proxy_request_first_attempt_done", {
|
||||||
|
target,
|
||||||
|
status: res.status,
|
||||||
|
needsTrailingSlashRetry: res.status === 404 && !target.endsWith("/"),
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
if (res.status === 404 && !target.endsWith("/")) {
|
||||||
|
const targetWithSlash = `${target}/`;
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H5", "proxy_request_retry_with_trailing_slash", {
|
||||||
|
from: target,
|
||||||
|
to: targetWithSlash,
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
try {
|
||||||
|
const retried = await fetch(targetWithSlash, init);
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H5", "proxy_request_retry_response", {
|
||||||
|
targetWithSlash,
|
||||||
|
status: retried.status,
|
||||||
|
statusText: retried.statusText,
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
res = retried;
|
||||||
|
} catch (error) {
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H5", "proxy_request_retry_exception", {
|
||||||
|
targetWithSlash,
|
||||||
|
error: error instanceof Error ? error.message : "unknown_error",
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H1_H3", "proxy_request_response", {
|
||||||
|
target,
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
if (res.status >= 400) {
|
||||||
|
let responsePreview = "";
|
||||||
|
try {
|
||||||
|
responsePreview = (await res.clone().text()).slice(0, 300);
|
||||||
|
} catch {
|
||||||
|
responsePreview = "unreadable_response_body";
|
||||||
|
}
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H2_H4", "proxy_request_error_response_preview", {
|
||||||
|
target,
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
responsePreview,
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseHeaders = new Headers();
|
||||||
|
res.headers.forEach((value, key) => {
|
||||||
|
responseHeaders.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(res.body, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type Usuario = {
|
||||||
|
matricula: string;
|
||||||
|
nome: string;
|
||||||
|
perfil: string;
|
||||||
|
perfil_display: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Solicitacao = {
|
||||||
|
id: string;
|
||||||
|
tipo: string;
|
||||||
|
tipo_display: string;
|
||||||
|
colaborador: string | null;
|
||||||
|
status: string;
|
||||||
|
status_display: string;
|
||||||
|
criado_em: string | null;
|
||||||
|
enviada_em: string | null;
|
||||||
|
solicitante_nome: string | null;
|
||||||
|
pode_aprovar: boolean;
|
||||||
|
pode_dar_parecer: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DashboardData = {
|
||||||
|
usuario: Usuario;
|
||||||
|
total: number;
|
||||||
|
pendentes: number;
|
||||||
|
solicitacoes: Solicitacao[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
total_pages: number;
|
||||||
|
total_count: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_CLASSES: Record<string, string> = {
|
||||||
|
RASCUNHO: "bg-amber-50 text-amber-800 border-amber-200",
|
||||||
|
AGUARDANDO_HEAD: "bg-amber-50 text-amber-800 border-amber-200",
|
||||||
|
ENVIADA: "bg-blue-50 text-blue-800 border-blue-200",
|
||||||
|
APROVADA_GG: "bg-emerald-50 text-emerald-800 border-emerald-200",
|
||||||
|
APROVADA_CONTROLADORIA: "bg-emerald-50 text-emerald-800 border-emerald-200",
|
||||||
|
APROVADA_DIRETORIA: "bg-emerald-50 text-emerald-800 border-emerald-200",
|
||||||
|
AGUARDANDO_DIRETORIA: "bg-amber-50 text-amber-800 border-amber-200",
|
||||||
|
FINALIZADA: "bg-slate-100 text-slate-700 border-slate-200",
|
||||||
|
REPROVADA: "bg-red-50 text-red-800 border-red-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DJANGO_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8888";
|
||||||
|
|
||||||
|
function formatarData(iso: string | null) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString("pt-BR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function carregar() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/dashboard/?page=${page}`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
// #region agent log
|
||||||
|
fetch("http://localhost:7701/ingest/5073259c-ddcc-441a-a087-e13a2cf7ac9e", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Debug-Session-Id": "ca90b5",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: "ca90b5",
|
||||||
|
runId: "login-debug-01",
|
||||||
|
hypothesisId: "H1",
|
||||||
|
location: "frontend/app/dashboard/page.tsx:fetch",
|
||||||
|
message: "dashboard_fetch_status",
|
||||||
|
data: { status: res.status, page },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
// #endregion
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.push(`/tela_login?next=${encodeURIComponent("/dashboard")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("Erro ao carregar o dashboard.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
setData(json);
|
||||||
|
} catch {
|
||||||
|
setError("Erro de conexão.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
carregar();
|
||||||
|
}, [page, router]);
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout/", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
router.push("/tela_login");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-100">
|
||||||
|
<p className="text-slate-500">Carregando…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-slate-100">
|
||||||
|
<p className="text-red-600">{error || "Erro ao carregar."}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Tentar novamente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { usuario, total, pendentes, solicitacoes, pagination } = data;
|
||||||
|
const isGestor = usuario.perfil === "GESTOR";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-100">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-slate-900 text-white border-b border-slate-800">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||||
|
<h1 className="text-lg font-bold tracking-tight">SGMP CORP</h1>
|
||||||
|
<nav className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="text-sm font-medium text-slate-300 hover:text-white"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-sm text-red-400 hover:text-red-300 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Sair
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-6xl mx-auto py-6 md:py-8 px-4 sm:px-6">
|
||||||
|
{/* Saudação */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-800 tracking-tight">
|
||||||
|
SGMP - Movimentação de Pessoas
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500 text-sm md:text-base mt-2">
|
||||||
|
Olá, <strong>{usuario.nome}</strong>!
|
||||||
|
<span className="inline-block bg-slate-200 py-0.5 px-2 rounded text-xs ml-2 text-slate-600">
|
||||||
|
{usuario.perfil_display}
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
<small className="text-slate-400">Matrícula: {usuario.matricula}</small>
|
||||||
|
</p>
|
||||||
|
{isGestor && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Link
|
||||||
|
href="/nova-solicitacao"
|
||||||
|
className="inline-flex items-center gap-2 py-2.5 px-4 rounded-md text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
+ Nova Solicitação
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isGestor && (
|
||||||
|
<div className="mb-8 bg-amber-50 border border-amber-300 rounded-xl p-4 flex gap-3 items-start">
|
||||||
|
<span className="text-2xl">💡</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="m-0 mb-1 text-amber-800 font-semibold">
|
||||||
|
Lembrete Rápido
|
||||||
|
</h4>
|
||||||
|
<p className="m-0 text-amber-700 text-sm">
|
||||||
|
Solicitações em <strong>Rascunho</strong> só são visíveis para
|
||||||
|
você. Lembre-se de clicar em{" "}
|
||||||
|
<strong>"Enviar para Aprovação"</strong> na página de
|
||||||
|
detalhes para iniciar o fluxo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Métricas */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4 md:gap-6 mb-10">
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm border-l-4 border-l-blue-500">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-slate-500 font-bold mb-2">
|
||||||
|
Total
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-extrabold leading-none text-blue-600">
|
||||||
|
{total}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm border-l-4 border-l-amber-500">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-slate-500 font-bold mb-2">
|
||||||
|
Pendentes
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-extrabold leading-none text-amber-600">
|
||||||
|
{pendentes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabela de solicitações */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-slate-800 text-xl font-semibold mb-4 flex items-center gap-2">
|
||||||
|
{isGestor ? "📋 Minhas Solicitações" : "⏳ Pendentes de Aprovação"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{!isGestor && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg py-3 px-4 mb-5 text-blue-800 text-sm flex items-center gap-2">
|
||||||
|
ℹ️ Você está vendo solicitações com status{" "}
|
||||||
|
<strong>Enviada</strong> aguardando sua análise.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{solicitacoes.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="md:hidden space-y-3">
|
||||||
|
{solicitacoes.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="bg-white rounded-xl shadow border border-slate-200 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-2 min-w-0">
|
||||||
|
<p className="font-semibold text-slate-900 leading-tight min-w-0 flex-1 text-sm">
|
||||||
|
{s.tipo_display}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={`inline-flex max-w-[min(100%,14rem)] text-left break-words leading-snug py-1 px-2.5 rounded-full text-xs font-bold border ${
|
||||||
|
STATUS_CLASSES[s.status] ||
|
||||||
|
"bg-slate-100 text-slate-700 border-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.status_display}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-700 font-medium">
|
||||||
|
{s.colaborador || "N/A"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-600 mt-1">
|
||||||
|
{formatarData(s.criado_em)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mt-3">
|
||||||
|
<Link
|
||||||
|
href={`/solicitacoes/${s.id}`}
|
||||||
|
className="text-blue-600 font-semibold text-sm hover:underline"
|
||||||
|
>
|
||||||
|
Detalhes
|
||||||
|
</Link>
|
||||||
|
{s.pode_dar_parecer && (
|
||||||
|
<a
|
||||||
|
href={`${DJANGO_BASE_URL}/solicitacao/${s.id}/`}
|
||||||
|
className="inline-flex items-center gap-1.5 py-2 px-3 rounded-md text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 no-underline"
|
||||||
|
>
|
||||||
|
📝 Parecer
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{s.pode_aprovar && (
|
||||||
|
<p className="text-slate-600 text-sm font-medium mt-2">
|
||||||
|
Aprovar/Reprovar na página de detalhes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:block overflow-x-auto bg-white rounded-xl shadow border border-slate-200">
|
||||||
|
<table className="w-full min-w-[640px] border-collapse text-slate-900">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">
|
||||||
|
Tipo
|
||||||
|
</th>
|
||||||
|
<th className="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">
|
||||||
|
Colaborador
|
||||||
|
</th>
|
||||||
|
<th className="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">
|
||||||
|
Data
|
||||||
|
</th>
|
||||||
|
<th className="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">
|
||||||
|
Ações
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{solicitacoes.map((s) => (
|
||||||
|
<tr
|
||||||
|
key={s.id}
|
||||||
|
className="border-b border-slate-200 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<td className="p-4 font-semibold min-w-0 max-w-[11rem] break-words">
|
||||||
|
{s.tipo_display}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-slate-900 font-medium min-w-0 max-w-[12rem] break-words">
|
||||||
|
{s.colaborador || (
|
||||||
|
<span className="text-slate-700 font-medium">N/A</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 min-w-0 max-w-[14rem]">
|
||||||
|
<span
|
||||||
|
className={`inline-flex max-w-full text-left break-words leading-snug py-1 px-2.5 rounded-full text-xs font-bold border ${
|
||||||
|
STATUS_CLASSES[s.status] ||
|
||||||
|
"bg-slate-100 text-slate-700 border-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.status_display}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-slate-900 font-medium whitespace-nowrap">
|
||||||
|
{formatarData(s.criado_em)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex gap-3 items-center flex-wrap">
|
||||||
|
<Link
|
||||||
|
href={`/solicitacoes/${s.id}`}
|
||||||
|
className="text-blue-600 font-semibold text-sm hover:underline"
|
||||||
|
>
|
||||||
|
Detalhes
|
||||||
|
</Link>
|
||||||
|
{s.pode_dar_parecer && (
|
||||||
|
<a
|
||||||
|
href={`${DJANGO_BASE_URL}/solicitacao/${s.id}/`}
|
||||||
|
className="inline-flex items-center gap-1.5 py-2 px-4 rounded-md text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 no-underline"
|
||||||
|
>
|
||||||
|
📝 Parecer
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{s.pode_aprovar && (
|
||||||
|
<span className="text-slate-600 text-sm font-medium">
|
||||||
|
(Aprovar/Reprovar na página de detalhes)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pagination.total_pages > 1 && (
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
className="py-2 px-3.5 border border-slate-200 rounded-md text-slate-600 bg-white text-sm hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
‹ Anterior
|
||||||
|
</button>
|
||||||
|
<span className="py-2 px-3.5 bg-blue-600 text-white rounded-md font-medium text-sm">
|
||||||
|
{pagination.page} / {pagination.total_pages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setPage((p) =>
|
||||||
|
Math.min(pagination.total_pages, p + 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={pagination.page >= pagination.total_pages}
|
||||||
|
className="py-2 px-3.5 border border-slate-200 rounded-md text-slate-600 bg-white text-sm hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Próxima ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 px-5 text-slate-500 bg-white rounded-xl border-2 border-dashed border-slate-300">
|
||||||
|
<p className="m-0 text-lg">🎉 Nenhuma solicitação encontrada.</p>
|
||||||
|
{isGestor && (
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
Clique em <strong>Nova Solicitação</strong> para iniciar um novo fluxo.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -0,0 +1,26 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "SGMP - Gestão de Pessoas",
|
||||||
|
description: "Sistema de Gestão e Movimentações de Pessoas",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="pt-BR"
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { apiGet, apiPostFormData, apiPostJson, ApiError } from "@/lib/api/client";
|
||||||
|
import { validateSolicitacaoPayload } from "@/lib/validation/solicitacao";
|
||||||
|
import { Field } from "@/components/forms/Field";
|
||||||
|
|
||||||
|
type Meta = {
|
||||||
|
cargos: Array<{ codigo: string; nome: string }>;
|
||||||
|
secoes: Array<{ codigo: string; descricao: string }>;
|
||||||
|
coligadas: Array<{ codigo: number; nome: string }>;
|
||||||
|
tipos: Array<{ value: string; label: string; precisa_colaborador: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Colaborador = {
|
||||||
|
id: string | null;
|
||||||
|
matricula: string | null;
|
||||||
|
nome: string | null;
|
||||||
|
cargo: string | null;
|
||||||
|
setor: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormState = Record<string, string | boolean>;
|
||||||
|
|
||||||
|
export default function NovaSolicitacaoPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [meta, setMeta] = useState<Meta | null>(null);
|
||||||
|
const [tipo, setTipo] = useState("");
|
||||||
|
const [busca, setBusca] = useState("");
|
||||||
|
const [colaboradores, setColaboradores] = useState<Colaborador[]>([]);
|
||||||
|
const [funcionarioId, setFuncionarioId] = useState("");
|
||||||
|
const [arquivoPedido, setArquivoPedido] = useState<File | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState>({});
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [feedback, setFeedback] = useState("");
|
||||||
|
|
||||||
|
const selectedTipo = useMemo(
|
||||||
|
() => meta?.tipos.find((t) => t.value === tipo) || null,
|
||||||
|
[meta, tipo]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function ensureMeta() {
|
||||||
|
if (meta) return meta;
|
||||||
|
const loaded = await apiGet<Meta>("/api/nova-solicitacao/metadata/");
|
||||||
|
setMeta(loaded);
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buscarColaboradores() {
|
||||||
|
if (!tipo) return;
|
||||||
|
setLoading(true);
|
||||||
|
setFeedback("");
|
||||||
|
try {
|
||||||
|
await ensureMeta();
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (busca.trim()) query.set("q", busca.trim());
|
||||||
|
if (tipo === "ADM_SUBSTITUICAO") query.set("tipo", "substituicao");
|
||||||
|
const res = await apiGet<{ colaboradores: Colaborador[] }>(
|
||||||
|
`/api/colaboradores/?${query.toString()}`
|
||||||
|
);
|
||||||
|
setColaboradores(res.colaboradores);
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback(error instanceof Error ? error.message : "Erro ao buscar colaboradores.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const payload: Record<string, unknown> = { ...form, tipo };
|
||||||
|
if (selectedTipo?.precisa_colaborador) payload.funcionario_id = funcionarioId;
|
||||||
|
if (tipo === "DESLIGAMENTO") {
|
||||||
|
payload.data_prevista_desligamento = form.data_prevista_desligamento || "";
|
||||||
|
}
|
||||||
|
const validation = validateSolicitacaoPayload(payload);
|
||||||
|
setErrors(validation);
|
||||||
|
if (Object.keys(validation).length > 0) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setFeedback("");
|
||||||
|
try {
|
||||||
|
if (tipo === "DESLIGAMENTO" && arquivoPedido) {
|
||||||
|
const fd = new FormData();
|
||||||
|
Object.entries(payload).forEach(([k, v]) => {
|
||||||
|
if (typeof v === "boolean") fd.append(k, v ? "true" : "false");
|
||||||
|
else fd.append(k, String(v ?? ""));
|
||||||
|
});
|
||||||
|
fd.append("arquivo_pedido", arquivoPedido);
|
||||||
|
const result = await apiPostFormData<{ redirect: string }>(
|
||||||
|
"/api/solicitacoes/",
|
||||||
|
fd
|
||||||
|
);
|
||||||
|
router.push(result.redirect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await apiPostJson<{ redirect: string }>(
|
||||||
|
"/api/solicitacoes/",
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
router.push(result.redirect);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
setFeedback(error.message);
|
||||||
|
} else {
|
||||||
|
setFeedback("Erro ao criar solicitação.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-100 p-4 md:p-6">
|
||||||
|
<div className="max-w-5xl mx-auto space-y-4">
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-5">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Nova Solicitação</h1>
|
||||||
|
<p className="text-slate-700 mt-1">Crie e envie solicitações sem sair do Next.</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Link href="/dashboard" className="text-blue-600 font-semibold hover:underline">
|
||||||
|
Voltar ao dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="bg-white rounded-xl border border-slate-200 p-5 space-y-4">
|
||||||
|
<Field label="Tipo de solicitação" required error={errors.tipo}>
|
||||||
|
<select
|
||||||
|
className="w-full border border-slate-300 rounded-md p-2 text-slate-900"
|
||||||
|
value={tipo}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTipo(e.target.value);
|
||||||
|
setFuncionarioId("");
|
||||||
|
setColaboradores([]);
|
||||||
|
setForm({});
|
||||||
|
}}
|
||||||
|
onFocus={ensureMeta}
|
||||||
|
>
|
||||||
|
<option value="">Selecione…</option>
|
||||||
|
{(meta?.tipos || []).map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{selectedTipo?.precisa_colaborador && (
|
||||||
|
<div className="border border-slate-200 rounded-lg p-4 space-y-3">
|
||||||
|
<h2 className="text-base font-bold text-slate-900">Selecionar colaborador</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="flex-1 border border-slate-300 rounded-md p-2 text-slate-900"
|
||||||
|
placeholder="Buscar por nome ou matrícula"
|
||||||
|
value={busca}
|
||||||
|
onChange={(e) => setBusca(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={buscarColaboradores}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 rounded-md bg-blue-600 text-white font-semibold disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Buscar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
Para substituição, serão listados colaboradores desligados.
|
||||||
|
</p>
|
||||||
|
<Field label="Colaborador" required error={errors.funcionario_id}>
|
||||||
|
<select
|
||||||
|
className="w-full border border-slate-300 rounded-md p-2 text-slate-900"
|
||||||
|
value={funcionarioId}
|
||||||
|
onChange={(e) => setFuncionarioId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Selecione…</option>
|
||||||
|
{colaboradores.map((c) => (
|
||||||
|
<option key={`${c.id || "rm"}-${c.matricula}`} value={c.id || ""}>
|
||||||
|
{c.nome} ({c.matricula || "sem matrícula"})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tipo === "DESLIGAMENTO" && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Field label="Tipo de desligamento" required error={errors.tipo_desligamento}>
|
||||||
|
<input className="w-full border border-slate-300 rounded-md p-2" value={String(form.tipo_desligamento || "")} onChange={(e) => setForm((f) => ({ ...f, tipo_desligamento: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Aviso prévio" required error={errors.aviso_previo}>
|
||||||
|
<input className="w-full border border-slate-300 rounded-md p-2" value={String(form.aviso_previo || "")} onChange={(e) => setForm((f) => ({ ...f, aviso_previo: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Data prevista de saída" required error={errors.data_prevista_desligamento}>
|
||||||
|
<input type="date" className="w-full border border-slate-300 rounded-md p-2" value={String(form.data_prevista_desligamento || "")} onChange={(e) => setForm((f) => ({ ...f, data_prevista_desligamento: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Carta de pedido (opcional)">
|
||||||
|
<input type="file" className="w-full border border-slate-300 rounded-md p-2" onChange={(e) => setArquivoPedido(e.target.files?.[0] || null)} />
|
||||||
|
</Field>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Field label="Justificativa" required error={errors.motivo}>
|
||||||
|
<textarea className="w-full border border-slate-300 rounded-md p-2 min-h-28" value={String(form.motivo || "")} onChange={(e) => setForm((f) => ({ ...f, motivo: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Field label="Observações adicionais">
|
||||||
|
<textarea className="w-full border border-slate-300 rounded-md p-2 min-h-20" value={String(form.observacoes || "")} onChange={(e) => setForm((f) => ({ ...f, observacoes: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tipo === "MOVIMENTACAO" && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Field label="Data de efetivação" required error={errors.data_efetivacao}>
|
||||||
|
<input type="date" className="w-full border border-slate-300 rounded-md p-2" value={String(form.data_efetivacao || "")} onChange={(e) => setForm((f) => ({ ...f, data_efetivacao: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Novo salário">
|
||||||
|
<input type="number" className="w-full border border-slate-300 rounded-md p-2" value={String(form.novo_salario || "")} onChange={(e) => setForm((f) => ({ ...f, novo_salario: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Nova função (código)">
|
||||||
|
<input className="w-full border border-slate-300 rounded-md p-2" value={String(form.novo_cod_funcao || "")} onChange={(e) => setForm((f) => ({ ...f, novo_cod_funcao: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Nova seção (código)">
|
||||||
|
<input className="w-full border border-slate-300 rounded-md p-2" value={String(form.novo_cod_secao || "")} onChange={(e) => setForm((f) => ({ ...f, novo_cod_secao: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<div className="md:col-span-2 flex gap-6">
|
||||||
|
<label className="text-sm font-semibold text-slate-800">
|
||||||
|
<input type="checkbox" checked={Boolean(form.altera_funcao)} onChange={(e) => setForm((f) => ({ ...f, altera_funcao: e.target.checked }))} className="mr-2" />
|
||||||
|
Altera função
|
||||||
|
</label>
|
||||||
|
<label className="text-sm font-semibold text-slate-800">
|
||||||
|
<input type="checkbox" checked={Boolean(form.altera_centro_custo)} onChange={(e) => setForm((f) => ({ ...f, altera_centro_custo: e.target.checked }))} className="mr-2" />
|
||||||
|
Altera centro de custo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Field label="Justificativa" required error={errors.justificativa}>
|
||||||
|
<textarea className="w-full border border-slate-300 rounded-md p-2 min-h-28" value={String(form.justificativa || "")} onChange={(e) => setForm((f) => ({ ...f, justificativa: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(tipo === "ADM_SUBSTITUICAO" || tipo === "ADM_AUMENTO") && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Field label="Data prevista contratação" required error={errors.data_previsao_contratacao}>
|
||||||
|
<input type="date" className="w-full border border-slate-300 rounded-md p-2" value={String(form.data_previsao_contratacao || "")} onChange={(e) => setForm((f) => ({ ...f, data_previsao_contratacao: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Coligada" required error={errors.cod_coligada_destino}>
|
||||||
|
<select className="w-full border border-slate-300 rounded-md p-2" value={String(form.cod_coligada_destino || "")} onFocus={ensureMeta} onChange={(e) => setForm((f) => ({ ...f, cod_coligada_destino: e.target.value }))}>
|
||||||
|
<option value="">Selecione…</option>
|
||||||
|
{(meta?.coligadas || []).map((c) => (
|
||||||
|
<option key={c.codigo} value={String(c.codigo)}>{c.codigo} - {c.nome}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Filial" required error={errors.cod_filial_destino}>
|
||||||
|
<input className="w-full border border-slate-300 rounded-md p-2" value={String(form.cod_filial_destino || "")} onChange={(e) => setForm((f) => ({ ...f, cod_filial_destino: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Seção destino" required error={errors.cod_secao_destino}>
|
||||||
|
<select className="w-full border border-slate-300 rounded-md p-2" value={String(form.cod_secao_destino || "")} onFocus={ensureMeta} onChange={(e) => setForm((f) => ({ ...f, cod_secao_destino: e.target.value }))}>
|
||||||
|
<option value="">Selecione…</option>
|
||||||
|
{(meta?.secoes || []).map((s) => (
|
||||||
|
<option key={s.codigo} value={s.codigo}>{s.codigo} - {s.descricao}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Função destino" required error={errors.cod_funcao_destino}>
|
||||||
|
<select className="w-full border border-slate-300 rounded-md p-2" value={String(form.cod_funcao_destino || "")} onFocus={ensureMeta} onChange={(e) => setForm((f) => ({ ...f, cod_funcao_destino: e.target.value }))}>
|
||||||
|
<option value="">Selecione…</option>
|
||||||
|
{(meta?.cargos || []).map((c) => (
|
||||||
|
<option key={c.codigo} value={c.codigo}>{c.codigo} - {c.nome}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Field
|
||||||
|
label={tipo === "ADM_AUMENTO" ? "Justificativa estratégica" : "Justificativa"}
|
||||||
|
required
|
||||||
|
error={tipo === "ADM_AUMENTO" ? errors.justificativa_estrategica : errors.justificativa}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-slate-300 rounded-md p-2 min-h-28"
|
||||||
|
value={String(
|
||||||
|
tipo === "ADM_AUMENTO"
|
||||||
|
? form.justificativa_estrategica || ""
|
||||||
|
: form.justificativa || ""
|
||||||
|
)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
[tipo === "ADM_AUMENTO" ? "justificativa_estrategica" : "justificativa"]:
|
||||||
|
e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feedback && <p className="text-sm font-semibold text-red-600">{feedback}</p>}
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading || !tipo}
|
||||||
|
className="px-5 py-2.5 rounded-md bg-blue-600 text-white font-semibold hover:bg-blue-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? "Salvando..." : "Criar solicitação"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen items-center justify-center bg-slate-100 font-sans">
|
||||||
|
<main className="flex flex-1 w-full max-w-2xl flex-col items-center justify-center py-16 px-6">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-slate-800 mb-4">
|
||||||
|
SGMP
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600 text-lg">
|
||||||
|
Sistema de Gestão e Movimentações de Pessoas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 w-full max-w-xs">
|
||||||
|
<Link
|
||||||
|
href="/tela_login"
|
||||||
|
className="flex h-12 w-full items-center justify-center rounded-lg bg-blue-600 text-white font-semibold transition-colors hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Entrar
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="flex h-12 w-full items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-700 font-medium transition-colors hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Dashboard (após login)
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-8 text-sm text-slate-500 text-center">
|
||||||
|
Migração gradual: Login e Dashboard já disponíveis no Next.js.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,579 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { apiPostFormData, apiPostJson, ApiError } from "@/lib/api/client";
|
||||||
|
|
||||||
|
type SolicitacaoDetalhe = {
|
||||||
|
id: string;
|
||||||
|
tipo: string;
|
||||||
|
tipo_display: string;
|
||||||
|
status: string;
|
||||||
|
status_display: string;
|
||||||
|
criado_em: string | null;
|
||||||
|
enviada_em: string | null;
|
||||||
|
finalizada_em: string | null;
|
||||||
|
solicitante: {
|
||||||
|
matricula: string | null;
|
||||||
|
nome: string | null;
|
||||||
|
} | null;
|
||||||
|
funcionario: {
|
||||||
|
id: string;
|
||||||
|
id_rm: string | null;
|
||||||
|
matricula: string | null;
|
||||||
|
nome: string | null;
|
||||||
|
cpf: string | null;
|
||||||
|
data_admissao: string | null;
|
||||||
|
situacao: string | null;
|
||||||
|
cargo: string | null;
|
||||||
|
setor: string | null;
|
||||||
|
centro_custo: string | null;
|
||||||
|
cod_funcao: string | null;
|
||||||
|
salario: string | null;
|
||||||
|
cod_sindicato: string | null;
|
||||||
|
saldo_banco_horas_minutos: number | null;
|
||||||
|
inicio_periodo_banco_horas: string | null;
|
||||||
|
fim_periodo_banco_horas: string | null;
|
||||||
|
sincronizado_em: string | null;
|
||||||
|
matricula_winthor: string | null;
|
||||||
|
} | null;
|
||||||
|
dados_winthor: {
|
||||||
|
basicos: { matricula: string | null; nome: string | null; cpf: string | null };
|
||||||
|
admissao: { admissao: string | null; situacao: string | null; dt_exclusao: string | null };
|
||||||
|
endereco: { endereco: string | null; bairro: string | null; cidade: string | null; estado: string | null };
|
||||||
|
} | null;
|
||||||
|
detalhes_tipo: {
|
||||||
|
tipo: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
acoes: {
|
||||||
|
pode_aprovar: boolean;
|
||||||
|
pode_dar_parecer: boolean;
|
||||||
|
pode_enviar: boolean;
|
||||||
|
is_solicitante: boolean;
|
||||||
|
};
|
||||||
|
pareceres: Array<{
|
||||||
|
id: string;
|
||||||
|
etapa: string;
|
||||||
|
etapa_display: string;
|
||||||
|
texto: string;
|
||||||
|
criado_em: string | null;
|
||||||
|
usuario_nome: string | null;
|
||||||
|
anexo_url: string | null;
|
||||||
|
}>;
|
||||||
|
aprovacoes: Array<{
|
||||||
|
id: string;
|
||||||
|
etapa: string;
|
||||||
|
etapa_display: string;
|
||||||
|
decisao: string;
|
||||||
|
decisao_display: string;
|
||||||
|
justificativa: string;
|
||||||
|
decidido_em: string | null;
|
||||||
|
usuario_nome: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DJANGO_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8888";
|
||||||
|
|
||||||
|
function toAbsoluteDjangoUrl(pathOrUrl: string) {
|
||||||
|
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
|
||||||
|
return pathOrUrl;
|
||||||
|
}
|
||||||
|
return `${DJANGO_BASE_URL}${pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatarData(iso: string | null) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("pt-BR");
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatarDinheiro(valor: string | null) {
|
||||||
|
if (!valor) return "—";
|
||||||
|
const numero = Number(valor);
|
||||||
|
if (Number.isNaN(numero)) return valor;
|
||||||
|
return numero.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// #region agent log
|
||||||
|
function debugLog(hypothesisId: string, message: string, data: Record<string, unknown>) {
|
||||||
|
fetch("http://localhost:7687/ingest/ee56ea93-ad22-4673-ab77-595d83a9b3c5", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Debug-Session-Id": "6b638a",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: "6b638a",
|
||||||
|
runId: "detail-permission-debug",
|
||||||
|
hypothesisId,
|
||||||
|
location: "frontend/app/solicitacoes/[id]/page.tsx",
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
export default function SolicitacaoDetalhePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const solicitacaoId = useMemo(() => String(params?.id || ""), [params]);
|
||||||
|
|
||||||
|
const [data, setData] = useState<SolicitacaoDetalhe | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
const [actionFeedback, setActionFeedback] = useState("");
|
||||||
|
const [parecerTexto, setParecerTexto] = useState("");
|
||||||
|
const [parecerAnexo, setParecerAnexo] = useState<File | null>(null);
|
||||||
|
const [decisao, setDecisao] = useState<"APROVADO" | "REPROVADO">("APROVADO");
|
||||||
|
const [justificativaDecisao, setJustificativaDecisao] = useState("");
|
||||||
|
const [reloadTick, setReloadTick] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function carregarDetalhe() {
|
||||||
|
if (!solicitacaoId) {
|
||||||
|
setError("Solicitação inválida.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H1_H2", "detail_fetch_start", {
|
||||||
|
solicitacaoId,
|
||||||
|
endpoint: `/api/solicitacoes/${solicitacaoId}/`,
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
const res = await fetch(`/api/solicitacoes/${solicitacaoId}/`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
// #region agent log
|
||||||
|
let responsePreview = "";
|
||||||
|
try {
|
||||||
|
responsePreview = (await res.clone().text()).slice(0, 300);
|
||||||
|
} catch {
|
||||||
|
responsePreview = "unreadable_response";
|
||||||
|
}
|
||||||
|
debugLog("H1_H3_H4", "detail_fetch_response", {
|
||||||
|
solicitacaoId,
|
||||||
|
status: res.status,
|
||||||
|
ok: res.ok,
|
||||||
|
responsePreview,
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H5", "detail_fetch_unauthorized_redirect", {
|
||||||
|
solicitacaoId,
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
router.push(`/tela_login?next=${encodeURIComponent(`/solicitacoes/${solicitacaoId}`)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) {
|
||||||
|
setError("Solicitação não encontrada ou sem permissão.");
|
||||||
|
} else {
|
||||||
|
setError("Erro ao carregar detalhes da solicitação.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await res.json()) as SolicitacaoDetalhe;
|
||||||
|
setData(json);
|
||||||
|
} catch {
|
||||||
|
setError("Erro de conexão ao carregar detalhes.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
carregarDetalhe();
|
||||||
|
}, [router, solicitacaoId, reloadTick]);
|
||||||
|
|
||||||
|
async function handleEnviar() {
|
||||||
|
if (!data) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
setActionFeedback("");
|
||||||
|
try {
|
||||||
|
await apiPostJson(`/api/solicitacoes/${data.id}/enviar/`, {});
|
||||||
|
setActionFeedback("Solicitação enviada para aprovação.");
|
||||||
|
setReloadTick((v) => v + 1);
|
||||||
|
} catch (err) {
|
||||||
|
setActionFeedback(err instanceof ApiError ? err.message : "Erro ao enviar solicitação.");
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleParecer() {
|
||||||
|
if (!data) return;
|
||||||
|
if (!parecerTexto.trim()) {
|
||||||
|
setActionFeedback("Digite o texto do parecer.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActionLoading(true);
|
||||||
|
setActionFeedback("");
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("texto", parecerTexto.trim());
|
||||||
|
if (parecerAnexo) fd.append("anexo", parecerAnexo);
|
||||||
|
await apiPostFormData(`/api/solicitacoes/${data.id}/parecer/`, fd);
|
||||||
|
setParecerTexto("");
|
||||||
|
setParecerAnexo(null);
|
||||||
|
setActionFeedback("Parecer registrado com sucesso.");
|
||||||
|
setReloadTick((v) => v + 1);
|
||||||
|
} catch (err) {
|
||||||
|
setActionFeedback(err instanceof ApiError ? err.message : "Erro ao registrar parecer.");
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecisao() {
|
||||||
|
if (!data) return;
|
||||||
|
if (decisao === "REPROVADO" && !justificativaDecisao.trim()) {
|
||||||
|
setActionFeedback("Justificativa é obrigatória para reprovação.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActionLoading(true);
|
||||||
|
setActionFeedback("");
|
||||||
|
try {
|
||||||
|
await apiPostJson(`/api/solicitacoes/${data.id}/decidir/`, {
|
||||||
|
decisao,
|
||||||
|
justificativa: justificativaDecisao,
|
||||||
|
});
|
||||||
|
setActionFeedback("Decisão registrada com sucesso.");
|
||||||
|
setJustificativaDecisao("");
|
||||||
|
setReloadTick((v) => v + 1);
|
||||||
|
} catch (err) {
|
||||||
|
setActionFeedback(err instanceof ApiError ? err.message : "Erro ao registrar decisão.");
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-100">
|
||||||
|
<p className="text-slate-600">Carregando detalhes…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-100 p-6">
|
||||||
|
<div className="max-w-4xl mx-auto bg-white border border-slate-200 rounded-xl p-6">
|
||||||
|
<p className="text-red-600 font-medium">{error || "Falha ao carregar detalhes."}</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Link href="/dashboard" className="text-blue-600 font-semibold hover:underline">
|
||||||
|
Voltar para dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-100 p-4 md:p-6">
|
||||||
|
<div className="max-w-5xl mx-auto space-y-4 text-slate-900">
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<h1 className="text-xl md:text-2xl font-bold text-slate-900">
|
||||||
|
{data.tipo_display}
|
||||||
|
</h1>
|
||||||
|
<span className="inline-flex py-1 px-2.5 rounded-full text-xs font-bold border bg-slate-100 text-slate-700 border-slate-200">
|
||||||
|
{data.status_display}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-base text-slate-800 mt-2 font-medium">
|
||||||
|
Solicitação: <strong>{data.id}</strong>
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-4 text-base text-slate-800 font-medium">
|
||||||
|
<span>Criada em: {formatarData(data.criado_em)}</span>
|
||||||
|
<span>Enviada em: {formatarData(data.enviada_em)}</span>
|
||||||
|
<span>Finalizada em: {formatarData(data.finalizada_em)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center gap-4 flex-wrap">
|
||||||
|
<Link href="/dashboard" className="text-blue-600 font-semibold hover:underline">
|
||||||
|
Voltar para dashboard
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`${DJANGO_BASE_URL}/solicitacao/${data.id}/comprovante.pdf`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 font-semibold hover:underline"
|
||||||
|
>
|
||||||
|
Download PDF
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="bg-white border border-slate-200 rounded-xl p-5">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 mb-3">Dados gerais</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-base">
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-700 uppercase text-sm font-bold mb-1">Tipo de processo</div>
|
||||||
|
<div className="text-slate-900 font-semibold">{data.tipo_display}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-700 uppercase text-sm font-bold mb-1">Solicitante</div>
|
||||||
|
<div className="text-slate-900 font-semibold">
|
||||||
|
{data.solicitante?.nome || "—"} ({data.solicitante?.matricula || "—"})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-700 uppercase text-sm font-bold mb-1">Status</div>
|
||||||
|
<div className="text-slate-900 font-semibold">{data.status_display}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{data.funcionario && (
|
||||||
|
<section className="bg-white border border-slate-200 rounded-xl p-5 border-l-4 border-l-blue-500">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 mb-3">Dados do colaborador (TOTVS RM)</h2>
|
||||||
|
<p className="text-sm text-slate-700 mb-4 font-medium">Snapshot no momento da criação da solicitação</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-base">
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Matrícula</div><div className="font-semibold">{data.funcionario.matricula || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Nome completo</div><div className="font-bold">{data.funcionario.nome || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">CPF</div><div className="font-semibold">{data.funcionario.cpf || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Data admissão</div><div className="font-semibold">{formatarData(data.funcionario.data_admissao)}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Cargo/Função</div><div className="font-semibold">{data.funcionario.cargo || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Cód. função</div><div className="font-semibold">{data.funcionario.cod_funcao || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Setor/Seção</div><div className="font-semibold">{data.funcionario.setor || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Centro de custo</div><div className="font-semibold">{data.funcionario.centro_custo || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Salário atual</div><div className="font-semibold text-emerald-700">{formatarDinheiro(data.funcionario.salario)}</div></div>
|
||||||
|
</div>
|
||||||
|
{data.funcionario.saldo_banco_horas_minutos !== null && (
|
||||||
|
<div className="mt-5 pt-4 border-t border-slate-100 grid grid-cols-1 md:grid-cols-2 gap-4 text-base">
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-700 uppercase text-sm font-bold mb-1">Saldo banco de horas</div>
|
||||||
|
<div className="font-semibold">
|
||||||
|
{data.funcionario.saldo_banco_horas_minutos >= 0 ? "+" : ""}
|
||||||
|
{data.funcionario.saldo_banco_horas_minutos} min
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-700 uppercase text-sm font-bold mb-1">Período referência</div>
|
||||||
|
<div className="font-semibold">
|
||||||
|
{formatarData(data.funcionario.inicio_periodo_banco_horas)} a {formatarData(data.funcionario.fim_periodo_banco_horas)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.funcionario.sincronizado_em && (
|
||||||
|
<p className="text-sm text-slate-600 mt-4 text-right font-medium">
|
||||||
|
Sincronizado com RM em {formatarData(data.funcionario.sincronizado_em)} | ID: {data.funcionario.id_rm || "—"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.dados_winthor && (
|
||||||
|
<section className="bg-white border border-slate-200 rounded-xl p-5 border-l-4 border-l-blue-500">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 mb-3">Dados do colaborador (Winthor)</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-base">
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Matrícula</div><div className="font-semibold">{data.dados_winthor.basicos.matricula || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Nome</div><div className="font-semibold">{data.dados_winthor.basicos.nome || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">CPF</div><div className="font-semibold">{data.dados_winthor.basicos.cpf || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Data admissão</div><div className="font-semibold">{formatarData(data.dados_winthor.admissao.admissao)}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Situação</div><div className="font-semibold">{data.dados_winthor.admissao.situacao || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Data exclusão</div><div className="font-semibold">{formatarData(data.dados_winthor.admissao.dt_exclusao)}</div></div>
|
||||||
|
<div className="md:col-span-3"><div className="text-slate-700 uppercase text-sm font-bold mb-1">Endereço</div><div className="font-semibold">{data.dados_winthor.endereco.endereco || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Bairro</div><div className="font-semibold">{data.dados_winthor.endereco.bairro || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Cidade</div><div className="font-semibold">{data.dados_winthor.endereco.cidade || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Estado</div><div className="font-semibold">{data.dados_winthor.endereco.estado || "—"}</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.detalhes_tipo && (
|
||||||
|
<section className="bg-white border border-slate-200 rounded-xl p-5">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 mb-3">Detalhes da movimentação</h2>
|
||||||
|
{data.detalhes_tipo.tipo === "DESLIGAMENTO" && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-base">
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Tipo desligamento</div><div className="font-semibold">{(data.detalhes_tipo.tipo_desligamento_display as string) || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Aviso prévio</div><div className="font-semibold">{(data.detalhes_tipo.aviso_previo_display as string) || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Data prevista saída</div><div className="font-semibold">{formatarData((data.detalhes_tipo.data_prevista_desligamento as string) || null)}</div></div>
|
||||||
|
<div className="md:col-span-3"><div className="text-slate-700 uppercase text-sm font-bold mb-1">Motivo</div><div className="font-semibold whitespace-pre-wrap">{(data.detalhes_tipo.motivo as string) || "—"}</div></div>
|
||||||
|
{(data.detalhes_tipo.observacoes as string) && <div className="md:col-span-3"><div className="text-slate-700 uppercase text-sm font-bold mb-1">Observações</div><div className="font-semibold whitespace-pre-wrap">{data.detalhes_tipo.observacoes as string}</div></div>}
|
||||||
|
{(data.detalhes_tipo.arquivo_pedido_url as string) && (
|
||||||
|
<div className="md:col-span-3">
|
||||||
|
<a href={data.detalhes_tipo.arquivo_pedido_url as string} target="_blank" rel="noopener noreferrer" className="text-blue-600 font-semibold hover:underline">
|
||||||
|
📎 Ver carta de pedido
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.detalhes_tipo.tipo === "MOVIMENTACAO" && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-base">
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Nova função</div><div className="font-semibold">{(data.detalhes_tipo.novo_cod_funcao as string) || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Nova seção</div><div className="font-semibold">{(data.detalhes_tipo.novo_cod_secao as string) || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Novo salário</div><div className="font-semibold">{formatarDinheiro((data.detalhes_tipo.novo_salario as string) || null)}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Data efetivação</div><div className="font-semibold">{formatarData((data.detalhes_tipo.data_efetivacao as string) || null)}</div></div>
|
||||||
|
<div className="md:col-span-3"><div className="text-slate-700 uppercase text-sm font-bold mb-1">Justificativa</div><div className="font-semibold whitespace-pre-wrap">{(data.detalhes_tipo.justificativa as string) || "—"}</div></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.detalhes_tipo.tipo === "ADM_SUBSTITUICAO" && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-base">
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Data prevista</div><div className="font-semibold">{formatarData((data.detalhes_tipo.data_previsao_contratacao as string) || null)}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Coligada/Filial</div><div className="font-semibold">{String(data.detalhes_tipo.cod_coligada_destino || "—")} / {String(data.detalhes_tipo.cod_filial_destino || "—")}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Seção destino</div><div className="font-semibold">{(data.detalhes_tipo.cod_secao_destino as string) || "—"}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Função destino</div><div className="font-semibold">{(data.detalhes_tipo.cod_funcao_destino as string) || "—"}</div></div>
|
||||||
|
<div className="md:col-span-3"><div className="text-slate-700 uppercase text-sm font-bold mb-1">Justificativa</div><div className="font-semibold whitespace-pre-wrap">{(data.detalhes_tipo.justificativa as string) || "—"}</div></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.detalhes_tipo.tipo === "ADM_AUMENTO" && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-base">
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Data prevista</div><div className="font-semibold">{formatarData((data.detalhes_tipo.data_previsao_contratacao as string) || null)}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Local destino</div><div className="font-semibold">Col: {String(data.detalhes_tipo.cod_coligada_destino || "—")} / Fil: {String(data.detalhes_tipo.cod_filial_destino || "—")}</div></div>
|
||||||
|
<div><div className="text-slate-700 uppercase text-sm font-bold mb-1">Suplementação orçamentária</div><div className="font-semibold">{data.detalhes_tipo.requer_suplementacao_orcamentaria ? "Sim" : "Não"}</div></div>
|
||||||
|
<div className="md:col-span-3"><div className="text-slate-700 uppercase text-sm font-bold mb-1">Justificativa estratégica</div><div className="font-semibold whitespace-pre-wrap">{(data.detalhes_tipo.justificativa_estrategica as string) || "—"}</div></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="bg-white border border-slate-200 rounded-xl p-5">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 mb-3">Pareceres</h2>
|
||||||
|
{data.pareceres.length === 0 ? (
|
||||||
|
<p className="text-base text-slate-700">Nenhum parecer registrado.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{data.pareceres.map((parecer) => (
|
||||||
|
<article key={parecer.id} className="border border-slate-200 rounded-lg p-3">
|
||||||
|
<p className="text-base font-bold text-slate-900">
|
||||||
|
{parecer.etapa_display} · {parecer.usuario_nome || "Usuário"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-700 mb-2 font-medium">{formatarData(parecer.criado_em)}</p>
|
||||||
|
<p className="text-base text-slate-800 whitespace-pre-wrap font-medium">{parecer.texto}</p>
|
||||||
|
{parecer.anexo_url && (
|
||||||
|
<a
|
||||||
|
href={toAbsoluteDjangoUrl(parecer.anexo_url)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-block mt-2 text-blue-600 text-sm font-semibold hover:underline"
|
||||||
|
>
|
||||||
|
📎 Ver anexo
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-white border border-slate-200 rounded-xl p-5">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 mb-3">Aprovações</h2>
|
||||||
|
{data.aprovacoes.length === 0 ? (
|
||||||
|
<p className="text-base text-slate-700">Nenhuma aprovação registrada.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.aprovacoes.map((aprovacao) => (
|
||||||
|
<article key={aprovacao.id} className="border border-slate-200 rounded-lg p-3">
|
||||||
|
<p className="text-base font-bold text-slate-900">
|
||||||
|
{aprovacao.etapa_display} · {aprovacao.decisao_display}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-700 font-medium">
|
||||||
|
{aprovacao.usuario_nome || "Usuário"} · {formatarData(aprovacao.decidido_em)}
|
||||||
|
</p>
|
||||||
|
<p className="text-base text-slate-800 mt-1 whitespace-pre-wrap font-medium">
|
||||||
|
{aprovacao.justificativa || "Sem justificativa."}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{data.acoes.pode_enviar && data.acoes.is_solicitante && (
|
||||||
|
<section className="bg-sky-50 border border-sky-200 rounded-xl p-5">
|
||||||
|
<h2 className="text-base font-semibold text-sky-900 mb-1">Enviar para aprovação</h2>
|
||||||
|
<p className="text-sm text-sky-800 mb-3">
|
||||||
|
Sua solicitação está em rascunho. Revise os dados e envie para iniciar o fluxo.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleEnviar}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="inline-flex items-center gap-1.5 py-2 px-4 rounded-md text-sm font-semibold bg-blue-600 text-white no-underline hover:bg-blue-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Confirmar envio
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.acoes.pode_dar_parecer && (
|
||||||
|
<section className="bg-white border border-slate-200 rounded-xl p-5">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 mb-2">Registrar parecer</h2>
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-slate-300 rounded-md p-2 min-h-24"
|
||||||
|
value={parecerTexto}
|
||||||
|
onChange={(e) => setParecerTexto(e.target.value)}
|
||||||
|
placeholder="Digite seu parecer técnico..."
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="mt-3 w-full border border-slate-300 rounded-md p-2"
|
||||||
|
onChange={(e) => setParecerAnexo(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleParecer}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="mt-3 px-4 py-2 rounded-md bg-blue-600 text-white font-semibold hover:bg-blue-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Salvar parecer
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.acoes.pode_aprovar && (
|
||||||
|
<section className="bg-white border border-slate-200 rounded-xl p-5">
|
||||||
|
<h2 className="text-base font-semibold text-slate-900 mb-2">Registrar decisão</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<select
|
||||||
|
className="border border-slate-300 rounded-md p-2"
|
||||||
|
value={decisao}
|
||||||
|
onChange={(e) => setDecisao(e.target.value as "APROVADO" | "REPROVADO")}
|
||||||
|
>
|
||||||
|
<option value="APROVADO">Aprovar</option>
|
||||||
|
<option value="REPROVADO">Reprovar</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="border border-slate-300 rounded-md p-2"
|
||||||
|
placeholder="Justificativa (obrigatória para reprovação)"
|
||||||
|
value={justificativaDecisao}
|
||||||
|
onChange={(e) => setJustificativaDecisao(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDecisao}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="mt-3 px-4 py-2 rounded-md bg-blue-600 text-white font-semibold hover:bg-blue-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Confirmar decisão
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionFeedback && (
|
||||||
|
<p className="text-sm font-semibold text-slate-800 bg-slate-200 px-3 py-2 rounded">
|
||||||
|
{actionFeedback}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default function SolicitacoesPage() {
|
||||||
|
return(
|
||||||
|
<div>
|
||||||
|
<h1> Solicitações</h1>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const next = searchParams.get("next") || "/dashboard";
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// #region agent log
|
||||||
|
function debugLog(hypothesisId: string, message: string, data: Record<string, unknown>) {
|
||||||
|
fetch("http://localhost:7701/ingest/5073259c-ddcc-441a-a087-e13a2cf7ac9e", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Debug-Session-Id": "ca90b5",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: "ca90b5",
|
||||||
|
runId: "login-debug-01",
|
||||||
|
hypothesisId,
|
||||||
|
location: "frontend/app/tela_login/page.tsx:debugLog",
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (!username.trim() || !password.trim()) {
|
||||||
|
setError("Informe usuário e senha.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H4", "login_submit_start", {
|
||||||
|
endpoint: "/api/api/auth/login/",
|
||||||
|
next,
|
||||||
|
usernameLength: username.trim().length,
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
const res = await fetch("/api/api/auth/login/", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ username: username.trim(), password, next }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H5", "login_submit_response_body", {
|
||||||
|
status: res.status,
|
||||||
|
ok: res.ok,
|
||||||
|
success: (data as { success?: boolean }).success,
|
||||||
|
hasSuccessKey: Object.prototype.hasOwnProperty.call(data, "success"),
|
||||||
|
redirect: (data as { redirect?: string })?.redirect || null,
|
||||||
|
message: (data as { message?: string })?.message || null,
|
||||||
|
});
|
||||||
|
debugLog("H1_H3", "login_submit_response", {
|
||||||
|
status: res.status,
|
||||||
|
ok: res.ok,
|
||||||
|
redirect: (data as { redirect?: string })?.redirect || null,
|
||||||
|
message: (data as { message?: string })?.message || null,
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.message || "Erro ao autenticar. Tente novamente.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.success && data.redirect) {
|
||||||
|
router.push(data.redirect);
|
||||||
|
} else {
|
||||||
|
router.push("/dashboard");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// #region agent log
|
||||||
|
debugLog("H2", "login_submit_fetch_exception", {
|
||||||
|
apiBaseHint: "proxied_by_next_api_route",
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
setError("Erro de conexão. Verifique se o backend está rodando.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full h-screen overflow-hidden">
|
||||||
|
{/* Painel esquerdo - visual */}
|
||||||
|
<div className="hidden md:flex flex-1 flex-col justify-center items-center text-white text-center p-8 bg-gradient-to-br from-[#1e3a8a] to-[#3b82f6] relative">
|
||||||
|
<div className="absolute top-5 right-5 bg-white/15 backdrop-blur-sm py-1.5 px-3 rounded-full text-xs font-semibold tracking-wide border border-white/20">
|
||||||
|
PROD
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl font-bold mb-4 tracking-tight">SGMP</h1>
|
||||||
|
<p className="text-xl opacity-90 max-w-md">
|
||||||
|
Sistema de Gestão e Movimentações de Pessoas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Painel direito - formulário */}
|
||||||
|
<div className="flex-1 flex justify-center items-center bg-white p-8">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-2xl font-semibold text-slate-800 mb-1">
|
||||||
|
Bem-vindo
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500">Insira suas credenciais para acessar.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-3 rounded-lg bg-red-50 text-red-800 text-sm border border-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="block mb-2 font-medium text-slate-700 text-sm"
|
||||||
|
>
|
||||||
|
Usuário
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Matrícula Winthor"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-4 py-3 border border-slate-200 rounded-lg text-base focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block mb-2 font-medium text-slate-700 text-sm"
|
||||||
|
>
|
||||||
|
Senha
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Senha Winthor"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-3 border border-slate-200 rounded-lg text-base focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg text-base transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Entrando…" : "Entrar"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-slate-500">
|
||||||
|
Use suas credenciais do Winthor (matrícula e senha).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TelaLoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-white">
|
||||||
|
<p className="text-slate-500">Carregando…</p>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default function UsuariosPage() {
|
||||||
|
return(
|
||||||
|
<div>
|
||||||
|
<h1> usuarios</h1>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
type FieldProps = {
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
required?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Field({ label, error, required, children }: FieldProps) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="block text-sm font-bold text-slate-700 mb-1">
|
||||||
|
{label}
|
||||||
|
{required ? <span className="text-red-600"> *</span> : null}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
{error ? <span className="text-xs text-red-600 mt-1 block">{error}</span> : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
payload: unknown;
|
||||||
|
|
||||||
|
constructor(message: string, status: number, payload: unknown = null) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseResponse<T>(res: Response): Promise<T> {
|
||||||
|
const contentType = res.headers.get("content-type") || "";
|
||||||
|
const isJson = contentType.includes("application/json");
|
||||||
|
const payload = isJson ? await res.json() : await res.text();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message =
|
||||||
|
(typeof payload === "object" &&
|
||||||
|
payload &&
|
||||||
|
"error" in payload &&
|
||||||
|
typeof (payload as { error?: unknown }).error === "string" &&
|
||||||
|
(payload as { error: string }).error) ||
|
||||||
|
`Erro HTTP ${res.status}`;
|
||||||
|
throw new ApiError(message, res.status, payload);
|
||||||
|
}
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGet<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
return parseResponse<T>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPostJson<T>(url: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return parseResponse<T>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPostFormData<T>(url: string, body: FormData): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return parseResponse<T>(res);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
export type ValidationErrors = Record<string, string>;
|
||||||
|
|
||||||
|
function required(value: unknown) {
|
||||||
|
return String(value ?? "").trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSolicitacaoPayload(payload: Record<string, unknown>): ValidationErrors {
|
||||||
|
const errors: ValidationErrors = {};
|
||||||
|
const tipo = String(payload.tipo || "");
|
||||||
|
|
||||||
|
if (!required(tipo)) errors.tipo = "Selecione o tipo de solicitação.";
|
||||||
|
|
||||||
|
if (tipo !== "ADM_AUMENTO" && !required(payload.funcionario_id)) {
|
||||||
|
errors.funcionario_id = "Selecione o colaborador.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tipo === "DESLIGAMENTO") {
|
||||||
|
if (!required(payload.tipo_desligamento)) errors.tipo_desligamento = "Informe o tipo de desligamento.";
|
||||||
|
if (!required(payload.aviso_previo)) errors.aviso_previo = "Informe o aviso prévio.";
|
||||||
|
if (!required(payload.data_prevista_desligamento)) errors.data_prevista_desligamento = "Informe a data prevista.";
|
||||||
|
if (!required(payload.motivo)) errors.motivo = "Informe a justificativa.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tipo === "MOVIMENTACAO") {
|
||||||
|
if (!required(payload.data_efetivacao)) errors.data_efetivacao = "Informe a data de efetivação.";
|
||||||
|
if (!required(payload.justificativa)) errors.justificativa = "Informe a justificativa.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tipo === "ADM_SUBSTITUICAO") {
|
||||||
|
if (!required(payload.data_previsao_contratacao)) errors.data_previsao_contratacao = "Informe a data prevista.";
|
||||||
|
if (!required(payload.cod_coligada_destino)) errors.cod_coligada_destino = "Informe a coligada.";
|
||||||
|
if (!required(payload.cod_filial_destino)) errors.cod_filial_destino = "Informe a filial.";
|
||||||
|
if (!required(payload.cod_secao_destino)) errors.cod_secao_destino = "Informe a seção.";
|
||||||
|
if (!required(payload.cod_funcao_destino)) errors.cod_funcao_destino = "Informe a função.";
|
||||||
|
if (!required(payload.justificativa)) errors.justificativa = "Informe a justificativa.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tipo === "ADM_AUMENTO") {
|
||||||
|
if (!required(payload.data_previsao_contratacao)) errors.data_previsao_contratacao = "Informe a data prevista.";
|
||||||
|
if (!required(payload.cod_coligada_destino)) errors.cod_coligada_destino = "Informe a coligada.";
|
||||||
|
if (!required(payload.cod_filial_destino)) errors.cod_filial_destino = "Informe a filial.";
|
||||||
|
if (!required(payload.cod_secao_destino)) errors.cod_secao_destino = "Informe a seção.";
|
||||||
|
if (!required(payload.cod_funcao_destino)) errors.cod_funcao_destino = "Informe a função.";
|
||||||
|
if (!required(payload.justificativa_estrategica)) errors.justificativa_estrategica = "Informe a justificativa.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
reactCompiler: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "16.2.1",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.1",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
|
|
@ -8,4 +8,5 @@ oracledb==2.0.1
|
||||||
pymssql==2.2.7
|
pymssql==2.2.7
|
||||||
python-dotenv
|
python-dotenv
|
||||||
django-cors-headers
|
django-cors-headers
|
||||||
python-dateutil
|
python-dateutil
|
||||||
|
weasyprint>=60.0
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Controle de acesso a rotas sensíveis com base em ConfiguracaoSGMP e UsuarioSistema.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
|
||||||
|
from .models import ConfiguracaoSGMP, UsuarioSistema
|
||||||
|
|
||||||
|
|
||||||
|
def get_perfis_gerenciar_permissoes() -> list[str]:
|
||||||
|
"""Retorna os códigos de perfil autorizados a acessar /permissoes/ (config Admin)."""
|
||||||
|
cfg = ConfiguracaoSGMP.load()
|
||||||
|
perfis = cfg.perfis_gerenciar_permissoes
|
||||||
|
if not perfis:
|
||||||
|
return [UsuarioSistema.Perfil.ADMIN]
|
||||||
|
return list(perfis)
|
||||||
|
|
||||||
|
|
||||||
|
def usuario_pode_gerenciar_permissoes(usuario: UsuarioSistema | None) -> bool:
|
||||||
|
if not usuario or not usuario.ativo:
|
||||||
|
return False
|
||||||
|
perfis = get_perfis_gerenciar_permissoes()
|
||||||
|
return any(usuario.tem_perfil(p) for p in perfis)
|
||||||
|
|
||||||
|
|
||||||
|
def requer_acesso_gerenciar_permissoes(view_func):
|
||||||
|
"""
|
||||||
|
Exige login, UsuarioSistema ativo e perfil permitido pela ConfiguracaoSGMP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(request: HttpRequest, *args, **kwargs):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
messages.error(request, "Você precisa estar autenticado.")
|
||||||
|
return redirect("solicitacoes:login")
|
||||||
|
|
||||||
|
try:
|
||||||
|
usuario = UsuarioSistema.objects.get(
|
||||||
|
matricula=request.user.username,
|
||||||
|
ativo=True,
|
||||||
|
)
|
||||||
|
except UsuarioSistema.DoesNotExist:
|
||||||
|
messages.error(request, "Usuário não encontrado no sistema.")
|
||||||
|
return redirect("solicitacoes:login")
|
||||||
|
|
||||||
|
if not usuario_pode_gerenciar_permissoes(usuario):
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
"Você não tem permissão para acessar o gerenciamento de permissões.",
|
||||||
|
)
|
||||||
|
return redirect("solicitacoes:dashboard")
|
||||||
|
|
||||||
|
request.usuario_sistema = usuario
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
@ -1,12 +1,65 @@
|
||||||
|
from django import forms
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
from django.urls import path
|
from django.core.exceptions import ValidationError
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import path, reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from .models import HeadGestor, PessoaRM, UsuarioSistema
|
|
||||||
from .intf_sqlserver import listar_para_selecionar_colaborador
|
from .intf_sqlserver import listar_para_selecionar_colaborador
|
||||||
from .intf_winthor import buscar_colaborador_oracle
|
from .intf_winthor import buscar_colaborador_oracle
|
||||||
from .models import Solicitacao
|
from .models import ConfiguracaoSGMP, HeadGestor, PessoaRM, Solicitacao, UsuarioSistema
|
||||||
|
|
||||||
|
|
||||||
|
class ConfiguracaoSGMPForm(forms.ModelForm):
|
||||||
|
perfis_gerenciar_permissoes = forms.MultipleChoiceField(
|
||||||
|
label="Perfis com acesso à tela Gerenciar Permissões (/permissoes/)",
|
||||||
|
choices=UsuarioSistema.Perfil.choices,
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ConfiguracaoSGMP
|
||||||
|
fields = ("perfis_gerenciar_permissoes",)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if self.instance.pk:
|
||||||
|
v = self.instance.perfis_gerenciar_permissoes
|
||||||
|
if v is not None:
|
||||||
|
self.initial["perfis_gerenciar_permissoes"] = list(v)
|
||||||
|
else:
|
||||||
|
self.initial.setdefault(
|
||||||
|
"perfis_gerenciar_permissoes",
|
||||||
|
[UsuarioSistema.Perfil.ADMIN],
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_perfis_gerenciar_permissoes(self):
|
||||||
|
data = self.cleaned_data.get("perfis_gerenciar_permissoes") or []
|
||||||
|
if not data:
|
||||||
|
raise ValidationError("Selecione ao menos um perfil.")
|
||||||
|
valid = {c[0] for c in UsuarioSistema.Perfil.choices}
|
||||||
|
cleaned = [x for x in data if x in valid]
|
||||||
|
if not cleaned:
|
||||||
|
raise ValidationError("Nenhum código de perfil válido selecionado.")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ConfiguracaoSGMP)
|
||||||
|
class ConfiguracaoSGMPAdmin(admin.ModelAdmin):
|
||||||
|
form = ConfiguracaoSGMPForm
|
||||||
|
list_display = ("__str__",)
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return not ConfiguracaoSGMP.objects.filter(pk=1).exists()
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def changelist_view(self, request, extra_context=None):
|
||||||
|
if ConfiguracaoSGMP.objects.filter(pk=1).exists():
|
||||||
|
return redirect(reverse("admin:solicitacoes_configuracaosgmp_change", args=(1,)))
|
||||||
|
return super().changelist_view(request, extra_context)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PessoaRM)
|
@admin.register(PessoaRM)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
# SGMP_PROD/solicitacoes/api_urls.py
|
||||||
|
from django.urls import path
|
||||||
|
from . import api_views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("auth/login/", api_views.api_login),
|
||||||
|
path("auth/logout/", api_views.api_logout),
|
||||||
|
path("auth/me/", api_views.api_me),
|
||||||
|
path("dashboard/", api_views.api_dashboard),
|
||||||
|
path("colaboradores/", api_views.api_colaboradores),
|
||||||
|
path("nova-solicitacao/metadata/", api_views.api_nova_solicitacao_metadata),
|
||||||
|
path("solicitacoes/", api_views.api_solicitacao_criar),
|
||||||
|
path("solicitacoes/<uuid:solicitacao_id>/", api_views.api_solicitacao_detalhe),
|
||||||
|
path("solicitacoes/<uuid:solicitacao_id>/enviar/", api_views.api_solicitacao_enviar),
|
||||||
|
path("solicitacoes/<uuid:solicitacao_id>/decidir/", api_views.api_solicitacao_decidir),
|
||||||
|
path("solicitacoes/<uuid:solicitacao_id>/parecer/", api_views.api_solicitacao_parecer),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,803 @@
|
||||||
|
# SGMP_PROD/solicitacoes/api_views.py
|
||||||
|
"""
|
||||||
|
APIs REST para integração com o frontend Next.js.
|
||||||
|
Usado na migração gradual de telas do Django para o frontend.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import date
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
from django.contrib.auth import login, logout, get_user_model
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
UsuarioSistema,
|
||||||
|
Solicitacao,
|
||||||
|
StatusSolicitacao,
|
||||||
|
EtapaAprovacao,
|
||||||
|
DecisaoAprovacao,
|
||||||
|
PessoaRM,
|
||||||
|
matriculas_gestores_do_head,
|
||||||
|
)
|
||||||
|
from . import services
|
||||||
|
from .intf_sqlserver import (
|
||||||
|
listar_cargos_ativos_rm,
|
||||||
|
listar_secoes_ativas_rm,
|
||||||
|
listar_coligadas_rm,
|
||||||
|
buscar_colaboradores_rm,
|
||||||
|
)
|
||||||
|
from .intf_winthor import autenticar_usuario, buscar_colaborador_oracle
|
||||||
|
from .views import get_usuario_sistema
|
||||||
|
from .debug_status_log import log_dashboard_row
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# #region agent log
|
||||||
|
_API_DEBUG_LOG = "/home/f3lipe/dev/.cursor/debug-ca90b5.log"
|
||||||
|
|
||||||
|
|
||||||
|
def _api_debug_log(hypothesis_id: str, location: str, message: str, data: dict) -> None:
|
||||||
|
try:
|
||||||
|
with open(_API_DEBUG_LOG, "a", encoding="utf-8") as f:
|
||||||
|
f.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"sessionId": "ca90b5",
|
||||||
|
"hypothesisId": hypothesis_id,
|
||||||
|
"location": location,
|
||||||
|
"message": message,
|
||||||
|
"data": data,
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# #endregion
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_body(request):
|
||||||
|
"""Extrai body JSON do request."""
|
||||||
|
try:
|
||||||
|
return json.loads(request.body) if request.body else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_date(value, field_name):
|
||||||
|
if not value:
|
||||||
|
raise services.ValidacaoError(f"Campo obrigatório ausente: {field_name}.")
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(str(value))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise services.ValidacaoError(f"Data inválida em {field_name}. Use AAAA-MM-DD.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_bool(value):
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return str(value).strip().lower() in {"1", "true", "on", "sim", "yes"}
|
||||||
|
|
||||||
|
|
||||||
|
def _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario):
|
||||||
|
"""Fila do papel OR solicitações em que o usuário é o solicitante (distinct)."""
|
||||||
|
qs_minhas = Solicitacao.objects.filter(solicitante=usuario)
|
||||||
|
return (qs_fila | qs_minhas).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def _qs_dashboard_for_usuario(usuario):
|
||||||
|
"""Retorna queryset base conforme regras de visibilidade do dashboard."""
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN):
|
||||||
|
return Solicitacao.objects.all()
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.GESTOR):
|
||||||
|
return Solicitacao.objects.filter(solicitante=usuario)
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.HEAD):
|
||||||
|
matriculas = matriculas_gestores_do_head(usuario)
|
||||||
|
qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.AGUARDANDO_HEAD)
|
||||||
|
if matriculas:
|
||||||
|
qs_fila = qs_fila.filter(solicitante__matricula__in=matriculas)
|
||||||
|
return _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario)
|
||||||
|
if (
|
||||||
|
usuario.tem_perfil(UsuarioSistema.Perfil.GG)
|
||||||
|
or usuario.tem_perfil(UsuarioSistema.Perfil.CONTROLADORIA)
|
||||||
|
):
|
||||||
|
qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.ENVIADA)
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.GG) and not usuario.tem_perfil(UsuarioSistema.Perfil.CONTROLADORIA):
|
||||||
|
qs_fila = qs_fila.exclude(pareceres__etapa=EtapaAprovacao.GG, pareceres__usuario=usuario)
|
||||||
|
elif usuario.tem_perfil(UsuarioSistema.Perfil.CONTROLADORIA) and not usuario.tem_perfil(UsuarioSistema.Perfil.GG):
|
||||||
|
qs_fila = qs_fila.exclude(pareceres__etapa=EtapaAprovacao.CONTROLADORIA, pareceres__usuario=usuario)
|
||||||
|
return _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario)
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.DIRETORIA):
|
||||||
|
qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.AGUARDANDO_DIRETORIA)
|
||||||
|
return _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario)
|
||||||
|
return Solicitacao.objects.none()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_aprovacao(aprovacao):
|
||||||
|
return {
|
||||||
|
"id": str(aprovacao.id),
|
||||||
|
"etapa": aprovacao.etapa,
|
||||||
|
"etapa_display": aprovacao.get_etapa_display(),
|
||||||
|
"decisao": aprovacao.decisao,
|
||||||
|
"decisao_display": aprovacao.get_decisao_display(),
|
||||||
|
"justificativa": aprovacao.justificativa,
|
||||||
|
"decidido_em": aprovacao.decidido_em.isoformat() if aprovacao.decidido_em else None,
|
||||||
|
"usuario_nome": aprovacao.usuario.nome if aprovacao.usuario else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_parecer(parecer):
|
||||||
|
return {
|
||||||
|
"id": str(parecer.id),
|
||||||
|
"etapa": parecer.etapa,
|
||||||
|
"etapa_display": parecer.get_etapa_display(),
|
||||||
|
"texto": parecer.texto,
|
||||||
|
"criado_em": parecer.criado_em.isoformat() if parecer.criado_em else None,
|
||||||
|
"usuario_nome": parecer.usuario.nome if parecer.usuario else None,
|
||||||
|
"anexo_url": parecer.anexo.url if getattr(parecer, "anexo", None) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if hasattr(value, "isoformat"):
|
||||||
|
return value.isoformat()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_funcionario_rm(funcionario):
|
||||||
|
if not funcionario:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": str(funcionario.id),
|
||||||
|
"id_rm": funcionario.id_rm,
|
||||||
|
"matricula": funcionario.matricula,
|
||||||
|
"nome": funcionario.nome,
|
||||||
|
"cpf": funcionario.cpf,
|
||||||
|
"data_admissao": _json_value(funcionario.data_admissao),
|
||||||
|
"situacao": funcionario.situacao,
|
||||||
|
"cargo": funcionario.cargo,
|
||||||
|
"setor": funcionario.setor,
|
||||||
|
"centro_custo": funcionario.centro_custo,
|
||||||
|
"cod_funcao": funcionario.cod_funcao,
|
||||||
|
"salario": str(funcionario.salario) if funcionario.salario is not None else None,
|
||||||
|
"cod_sindicato": funcionario.cod_sindicato,
|
||||||
|
"saldo_banco_horas_minutos": funcionario.saldo_banco_horas_minutos,
|
||||||
|
"inicio_periodo_banco_horas": _json_value(funcionario.inicio_periodo_banco_horas),
|
||||||
|
"fim_periodo_banco_horas": _json_value(funcionario.fim_periodo_banco_horas),
|
||||||
|
"sincronizado_em": _json_value(funcionario.sincronizado_em),
|
||||||
|
"matricula_winthor": funcionario.matricula_winthor,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_dados_winthor(funcionario):
|
||||||
|
if not funcionario or not funcionario.cpf:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dados = buscar_colaborador_oracle(funcionario.cpf)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erro ao buscar dados do Winthor no detalhe API")
|
||||||
|
return None
|
||||||
|
if not dados:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"basicos": {
|
||||||
|
"matricula": _json_value(dados.get("matricula")),
|
||||||
|
"nome": _json_value(dados.get("nome")),
|
||||||
|
"cpf": _json_value(dados.get("cpf")),
|
||||||
|
},
|
||||||
|
"admissao": {
|
||||||
|
"admissao": _json_value(dados.get("admissao")),
|
||||||
|
"situacao": _json_value(dados.get("situacao")),
|
||||||
|
"dt_exclusao": _json_value(dados.get("dt_exclusao")),
|
||||||
|
},
|
||||||
|
"endereco": {
|
||||||
|
"endereco": _json_value(dados.get("endereco")),
|
||||||
|
"bairro": _json_value(dados.get("bairro")),
|
||||||
|
"cidade": _json_value(dados.get("cidade")),
|
||||||
|
"estado": _json_value(dados.get("estado")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_detalhes_tipo(solicitacao):
|
||||||
|
try:
|
||||||
|
if solicitacao.tipo == "DESLIGAMENTO":
|
||||||
|
desligamento = solicitacao.desligamento
|
||||||
|
return {
|
||||||
|
"tipo": "DESLIGAMENTO",
|
||||||
|
"tipo_desligamento": desligamento.tipo_desligamento,
|
||||||
|
"tipo_desligamento_display": desligamento.get_tipo_desligamento_display() if desligamento.tipo_desligamento else None,
|
||||||
|
"aviso_previo": desligamento.aviso_previo,
|
||||||
|
"aviso_previo_display": desligamento.get_aviso_previo_display() if desligamento.aviso_previo else None,
|
||||||
|
"data_prevista_desligamento": _json_value(desligamento.data_prevista_desligamento),
|
||||||
|
"motivo": desligamento.motivo,
|
||||||
|
"observacoes": desligamento.observacoes,
|
||||||
|
"arquivo_pedido_url": desligamento.arquivo_pedido.url if desligamento.arquivo_pedido else None,
|
||||||
|
"arquivo_pedido_nome": desligamento.arquivo_pedido.name if desligamento.arquivo_pedido else None,
|
||||||
|
}
|
||||||
|
if solicitacao.tipo == "MOVIMENTACAO":
|
||||||
|
mov = solicitacao.movimentacao
|
||||||
|
return {
|
||||||
|
"tipo": "MOVIMENTACAO",
|
||||||
|
"altera_funcao": mov.altera_funcao,
|
||||||
|
"altera_centro_custo": mov.altera_centro_custo,
|
||||||
|
"novo_cod_secao": mov.novo_cod_secao,
|
||||||
|
"novo_cod_funcao": mov.novo_cod_funcao,
|
||||||
|
"novo_salario": str(mov.novo_salario) if mov.novo_salario is not None else None,
|
||||||
|
"data_efetivacao": _json_value(mov.data_efetivacao),
|
||||||
|
"justificativa": mov.justificativa,
|
||||||
|
}
|
||||||
|
if solicitacao.tipo == "ADM_SUBSTITUICAO":
|
||||||
|
adm_sub = solicitacao.admissao_substituicao
|
||||||
|
return {
|
||||||
|
"tipo": "ADM_SUBSTITUICAO",
|
||||||
|
"data_previsao_contratacao": _json_value(adm_sub.data_previsao_contratacao),
|
||||||
|
"cod_coligada_destino": adm_sub.cod_coligada_destino,
|
||||||
|
"cod_filial_destino": adm_sub.cod_filial_destino,
|
||||||
|
"cod_secao_destino": adm_sub.cod_secao_destino,
|
||||||
|
"cod_funcao_destino": adm_sub.cod_funcao_destino,
|
||||||
|
"justificativa": adm_sub.justificativa,
|
||||||
|
}
|
||||||
|
if solicitacao.tipo == "ADM_AUMENTO":
|
||||||
|
adm_aumento = solicitacao.admissao_aumento
|
||||||
|
return {
|
||||||
|
"tipo": "ADM_AUMENTO",
|
||||||
|
"data_previsao_contratacao": _json_value(adm_aumento.data_previsao_contratacao),
|
||||||
|
"justificativa_estrategica": adm_aumento.justificativa_estrategica,
|
||||||
|
"cod_coligada_destino": adm_aumento.cod_coligada_destino,
|
||||||
|
"cod_filial_destino": adm_aumento.cod_filial_destino,
|
||||||
|
"cod_secao_destino": adm_aumento.cod_secao_destino,
|
||||||
|
"cod_funcao_destino": adm_aumento.cod_funcao_destino,
|
||||||
|
"requer_suplementacao_orcamentaria": adm_aumento.requer_suplementacao_orcamentaria,
|
||||||
|
}
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def api_login(request):
|
||||||
|
"""
|
||||||
|
Autentica usuário via credenciais Winthor.
|
||||||
|
Aceita JSON: { "username": "...", "password": "...", "next": "?" }
|
||||||
|
Retorna: { "success": bool, "message": str, "redirect": str }
|
||||||
|
"""
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return JsonResponse({
|
||||||
|
"success": True,
|
||||||
|
"message": "Já autenticado.",
|
||||||
|
"redirect": request.GET.get("next", "/dashboard") or "/dashboard",
|
||||||
|
})
|
||||||
|
|
||||||
|
data = _parse_json_body(request)
|
||||||
|
if not data:
|
||||||
|
# Fallback para form-urlencoded (compatibilidade)
|
||||||
|
login_input = request.POST.get("username", "").strip()
|
||||||
|
senha = request.POST.get("password", "").strip()
|
||||||
|
next_url = request.POST.get("next", "").strip()
|
||||||
|
else:
|
||||||
|
login_input = data.get("username", "").strip()
|
||||||
|
senha = data.get("password", "").strip()
|
||||||
|
next_url = data.get("next", "").strip()
|
||||||
|
|
||||||
|
if not login_input or not senha:
|
||||||
|
return JsonResponse(
|
||||||
|
{"success": False, "message": "Informe usuário e senha."},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
dados = autenticar_usuario(login_input, senha)
|
||||||
|
if not dados:
|
||||||
|
return JsonResponse(
|
||||||
|
{"success": False, "message": "Usuário ou senha inválidos no Winthor."},
|
||||||
|
status=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
user, _ = User.objects.get_or_create(
|
||||||
|
username=str(dados["matricula"]),
|
||||||
|
defaults={"first_name": dados.get("nome", "Usuario").split(" ")[0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
login(request, user)
|
||||||
|
|
||||||
|
usuario_sistema, created = UsuarioSistema.objects.get_or_create(
|
||||||
|
matricula=str(dados["matricula"]),
|
||||||
|
defaults={
|
||||||
|
"nome": dados["nome"],
|
||||||
|
"ativo": True,
|
||||||
|
"perfil": UsuarioSistema.Perfil.GESTOR,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
usuario_sistema.nome = dados["nome"]
|
||||||
|
usuario_sistema.ativo = True
|
||||||
|
usuario_sistema.save(update_fields=["nome", "ativo"])
|
||||||
|
|
||||||
|
redirect_to = next_url or "/dashboard"
|
||||||
|
# #region agent log
|
||||||
|
_api_debug_log(
|
||||||
|
"H5",
|
||||||
|
"api_views.api_login:success",
|
||||||
|
"api_login_json_response",
|
||||||
|
{
|
||||||
|
"redirect_to": redirect_to[:300],
|
||||||
|
"session_key": getattr(request.session, "session_key", None),
|
||||||
|
"user_id": getattr(request.user, "id", None),
|
||||||
|
"matricula": str(dados.get("matricula", ""))[:20],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# #endregion
|
||||||
|
return JsonResponse({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Bem-vindo, {dados['nome']}!",
|
||||||
|
"redirect": redirect_to,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def api_logout(request):
|
||||||
|
"""Encerra a sessão do usuário."""
|
||||||
|
logout(request)
|
||||||
|
return JsonResponse({
|
||||||
|
"success": True,
|
||||||
|
"message": "Você saiu do sistema.",
|
||||||
|
"redirect": "/tela_login",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def api_me(request):
|
||||||
|
"""
|
||||||
|
Retorna dados do usuário autenticado.
|
||||||
|
Usado para verificar sessão e exibir dados na UI.
|
||||||
|
"""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({"authenticated": False}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
usuario = get_usuario_sistema(request)
|
||||||
|
return JsonResponse({
|
||||||
|
"authenticated": True,
|
||||||
|
"usuario": {
|
||||||
|
"matricula": usuario.matricula,
|
||||||
|
"nome": usuario.nome,
|
||||||
|
"perfil": usuario.perfil,
|
||||||
|
"perfil_display": usuario.get_perfil_display(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse({"authenticated": False}, status=401)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def api_dashboard(request):
|
||||||
|
"""
|
||||||
|
Retorna dados do dashboard: métricas e lista de solicitações.
|
||||||
|
Mesma lógica da dashboard_view, em formato JSON.
|
||||||
|
"""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({"error": "Não autenticado"}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
usuario = get_usuario_sistema(request)
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse({"error": "Usuário não encontrado"}, status=401)
|
||||||
|
|
||||||
|
# #region agent log
|
||||||
|
log_dashboard_row(
|
||||||
|
location="api_views.api_dashboard:viewer",
|
||||||
|
message="dashboard_api_viewer",
|
||||||
|
hypothesis_id="H2",
|
||||||
|
run_id="status-ambiguity",
|
||||||
|
data={
|
||||||
|
"viewer_perfil": usuario.perfil,
|
||||||
|
"tem_gg": usuario.tem_perfil(UsuarioSistema.Perfil.GG),
|
||||||
|
"tem_controladoria": usuario.tem_perfil(UsuarioSistema.Perfil.CONTROLADORIA),
|
||||||
|
"tem_diretoria": usuario.tem_perfil(UsuarioSistema.Perfil.DIRETORIA),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# #endregion
|
||||||
|
|
||||||
|
qs_base = _qs_dashboard_for_usuario(usuario)
|
||||||
|
|
||||||
|
solicitacoes_qs = qs_base.order_by("-criado_em").prefetch_related("pareceres")
|
||||||
|
finalizados = [StatusSolicitacao.FINALIZADA, StatusSolicitacao.REPROVADA]
|
||||||
|
total = qs_base.count()
|
||||||
|
pendentes = qs_base.exclude(status__in=finalizados).count()
|
||||||
|
|
||||||
|
# Paginação
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
page = int(request.GET.get("page", 1))
|
||||||
|
per_page = int(request.GET.get("per_page", 10))
|
||||||
|
paginator = Paginator(solicitacoes_qs, per_page)
|
||||||
|
page_obj = paginator.get_page(page)
|
||||||
|
|
||||||
|
# Serializa solicitações
|
||||||
|
solicitacoes_list = []
|
||||||
|
for s in page_obj:
|
||||||
|
# #region agent log
|
||||||
|
_parecer_etapas = [p.etapa for p in s.pareceres.all()]
|
||||||
|
log_dashboard_row(
|
||||||
|
location="api_views.api_dashboard:row",
|
||||||
|
message="status_vs_fila_gg",
|
||||||
|
hypothesis_id="H1+H4+H5",
|
||||||
|
run_id="status-ambiguity",
|
||||||
|
data={
|
||||||
|
"solicitacao_id": str(s.id),
|
||||||
|
"status": s.status,
|
||||||
|
"status_display_base": s.get_status_display(),
|
||||||
|
"status_display_para": str(s.get_status_display_para_usuario(usuario)),
|
||||||
|
"etapa_atual": s.etapa_atual(),
|
||||||
|
"solicitante_tem_diretoria": s.solicitante.tem_perfil(
|
||||||
|
UsuarioSistema.Perfil.DIRETORIA
|
||||||
|
),
|
||||||
|
"parecer_etapas": _parecer_etapas,
|
||||||
|
"pode_dar_parecer": s.pode_dar_parecer(usuario),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# #endregion
|
||||||
|
func_nome = s.funcionario.nome if s.funcionario else None
|
||||||
|
solicitacoes_list.append({
|
||||||
|
"id": str(s.id),
|
||||||
|
"tipo": s.tipo,
|
||||||
|
"tipo_display": s.get_tipo_display(),
|
||||||
|
"colaborador": func_nome,
|
||||||
|
"status": s.status,
|
||||||
|
"status_display": str(s.get_status_display_para_usuario(usuario)),
|
||||||
|
"criado_em": s.criado_em.isoformat() if s.criado_em else None,
|
||||||
|
"enviada_em": s.enviada_em.isoformat() if s.enviada_em else None,
|
||||||
|
"solicitante_nome": s.solicitante.nome if s.solicitante else None,
|
||||||
|
"pode_aprovar": s.pode_aprovar(usuario),
|
||||||
|
"pode_dar_parecer": s.pode_dar_parecer(usuario),
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
"usuario": {
|
||||||
|
"matricula": usuario.matricula,
|
||||||
|
"nome": usuario.nome,
|
||||||
|
"perfil": usuario.perfil,
|
||||||
|
"perfil_display": usuario.get_perfil_display(),
|
||||||
|
},
|
||||||
|
"total": total,
|
||||||
|
"pendentes": pendentes,
|
||||||
|
"solicitacoes": solicitacoes_list,
|
||||||
|
"pagination": {
|
||||||
|
"page": page_obj.number,
|
||||||
|
"per_page": per_page,
|
||||||
|
"total_pages": paginator.num_pages,
|
||||||
|
"total_count": paginator.count,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def api_solicitacao_detalhe(request, solicitacao_id):
|
||||||
|
"""
|
||||||
|
Retorna detalhes de uma solicitação visível ao usuário conforme regras do dashboard.
|
||||||
|
"""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({"error": "Não autenticado"}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
usuario = get_usuario_sistema(request)
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse({"error": "Usuário não encontrado"}, status=401)
|
||||||
|
|
||||||
|
qs_base = _qs_dashboard_for_usuario(usuario)
|
||||||
|
solicitacao = get_object_or_404(
|
||||||
|
qs_base.select_related(
|
||||||
|
"solicitante",
|
||||||
|
"funcionario",
|
||||||
|
"desligamento",
|
||||||
|
"movimentacao",
|
||||||
|
"admissao_substituicao",
|
||||||
|
"admissao_aumento",
|
||||||
|
)
|
||||||
|
.prefetch_related("pareceres__usuario", "aprovacoes__usuario"),
|
||||||
|
id=solicitacao_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
funcionario = solicitacao.funcionario
|
||||||
|
payload = {
|
||||||
|
"id": str(solicitacao.id),
|
||||||
|
"tipo": solicitacao.tipo,
|
||||||
|
"tipo_display": solicitacao.get_tipo_display(),
|
||||||
|
"status": solicitacao.status,
|
||||||
|
"status_display": str(solicitacao.get_status_display_para_usuario(usuario)),
|
||||||
|
"criado_em": solicitacao.criado_em.isoformat() if solicitacao.criado_em else None,
|
||||||
|
"enviada_em": solicitacao.enviada_em.isoformat() if solicitacao.enviada_em else None,
|
||||||
|
"finalizada_em": solicitacao.finalizada_em.isoformat() if solicitacao.finalizada_em else None,
|
||||||
|
"solicitante": {
|
||||||
|
"matricula": solicitacao.solicitante.matricula if solicitacao.solicitante else None,
|
||||||
|
"nome": solicitacao.solicitante.nome if solicitacao.solicitante else None,
|
||||||
|
},
|
||||||
|
"funcionario": _serialize_funcionario_rm(funcionario),
|
||||||
|
"dados_winthor": _serialize_dados_winthor(funcionario),
|
||||||
|
"detalhes_tipo": _serialize_detalhes_tipo(solicitacao),
|
||||||
|
"acoes": {
|
||||||
|
"pode_aprovar": solicitacao.pode_aprovar(usuario),
|
||||||
|
"pode_dar_parecer": solicitacao.pode_dar_parecer(usuario),
|
||||||
|
"pode_enviar": solicitacao.pode_enviar(),
|
||||||
|
"is_solicitante": solicitacao.solicitante_id == usuario.id,
|
||||||
|
},
|
||||||
|
"pareceres": [
|
||||||
|
_serialize_parecer(parecer)
|
||||||
|
for parecer in solicitacao.pareceres.all().order_by("-criado_em")
|
||||||
|
],
|
||||||
|
"aprovacoes": [
|
||||||
|
_serialize_aprovacao(aprovacao)
|
||||||
|
for aprovacao in solicitacao.aprovacoes.all().order_by("-decidido_em")
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return JsonResponse(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def api_colaboradores(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({"error": "Não autenticado"}, status=401)
|
||||||
|
|
||||||
|
tipo = (request.GET.get("tipo") or "").strip()
|
||||||
|
busca = (request.GET.get("q") or "").strip()
|
||||||
|
|
||||||
|
if tipo == "substituicao":
|
||||||
|
resultados_rm = buscar_colaboradores_rm(nome=busca if busca else None)
|
||||||
|
colaboradores = []
|
||||||
|
for row in resultados_rm:
|
||||||
|
id_rm = f"{row['CODCOLIGADA']}-{row['CHAPA']}"
|
||||||
|
local_id = None
|
||||||
|
local_obj = PessoaRM.objects.filter(id_rm=id_rm).only("id").first()
|
||||||
|
if local_obj:
|
||||||
|
local_id = str(local_obj.id)
|
||||||
|
colaboradores.append(
|
||||||
|
{
|
||||||
|
"id_rm": id_rm,
|
||||||
|
"matricula": row.get("CHAPA"),
|
||||||
|
"nome": row.get("NOME"),
|
||||||
|
"cpf": row.get("CPF"),
|
||||||
|
"cargo": row.get("FUNCAO"),
|
||||||
|
"setor": row.get("SECAO"),
|
||||||
|
"situacao": row.get("CODSITUACAO"),
|
||||||
|
"id": local_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return JsonResponse({"colaboradores": colaboradores})
|
||||||
|
|
||||||
|
qs = PessoaRM.objects.exclude(situacao="D").order_by("nome")
|
||||||
|
if busca:
|
||||||
|
qs = qs.filter(nome__icontains=busca) | qs.filter(matricula__icontains=busca)
|
||||||
|
colaboradores = [
|
||||||
|
{
|
||||||
|
"id": str(c.id),
|
||||||
|
"id_rm": c.id_rm,
|
||||||
|
"matricula": c.matricula,
|
||||||
|
"nome": c.nome,
|
||||||
|
"cpf": c.cpf,
|
||||||
|
"cargo": c.cargo,
|
||||||
|
"setor": c.setor,
|
||||||
|
"situacao": c.situacao,
|
||||||
|
}
|
||||||
|
for c in qs[:60]
|
||||||
|
]
|
||||||
|
return JsonResponse({"colaboradores": colaboradores})
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def api_nova_solicitacao_metadata(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({"error": "Não autenticado"}, status=401)
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"cargos": listar_cargos_ativos_rm(),
|
||||||
|
"secoes": listar_secoes_ativas_rm(),
|
||||||
|
"coligadas": listar_coligadas_rm(),
|
||||||
|
"tipos": [
|
||||||
|
{"value": "DESLIGAMENTO", "label": "Desligamento", "precisa_colaborador": True},
|
||||||
|
{"value": "MOVIMENTACAO", "label": "Movimentação", "precisa_colaborador": True},
|
||||||
|
{"value": "ADM_SUBSTITUICAO", "label": "Admissão por Substituição", "precisa_colaborador": True},
|
||||||
|
{"value": "ADM_AUMENTO", "label": "Admissão por Aumento de Quadro", "precisa_colaborador": False},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def api_solicitacao_criar(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({"error": "Não autenticado"}, status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
usuario = get_usuario_sistema(request)
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse({"error": "Usuário não encontrado"}, status=401)
|
||||||
|
|
||||||
|
content_type = (request.content_type or "").lower()
|
||||||
|
if "multipart/form-data" in content_type:
|
||||||
|
payload = request.POST
|
||||||
|
else:
|
||||||
|
payload = _parse_json_body(request)
|
||||||
|
|
||||||
|
tipo = (payload.get("tipo") or "").strip()
|
||||||
|
try:
|
||||||
|
if tipo == "DESLIGAMENTO":
|
||||||
|
funcionario = get_object_or_404(PessoaRM, id=payload.get("funcionario_id"))
|
||||||
|
solicitacao = services.criar_solicitacao_desligamento(
|
||||||
|
solicitante=usuario,
|
||||||
|
funcionario=funcionario,
|
||||||
|
tipo_desligamento=(payload.get("tipo_desligamento") or "").strip(),
|
||||||
|
aviso_previo=(payload.get("aviso_previo") or "").strip(),
|
||||||
|
motivo=(payload.get("motivo") or "").strip(),
|
||||||
|
data_prevista_desligamento=_coerce_date(payload.get("data_prevista_desligamento"), "data_prevista_desligamento"),
|
||||||
|
observacoes=(payload.get("observacoes") or "").strip(),
|
||||||
|
arquivo_pedido=request.FILES.get("arquivo_pedido"),
|
||||||
|
)
|
||||||
|
elif tipo == "MOVIMENTACAO":
|
||||||
|
funcionario = get_object_or_404(PessoaRM, id=payload.get("funcionario_id"))
|
||||||
|
solicitacao = services.criar_solicitacao_movimentacao(
|
||||||
|
solicitante=usuario,
|
||||||
|
funcionario=funcionario,
|
||||||
|
dados_movimentacao={
|
||||||
|
"altera_funcao": _coerce_bool(payload.get("altera_funcao")),
|
||||||
|
"altera_centro_custo": _coerce_bool(payload.get("altera_centro_custo")),
|
||||||
|
"novo_cod_funcao": payload.get("novo_cod_funcao") or None,
|
||||||
|
"novo_cod_secao": payload.get("novo_cod_secao") or None,
|
||||||
|
"novo_salario": payload.get("novo_salario") or None,
|
||||||
|
"data_efetivacao": _coerce_date(payload.get("data_efetivacao"), "data_efetivacao"),
|
||||||
|
"justificativa": (payload.get("justificativa") or "").strip(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif tipo == "ADM_SUBSTITUICAO":
|
||||||
|
funcionario = get_object_or_404(PessoaRM, id=payload.get("funcionario_id"))
|
||||||
|
solicitacao = services.criar_solicitacao_substituicao(
|
||||||
|
solicitante=usuario,
|
||||||
|
funcionario_substituido=funcionario,
|
||||||
|
dados_admissao={
|
||||||
|
"data_previsao_contratacao": _coerce_date(payload.get("data_previsao_contratacao"), "data_previsao_contratacao"),
|
||||||
|
"cod_coligada_destino": (payload.get("cod_coligada_destino") or "").strip(),
|
||||||
|
"cod_filial_destino": (payload.get("cod_filial_destino") or "").strip(),
|
||||||
|
"cod_secao_destino": (payload.get("cod_secao_destino") or "").strip(),
|
||||||
|
"cod_funcao_destino": (payload.get("cod_funcao_destino") or "").strip(),
|
||||||
|
"justificativa": (payload.get("justificativa") or "").strip(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif tipo == "ADM_AUMENTO":
|
||||||
|
solicitacao = services.criar_solicitacao_aumento_quadro(
|
||||||
|
solicitante=usuario,
|
||||||
|
dados_admissao={
|
||||||
|
"data_previsao_contratacao": _coerce_date(payload.get("data_previsao_contratacao"), "data_previsao_contratacao"),
|
||||||
|
"cod_coligada_destino": (payload.get("cod_coligada_destino") or "").strip(),
|
||||||
|
"cod_filial_destino": (payload.get("cod_filial_destino") or "").strip(),
|
||||||
|
"cod_secao_destino": (payload.get("cod_secao_destino") or "").strip(),
|
||||||
|
"cod_funcao_destino": (payload.get("cod_funcao_destino") or "").strip(),
|
||||||
|
"justificativa_estrategica": (payload.get("justificativa_estrategica") or "").strip(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return JsonResponse({"error": "Tipo de solicitação inválido."}, status=400)
|
||||||
|
except services.SolicitacaoError as exc:
|
||||||
|
return JsonResponse({"error": str(exc)}, status=400)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erro ao criar solicitação via API")
|
||||||
|
return JsonResponse({"error": "Erro interno ao criar solicitação."}, status=500)
|
||||||
|
|
||||||
|
return JsonResponse({"success": True, "solicitacao_id": str(solicitacao.id), "redirect": f"/solicitacoes/{solicitacao.id}"})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def api_solicitacao_enviar(request, solicitacao_id):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({"error": "Não autenticado"}, status=401)
|
||||||
|
try:
|
||||||
|
usuario = get_usuario_sistema(request)
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse({"error": "Usuário não encontrado"}, status=401)
|
||||||
|
|
||||||
|
solicitacao = get_object_or_404(Solicitacao, id=solicitacao_id)
|
||||||
|
try:
|
||||||
|
services.enviar_solicitacao(solicitacao, usuario)
|
||||||
|
solicitacao.refresh_from_db()
|
||||||
|
return JsonResponse({
|
||||||
|
"success": True,
|
||||||
|
"status": solicitacao.status,
|
||||||
|
"status_display": str(solicitacao.get_status_display_para_usuario(usuario)),
|
||||||
|
})
|
||||||
|
except services.SolicitacaoError as exc:
|
||||||
|
return JsonResponse({"error": str(exc)}, status=400)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erro ao enviar solicitação via API")
|
||||||
|
return JsonResponse({"error": "Erro interno ao enviar solicitação."}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def api_solicitacao_decidir(request, solicitacao_id):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({"error": "Não autenticado"}, status=401)
|
||||||
|
try:
|
||||||
|
usuario = get_usuario_sistema(request)
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse({"error": "Usuário não encontrado"}, status=401)
|
||||||
|
|
||||||
|
data = _parse_json_body(request)
|
||||||
|
decisao = (data.get("decisao") or "").strip()
|
||||||
|
justificativa = (data.get("justificativa") or "").strip()
|
||||||
|
if decisao not in {DecisaoAprovacao.APROVADO, DecisaoAprovacao.REPROVADO}:
|
||||||
|
return JsonResponse({"error": "Decisão inválida."}, status=400)
|
||||||
|
|
||||||
|
solicitacao = get_object_or_404(Solicitacao, id=solicitacao_id)
|
||||||
|
try:
|
||||||
|
if solicitacao.status == StatusSolicitacao.AGUARDANDO_HEAD:
|
||||||
|
services.aprovar_reprovar_por_head(
|
||||||
|
solicitacao=solicitacao,
|
||||||
|
aprovador=usuario,
|
||||||
|
decisao=decisao,
|
||||||
|
justificativa=justificativa,
|
||||||
|
)
|
||||||
|
elif solicitacao.status == StatusSolicitacao.AGUARDANDO_DIRETORIA:
|
||||||
|
services.aprovar_reprovar_solicitacao(
|
||||||
|
solicitacao=solicitacao,
|
||||||
|
aprovador=usuario,
|
||||||
|
decisao=decisao,
|
||||||
|
justificativa=justificativa,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return JsonResponse({"error": "Solicitação não está aguardando decisão."}, status=400)
|
||||||
|
except services.SolicitacaoError as exc:
|
||||||
|
return JsonResponse({"error": str(exc)}, status=400)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erro ao decidir solicitação via API")
|
||||||
|
return JsonResponse({"error": "Erro interno ao decidir solicitação."}, status=500)
|
||||||
|
|
||||||
|
solicitacao.refresh_from_db()
|
||||||
|
return JsonResponse({
|
||||||
|
"success": True,
|
||||||
|
"status": solicitacao.status,
|
||||||
|
"status_display": str(solicitacao.get_status_display_para_usuario(usuario)),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def api_solicitacao_parecer(request, solicitacao_id):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse({"error": "Não autenticado"}, status=401)
|
||||||
|
try:
|
||||||
|
usuario = get_usuario_sistema(request)
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse({"error": "Usuário não encontrado"}, status=401)
|
||||||
|
|
||||||
|
solicitacao = get_object_or_404(Solicitacao, id=solicitacao_id)
|
||||||
|
texto = (request.POST.get("texto") or "").strip()
|
||||||
|
anexo = request.FILES.get("anexo")
|
||||||
|
if not texto:
|
||||||
|
return JsonResponse({"error": "Texto do parecer é obrigatório."}, status=400)
|
||||||
|
try:
|
||||||
|
parecer = services.registrar_parecer(solicitacao=solicitacao, usuario=usuario, texto=texto, anexo=anexo)
|
||||||
|
except services.SolicitacaoError as exc:
|
||||||
|
return JsonResponse({"error": str(exc)}, status=400)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erro ao registrar parecer via API")
|
||||||
|
return JsonResponse({"error": "Erro interno ao registrar parecer."}, status=500)
|
||||||
|
|
||||||
|
solicitacao.refresh_from_db()
|
||||||
|
return JsonResponse({
|
||||||
|
"success": True,
|
||||||
|
"parecer": _serialize_parecer(parecer),
|
||||||
|
"status": solicitacao.status,
|
||||||
|
"status_display": str(solicitacao.get_status_display_para_usuario(usuario)),
|
||||||
|
})
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# /SGMP_PROD/solicitacoes/context_processors.py
|
# /SGMP_PROD/solicitacoes/context_processors.py
|
||||||
|
|
||||||
|
from .acesso import usuario_pode_gerenciar_permissoes
|
||||||
from .models import UsuarioSistema
|
from .models import UsuarioSistema
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -13,8 +14,53 @@ def usuario_sistema(request):
|
||||||
matricula=request.user.username,
|
matricula=request.user.username,
|
||||||
ativo=True
|
ativo=True
|
||||||
)
|
)
|
||||||
return {'usuario_sistema': usuario}
|
usuario_eh_admin = usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN)
|
||||||
|
usuario_eh_gestor = usuario.tem_perfil(UsuarioSistema.Perfil.GESTOR)
|
||||||
|
usuario_eh_diretoria = usuario.tem_perfil(UsuarioSistema.Perfil.DIRETORIA)
|
||||||
|
usuario_eh_gg = usuario.tem_perfil(UsuarioSistema.Perfil.GG)
|
||||||
|
usuario_eh_controladoria = usuario.tem_perfil(UsuarioSistema.Perfil.CONTROLADORIA)
|
||||||
|
usuario_eh_head = usuario.tem_perfil(UsuarioSistema.Perfil.HEAD)
|
||||||
|
return {
|
||||||
|
"usuario_sistema": usuario,
|
||||||
|
"usuario_eh_admin": usuario_eh_admin,
|
||||||
|
"usuario_eh_gestor": usuario_eh_gestor,
|
||||||
|
"usuario_eh_diretoria": usuario_eh_diretoria,
|
||||||
|
"usuario_eh_gg": usuario_eh_gg,
|
||||||
|
"usuario_eh_controladoria": usuario_eh_controladoria,
|
||||||
|
"usuario_eh_head": usuario_eh_head,
|
||||||
|
"usuario_pode_criar_solicitacao": (
|
||||||
|
usuario_eh_gestor or usuario_eh_admin or usuario_eh_diretoria
|
||||||
|
),
|
||||||
|
"usuario_pode_ver_todas_solicitacoes": (
|
||||||
|
usuario.perfil != UsuarioSistema.Perfil.GESTOR or usuario_eh_admin
|
||||||
|
),
|
||||||
|
"usuario_pode_gerenciar_permissoes": usuario_pode_gerenciar_permissoes(
|
||||||
|
usuario
|
||||||
|
),
|
||||||
|
}
|
||||||
except UsuarioSistema.DoesNotExist:
|
except UsuarioSistema.DoesNotExist:
|
||||||
return {'usuario_sistema': None}
|
return {
|
||||||
return {'usuario_sistema': None}
|
"usuario_sistema": None,
|
||||||
|
"usuario_eh_admin": False,
|
||||||
|
"usuario_eh_gestor": False,
|
||||||
|
"usuario_eh_diretoria": False,
|
||||||
|
"usuario_eh_gg": False,
|
||||||
|
"usuario_eh_controladoria": False,
|
||||||
|
"usuario_eh_head": False,
|
||||||
|
"usuario_pode_criar_solicitacao": False,
|
||||||
|
"usuario_pode_ver_todas_solicitacoes": False,
|
||||||
|
"usuario_pode_gerenciar_permissoes": False,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"usuario_sistema": None,
|
||||||
|
"usuario_eh_admin": False,
|
||||||
|
"usuario_eh_gestor": False,
|
||||||
|
"usuario_eh_diretoria": False,
|
||||||
|
"usuario_eh_gg": False,
|
||||||
|
"usuario_eh_controladoria": False,
|
||||||
|
"usuario_eh_head": False,
|
||||||
|
"usuario_pode_criar_solicitacao": False,
|
||||||
|
"usuario_pode_ver_todas_solicitacoes": False,
|
||||||
|
"usuario_pode_gerenciar_permissoes": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
# #region agent log (debug session 6b638a — status / fila por perfil)
|
||||||
|
"""NDJSON append para análise de ambiguidade de status (GG vs origem). Não logar segredos nem PII."""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
LOG_PATH = "/home/f3lipe/dev/docker_services/.cursor/debug-6b638a.log"
|
||||||
|
SESSION_ID = "6b638a"
|
||||||
|
|
||||||
|
|
||||||
|
def log_dashboard_row(
|
||||||
|
*,
|
||||||
|
location: str,
|
||||||
|
message: str,
|
||||||
|
hypothesis_id: str,
|
||||||
|
run_id: str,
|
||||||
|
data: dict,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
line = {
|
||||||
|
"sessionId": SESSION_ID,
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
"runId": run_id,
|
||||||
|
"hypothesisId": hypothesis_id,
|
||||||
|
"location": location,
|
||||||
|
"message": message,
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
with open(LOG_PATH, "a", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(line, ensure_ascii=False, default=str) + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# #endregion
|
||||||
|
|
@ -30,6 +30,10 @@ def requer_perfil(*perfis_permitidos):
|
||||||
except UsuarioSistema.DoesNotExist:
|
except UsuarioSistema.DoesNotExist:
|
||||||
messages.error(request, "Usuário não encontrado no sistema.")
|
messages.error(request, "Usuário não encontrado no sistema.")
|
||||||
return redirect("solicitacoes:login")
|
return redirect("solicitacoes:login")
|
||||||
|
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN):
|
||||||
|
request.usuario_sistema = usuario
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
# Aceita se o usuário possuir pelo menos um dos perfis permitidos,
|
# Aceita se o usuário possuir pelo menos um dos perfis permitidos,
|
||||||
# considerando perfil principal e perfis extras.
|
# considerando perfil principal e perfis extras.
|
||||||
|
|
@ -52,7 +56,7 @@ def requer_perfil(*perfis_permitidos):
|
||||||
def pode_criar_solicitacao(view_func):
|
def pode_criar_solicitacao(view_func):
|
||||||
"""
|
"""
|
||||||
Decorador que verifica se o usuário pode criar solicitações.
|
Decorador que verifica se o usuário pode criar solicitações.
|
||||||
Por padrão, apenas GESTOR pode criar.
|
Gestores, diretoria e administradores podem criar.
|
||||||
"""
|
"""
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def wrapper(request, *args, **kwargs):
|
def wrapper(request, *args, **kwargs):
|
||||||
|
|
@ -69,10 +73,14 @@ def pode_criar_solicitacao(view_func):
|
||||||
messages.error(request, "Usuário não encontrado no sistema.")
|
messages.error(request, "Usuário não encontrado no sistema.")
|
||||||
return redirect("solicitacoes:login")
|
return redirect("solicitacoes:login")
|
||||||
|
|
||||||
if usuario.perfil != UsuarioSistema.Perfil.GESTOR:
|
if not (
|
||||||
|
usuario.tem_perfil(UsuarioSistema.Perfil.GESTOR)
|
||||||
|
or usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN)
|
||||||
|
or usuario.tem_perfil(UsuarioSistema.Perfil.DIRETORIA)
|
||||||
|
):
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
request,
|
||||||
"Apenas gestores podem criar solicitações."
|
"Apenas gestores, diretoria e administradores podem criar solicitações."
|
||||||
)
|
)
|
||||||
return redirect("solicitacoes:dashboard")
|
return redirect("solicitacoes:dashboard")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,19 +94,17 @@ def listar_para_selecionar_colaborador(apenas_desligados=False):
|
||||||
return cursor.fetchall()
|
return cursor.fetchall()
|
||||||
|
|
||||||
|
|
||||||
def buscar_colaboradores_rm_desligados(nome: str = None):
|
def buscar_colaboradores_rm(nome: str = None):
|
||||||
"""
|
"""
|
||||||
Busca colaboradores DESLIGADOS no RM para fluxo de Substituição.
|
Busca colaboradores no RM para o fluxo de admissão por substituição.
|
||||||
|
|
||||||
Seguindo padrão do sgmp:
|
Inclui todas as situações (ativos, desligados, etc.), sem filtrar por CODSITUACAO.
|
||||||
- CODSITUACAO = 'D'
|
|
||||||
- DATADEMISSAO IS NOT NULL
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
nome (str, optional): Nome para filtrar (busca parcial, case-insensitive)
|
nome (str, optional): Nome para filtrar (busca parcial, case-insensitive)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list: Lista de dicionários com dados dos colaboradores desligados
|
list: Lista de dicionários com dados dos colaboradores
|
||||||
"""
|
"""
|
||||||
query = """
|
query = """
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -132,9 +130,7 @@ def buscar_colaboradores_rm_desligados(nome: str = None):
|
||||||
JOIN PFUNCAO PU
|
JOIN PFUNCAO PU
|
||||||
ON PU.CODCOLIGADA = PF.CODCOLIGADA
|
ON PU.CODCOLIGADA = PF.CODCOLIGADA
|
||||||
AND PU.CODIGO = PF.CODFUNCAO
|
AND PU.CODIGO = PF.CODFUNCAO
|
||||||
WHERE
|
WHERE 1=1
|
||||||
PF.CODSITUACAO = 'D'
|
|
||||||
AND PF.DATADEMISSAO IS NOT NULL
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
params = []
|
params = []
|
||||||
|
|
|
||||||
|
|
@ -5,36 +5,45 @@ import oracledb
|
||||||
# Tenta inicializar o Oracle Client usando a variável de ambiente ou caminho padrão
|
# Tenta inicializar o Oracle Client usando a variável de ambiente ou caminho padrão
|
||||||
# No Docker, usa /opt/oracle/instantclient_21_13 (definido no Dockerfile)
|
# No Docker, usa /opt/oracle/instantclient_21_13 (definido no Dockerfile)
|
||||||
# Em desenvolvimento local, pode usar /usr/lib/oracle/23/client64/lib
|
# Em desenvolvimento local, pode usar /usr/lib/oracle/23/client64/lib
|
||||||
|
#
|
||||||
|
# ORACLE_USE_THIN=1 — não chama init_oracle_client; usa modo thin (sem Instant Client).
|
||||||
|
# Útil quando o servidor Oracle é acessível da máquina de dev mas não há libclntsh instalada.
|
||||||
_oracle_client_initialized = False
|
_oracle_client_initialized = False
|
||||||
|
|
||||||
|
|
||||||
def _init_oracle_client():
|
def _init_oracle_client():
|
||||||
"""Inicializa o Oracle Client de forma lazy e segura."""
|
"""Inicializa o Oracle Client (thick) de forma lazy, ou deixa o modo thin padrão."""
|
||||||
global _oracle_client_initialized
|
global _oracle_client_initialized
|
||||||
|
|
||||||
if _oracle_client_initialized:
|
if _oracle_client_initialized:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if os.getenv("ORACLE_USE_THIN", "").strip().lower() in ("1", "true", "yes", "on"):
|
||||||
|
_oracle_client_initialized = True
|
||||||
|
return
|
||||||
|
|
||||||
oracle_lib_dir = os.getenv("ORACLE_LIB_DIR", "/opt/oracle/instantclient_21_13")
|
oracle_lib_dir = os.getenv("ORACLE_LIB_DIR", "/opt/oracle/instantclient_21_13")
|
||||||
|
# API pública: oracledb.DatabaseError (não usar oracledb.exceptions.* — quebra em algumas instalações)
|
||||||
# Tenta inicializar com o caminho do Docker primeiro
|
_DatabaseError = getattr(oracledb, "DatabaseError", Exception)
|
||||||
|
|
||||||
|
# Tenta inicializar com o caminho do Docker / env primeiro
|
||||||
try:
|
try:
|
||||||
oracledb.init_oracle_client(lib_dir=oracle_lib_dir)
|
oracledb.init_oracle_client(lib_dir=oracle_lib_dir)
|
||||||
_oracle_client_initialized = True
|
_oracle_client_initialized = True
|
||||||
return
|
return
|
||||||
except (oracledb.exceptions.DatabaseError, Exception):
|
except (_DatabaseError, OSError, Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Se falhar, tenta o caminho alternativo (desenvolvimento local)
|
# Se falhar, tenta o caminho alternativo (desenvolvimento local com RPM Oracle)
|
||||||
if oracle_lib_dir != "/usr/lib/oracle/23/client64/lib":
|
if oracle_lib_dir != "/usr/lib/oracle/23/client64/lib":
|
||||||
try:
|
try:
|
||||||
oracledb.init_oracle_client(lib_dir="/usr/lib/oracle/23/client64/lib")
|
oracledb.init_oracle_client(lib_dir="/usr/lib/oracle/23/client64/lib")
|
||||||
_oracle_client_initialized = True
|
_oracle_client_initialized = True
|
||||||
return
|
return
|
||||||
except (oracledb.exceptions.DatabaseError, Exception):
|
except (_DatabaseError, OSError, Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Se ambos falharem, marca como inicializado para não tentar novamente
|
# Sem thick: segue em modo thin (sem segunda tentativa de init neste processo)
|
||||||
# O erro será lançado quando tentar usar as funções
|
|
||||||
_oracle_client_initialized = True
|
_oracle_client_initialized = True
|
||||||
|
|
||||||
def get_oracle_connection():
|
def get_oracle_connection():
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""
|
||||||
|
Desfaz um passo do fluxo da solicitação (da situação atual para trás).
|
||||||
|
|
||||||
|
Delega a lógica a ``solicitacoes.services.reverter_ultima_acao``.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python manage.py reverter_acao <uuid> --dry-run
|
||||||
|
python manage.py reverter_acao <uuid>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from solicitacoes.models import Solicitacao
|
||||||
|
from solicitacoes.services import ValidacaoError, reverter_ultima_acao
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Desfaz a última ação gravada na solicitação (Diretoria → pareceres → Head → envio ao Head)."
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("solicitacao_id", type=str, help="UUID da solicitação")
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Mostra o que seria feito sem gravar no banco.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
raw = options["solicitacao_id"].strip()
|
||||||
|
try:
|
||||||
|
sid = uuid.UUID(raw)
|
||||||
|
except ValueError as e:
|
||||||
|
raise CommandError(f"UUID inválido: {raw}") from e
|
||||||
|
|
||||||
|
dry = options["dry_run"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
s = Solicitacao.objects.get(pk=sid)
|
||||||
|
except Solicitacao.DoesNotExist as e:
|
||||||
|
raise CommandError(f"Solicitação não encontrada: {sid}") from e
|
||||||
|
|
||||||
|
self.stdout.write(f"Solicitação: {s.id}")
|
||||||
|
self.stdout.write(f" status atual: {s.status}")
|
||||||
|
self.stdout.write(f" finalizada_em: {s.finalizada_em}")
|
||||||
|
self.stdout.write(
|
||||||
|
f" pareceres: {list(s.pareceres.values_list('etapa', flat=True))}"
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f" aprovações: {list(s.aprovacoes.values_list('etapa', 'decisao'))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = reverter_ultima_acao(s, dry_run=dry)
|
||||||
|
except ValidacaoError as e:
|
||||||
|
raise CommandError(str(e)) from e
|
||||||
|
|
||||||
|
self.stdout.write(self.style.WARNING(f" → {result['detalhe']}"))
|
||||||
|
self.stdout.write(f" (ação: {result['acao']}; novo status: {result.get('novo_status', '—')})")
|
||||||
|
|
||||||
|
if dry:
|
||||||
|
self.stdout.write(self.style.NOTICE("Dry-run: nenhuma alteração gravada."))
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS("OK: reversão aplicada."))
|
||||||
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("solicitacoes", "0008_usuarioperfilextra"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usuariosistema",
|
||||||
|
name="perfil",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("ADMIN", "Admin"),
|
||||||
|
("GESTOR", "Gestor"),
|
||||||
|
("HEAD", "Head"),
|
||||||
|
("GG", "Gente e Gestão"),
|
||||||
|
("CONTROLADORIA", "Controladoria"),
|
||||||
|
("DIRETORIA", "Diretoria"),
|
||||||
|
],
|
||||||
|
default="GESTOR",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usuarioperfilextra",
|
||||||
|
name="perfil",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("ADMIN", "Admin"),
|
||||||
|
("GESTOR", "Gestor"),
|
||||||
|
("HEAD", "Head"),
|
||||||
|
("GG", "Gente e Gestão"),
|
||||||
|
("CONTROLADORIA", "Controladoria"),
|
||||||
|
("DIRETORIA", "Diretoria"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 4.2.26 on 2026-04-15 01:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import solicitacoes.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('solicitacoes', '0009_add_admin_profile_choices'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ConfiguracaoSGMP',
|
||||||
|
fields=[
|
||||||
|
('id', models.PositiveSmallIntegerField(default=1, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('perfis_gerenciar_permissoes', models.JSONField(default=solicitacoes.models._default_perfis_gerenciar_permissoes, help_text='Perfis de UsuarioSistema que podem acessar a tela Gerenciar Permissões (/permissoes/).')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Configuração do SGMP',
|
||||||
|
'verbose_name_plural': 'Configuração do SGMP',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -106,6 +106,7 @@ class UsuarioSistema(BaseModel):
|
||||||
ativo = models.BooleanField(default=True)
|
ativo = models.BooleanField(default=True)
|
||||||
|
|
||||||
class Perfil(models.TextChoices):
|
class Perfil(models.TextChoices):
|
||||||
|
ADMIN = "ADMIN", _("Admin")
|
||||||
GESTOR = "GESTOR", _("Gestor")
|
GESTOR = "GESTOR", _("Gestor")
|
||||||
HEAD = "HEAD", _("Head")
|
HEAD = "HEAD", _("Head")
|
||||||
GG = "GG", _("Gente e Gestão")
|
GG = "GG", _("Gente e Gestão")
|
||||||
|
|
@ -123,10 +124,12 @@ class UsuarioSistema(BaseModel):
|
||||||
Verifica se o usuário possui o perfil informado, considerando
|
Verifica se o usuário possui o perfil informado, considerando
|
||||||
o perfil principal e perfis extras.
|
o perfil principal e perfis extras.
|
||||||
"""
|
"""
|
||||||
|
if self.perfil == self.Perfil.ADMIN:
|
||||||
|
return True
|
||||||
if self.perfil == perfil:
|
if self.perfil == perfil:
|
||||||
return True
|
return True
|
||||||
# Evita import cíclico: FK de UsuarioPerfilExtra usa related_name="perfis_extras"
|
# Evita import cíclico: FK de UsuarioPerfilExtra usa related_name="perfis_extras"
|
||||||
return self.perfis_extras.filter(perfil=perfil).exists()
|
return self.perfis_extras.filter(perfil__in=[perfil, self.Perfil.ADMIN]).exists()
|
||||||
|
|
||||||
def perfis_ativos(self):
|
def perfis_ativos(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -200,12 +203,53 @@ class HeadGestor(BaseModel):
|
||||||
return f"{self.head} aprova {self.gestor}"
|
return f"{self.head} aprova {self.gestor}"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_perfis_gerenciar_permissoes():
|
||||||
|
"""Lista padrão: apenas ADMIN pode acessar /permissoes/."""
|
||||||
|
return [UsuarioSistema.Perfil.ADMIN]
|
||||||
|
|
||||||
|
|
||||||
|
class ConfiguracaoSGMP(models.Model):
|
||||||
|
"""
|
||||||
|
Parâmetros globais do SGMP (singleton: sempre pk=1).
|
||||||
|
|
||||||
|
Editável no Django Admin; não criar múltiplas linhas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.PositiveSmallIntegerField(primary_key=True, default=1, editable=False)
|
||||||
|
perfis_gerenciar_permissoes = models.JSONField(
|
||||||
|
default=_default_perfis_gerenciar_permissoes,
|
||||||
|
help_text="Perfis de UsuarioSistema que podem acessar a tela Gerenciar Permissões (/permissoes/).",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Configuração do SGMP"
|
||||||
|
verbose_name_plural = "Configuração do SGMP"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.pk = 1
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls) -> "ConfiguracaoSGMP":
|
||||||
|
obj, _ = cls.objects.get_or_create(
|
||||||
|
pk=1,
|
||||||
|
defaults={"perfis_gerenciar_permissoes": _default_perfis_gerenciar_permissoes()},
|
||||||
|
)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Configuração do SGMP"
|
||||||
|
|
||||||
|
|
||||||
def matriculas_gestores_do_head(usuario: "UsuarioSistema") -> list:
|
def matriculas_gestores_do_head(usuario: "UsuarioSistema") -> list:
|
||||||
"""
|
"""
|
||||||
Retorna a lista de matrículas dos gestores que o usuário (HEAD) aprova.
|
Retorna a lista de matrículas dos gestores que o usuário (HEAD) aprova.
|
||||||
Se o usuário não for HEAD ou não tiver gestores vinculados, retorna lista vazia.
|
Se o usuário não for HEAD ou não tiver gestores vinculados, retorna lista vazia.
|
||||||
"""
|
"""
|
||||||
if not usuario or not usuario.tem_perfil(UsuarioSistema.Perfil.HEAD):
|
if not usuario or (
|
||||||
|
not usuario.tem_perfil(UsuarioSistema.Perfil.HEAD)
|
||||||
|
and not usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN)
|
||||||
|
):
|
||||||
return []
|
return []
|
||||||
return list(
|
return list(
|
||||||
HeadGestor.objects.filter(head=usuario).values_list("gestor__matricula", flat=True)
|
HeadGestor.objects.filter(head=usuario).values_list("gestor__matricula", flat=True)
|
||||||
|
|
@ -478,11 +522,17 @@ class Solicitacao(BaseModel):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if etapa_atual == EtapaAprovacao.HEAD:
|
if etapa_atual == EtapaAprovacao.HEAD:
|
||||||
if usuario and not usuario.tem_perfil(UsuarioSistema.Perfil.HEAD):
|
if usuario and not (
|
||||||
|
usuario.tem_perfil(UsuarioSistema.Perfil.HEAD)
|
||||||
|
or usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN)
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
if etapa_atual == EtapaAprovacao.DIRETORIA:
|
if etapa_atual == EtapaAprovacao.DIRETORIA:
|
||||||
if usuario and not usuario.tem_perfil(UsuarioSistema.Perfil.DIRETORIA):
|
if usuario and not (
|
||||||
|
usuario.tem_perfil(UsuarioSistema.Perfil.DIRETORIA)
|
||||||
|
or usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN)
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -496,23 +546,84 @@ class Solicitacao(BaseModel):
|
||||||
if self.status != StatusSolicitacao.ENVIADA:
|
if self.status != StatusSolicitacao.ENVIADA:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# GG e CONTROLADORIA podem dar parecer
|
if usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN):
|
||||||
if usuario.perfil not in [UsuarioSistema.Perfil.GG, UsuarioSistema.Perfil.CONTROLADORIA]:
|
return self.pareceres.filter(etapa=EtapaAprovacao.GG).count() == 0 or self.pareceres.filter(
|
||||||
|
etapa=EtapaAprovacao.CONTROLADORIA
|
||||||
|
).count() == 0
|
||||||
|
|
||||||
|
etapas_aptas = []
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.GG):
|
||||||
|
etapas_aptas.append(EtapaAprovacao.GG)
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.CONTROLADORIA):
|
||||||
|
etapas_aptas.append(EtapaAprovacao.CONTROLADORIA)
|
||||||
|
if not etapas_aptas:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Verifica se já deu parecer
|
# Pode dar parecer se houver ao menos uma etapa apta sem parecer ainda.
|
||||||
etapa_esperada = None
|
return any(not self.pareceres.filter(etapa=etapa).exists() for etapa in etapas_aptas)
|
||||||
if usuario.perfil == UsuarioSistema.Perfil.GG:
|
|
||||||
etapa_esperada = EtapaAprovacao.GG
|
def _rotulo_enviada_observador(self):
|
||||||
elif usuario.perfil == UsuarioSistema.Perfil.CONTROLADORIA:
|
"""
|
||||||
etapa_esperada = EtapaAprovacao.CONTROLADORIA
|
Texto para quem acompanha ENVIADA sem atuar como GG/Ctrl (ex.: Admin, Diretoria).
|
||||||
|
Reflete em qual etapa de parecer o processo está.
|
||||||
if etapa_esperada:
|
"""
|
||||||
parecer_existente = self.pareceres.filter(etapa=etapa_esperada).exists()
|
tem_gg = self.pareceres.filter(etapa=EtapaAprovacao.GG).exists()
|
||||||
if parecer_existente:
|
tem_ctrl = self.pareceres.filter(etapa=EtapaAprovacao.CONTROLADORIA).exists()
|
||||||
return False # Já deu parecer
|
if not tem_gg:
|
||||||
|
return _("Aguardando parecer Gente e Gestão")
|
||||||
return True
|
if not tem_ctrl:
|
||||||
|
return _("Aguardando parecer Controladoria")
|
||||||
|
return _("Pareceres concluídos — aguardando decisão da Diretoria")
|
||||||
|
|
||||||
|
def get_status_display_para_usuario(self, usuario):
|
||||||
|
"""
|
||||||
|
Rótulo de status para o usuário atual (fila / etapa), evitando ambiguidade
|
||||||
|
de "Enviada" para GG/Controladoria e para o solicitante em ENVIADA.
|
||||||
|
"""
|
||||||
|
base = self.get_status_display()
|
||||||
|
if not usuario:
|
||||||
|
return base
|
||||||
|
|
||||||
|
if self.status == StatusSolicitacao.AGUARDANDO_HEAD and self.pode_aprovar(usuario):
|
||||||
|
return _("Pendente aprovação Head (sua etapa)")
|
||||||
|
if self.status == StatusSolicitacao.AGUARDANDO_DIRETORIA and self.pode_aprovar(usuario):
|
||||||
|
return _("Pendente decisão Diretoria (sua etapa)")
|
||||||
|
|
||||||
|
if self.status == StatusSolicitacao.AGUARDANDO_HEAD and usuario.tem_perfil(
|
||||||
|
UsuarioSistema.Perfil.DIRETORIA
|
||||||
|
):
|
||||||
|
return _("Aguardando aprovação do Head")
|
||||||
|
|
||||||
|
if self.status != StatusSolicitacao.ENVIADA:
|
||||||
|
return base
|
||||||
|
|
||||||
|
is_gg = usuario.tem_perfil(UsuarioSistema.Perfil.GG)
|
||||||
|
is_ctrl = usuario.tem_perfil(UsuarioSistema.Perfil.CONTROLADORIA)
|
||||||
|
|
||||||
|
tem_gg = self.pareceres.filter(etapa=EtapaAprovacao.GG).exists()
|
||||||
|
tem_ctrl = self.pareceres.filter(etapa=EtapaAprovacao.CONTROLADORIA).exists()
|
||||||
|
|
||||||
|
if is_gg or is_ctrl:
|
||||||
|
if not tem_gg:
|
||||||
|
if is_gg:
|
||||||
|
return _("Pendente parecer GG (sua etapa)")
|
||||||
|
return _("Aguardando parecer GG")
|
||||||
|
if not tem_ctrl:
|
||||||
|
if is_ctrl:
|
||||||
|
return _("Pendente parecer Controladoria (sua etapa)")
|
||||||
|
return _("Aguardando parecer Controladoria")
|
||||||
|
|
||||||
|
if self.solicitante_id == usuario.id:
|
||||||
|
return _("Em análise (GG e Controladoria)")
|
||||||
|
|
||||||
|
observador_enviada = (
|
||||||
|
usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN)
|
||||||
|
or usuario.tem_perfil(UsuarioSistema.Perfil.DIRETORIA)
|
||||||
|
) and not is_gg and not is_ctrl
|
||||||
|
if observador_enviada:
|
||||||
|
return self._rotulo_enviada_observador()
|
||||||
|
|
||||||
|
return base
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
# /SGMP_PROD/solicitacoes/services.py
|
# /SGMP_PROD/solicitacoes/services.py
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import date
|
from collections import Counter
|
||||||
from typing import Dict, Any, Tuple
|
from datetime import date, datetime
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from .models import (
|
from .models import (
|
||||||
|
|
@ -116,6 +117,17 @@ def sincronizar_colaboradores_rm() -> Tuple[int, int]:
|
||||||
logger.info(f"Sincronização concluída. Criados: {criados}, Atualizados: {atualizados}.")
|
logger.info(f"Sincronização concluída. Criados: {criados}, Atualizados: {atualizados}.")
|
||||||
return criados, atualizados
|
return criados, atualizados
|
||||||
|
|
||||||
|
|
||||||
|
def _status_e_envio_inicial(solicitante: UsuarioSistema) -> Tuple[str, Optional[datetime]]:
|
||||||
|
"""
|
||||||
|
Diretoria cria já como ENVIADA (pula rascunho e aprovação do Head).
|
||||||
|
Demais perfis iniciam em RASCUNHO.
|
||||||
|
"""
|
||||||
|
if solicitante.tem_perfil(UsuarioSistema.Perfil.DIRETORIA):
|
||||||
|
return StatusSolicitacao.ENVIADA, timezone.now()
|
||||||
|
return StatusSolicitacao.RASCUNHO, None
|
||||||
|
|
||||||
|
|
||||||
# --- Service de Gestão de Solicitações (Core do Domínio) ---
|
# --- Service de Gestão de Solicitações (Core do Domínio) ---
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def criar_solicitacao_desligamento(
|
def criar_solicitacao_desligamento(
|
||||||
|
|
@ -161,12 +173,16 @@ def criar_solicitacao_desligamento(
|
||||||
"\n".join(f"• {msg}" for msg in mensagens)
|
"\n".join(f"• {msg}" for msg in mensagens)
|
||||||
)
|
)
|
||||||
|
|
||||||
solicitacao = Solicitacao.objects.create(
|
status_inicial, enviada_em = _status_e_envio_inicial(solicitante)
|
||||||
tipo=TipoSolicitacao.DESLIGAMENTO,
|
create_kwargs = {
|
||||||
solicitante=solicitante,
|
"tipo": TipoSolicitacao.DESLIGAMENTO,
|
||||||
funcionario=funcionario,
|
"solicitante": solicitante,
|
||||||
status=StatusSolicitacao.RASCUNHO
|
"funcionario": funcionario,
|
||||||
)
|
"status": status_inicial,
|
||||||
|
}
|
||||||
|
if enviada_em is not None:
|
||||||
|
create_kwargs["enviada_em"] = enviada_em
|
||||||
|
solicitacao = Solicitacao.objects.create(**create_kwargs)
|
||||||
|
|
||||||
Desligamento.objects.create(
|
Desligamento.objects.create(
|
||||||
solicitacao=solicitacao,
|
solicitacao=solicitacao,
|
||||||
|
|
@ -192,13 +208,16 @@ def criar_solicitacao_aumento_quadro(
|
||||||
Este tipo de solicitação não possui um 'funcionario' vinculado
|
Este tipo de solicitação não possui um 'funcionario' vinculado
|
||||||
inicialmente, pois representa a criação de uma nova vaga.
|
inicialmente, pois representa a criação de uma nova vaga.
|
||||||
"""
|
"""
|
||||||
solicitacao = Solicitacao.objects.create(
|
status_inicial, enviada_em = _status_e_envio_inicial(solicitante)
|
||||||
tipo=TipoSolicitacao.ADMISSAO_AUMENTO,
|
create_kwargs = {
|
||||||
solicitante=solicitante,
|
"tipo": TipoSolicitacao.ADMISSAO_AUMENTO,
|
||||||
# 'funcionario' é None por definição aqui
|
"solicitante": solicitante,
|
||||||
funcionario=None,
|
"funcionario": None,
|
||||||
status=StatusSolicitacao.RASCUNHO
|
"status": status_inicial,
|
||||||
)
|
}
|
||||||
|
if enviada_em is not None:
|
||||||
|
create_kwargs["enviada_em"] = enviada_em
|
||||||
|
solicitacao = Solicitacao.objects.create(**create_kwargs)
|
||||||
|
|
||||||
AdmissaoAumentoQuadro.objects.create(
|
AdmissaoAumentoQuadro.objects.create(
|
||||||
solicitacao=solicitacao,
|
solicitacao=solicitacao,
|
||||||
|
|
@ -238,12 +257,16 @@ def criar_solicitacao_substituicao(
|
||||||
).exists():
|
).exists():
|
||||||
raise ValidacaoError("Já existe uma solicitação em andamento para este funcionário.")
|
raise ValidacaoError("Já existe uma solicitação em andamento para este funcionário.")
|
||||||
|
|
||||||
solicitacao = Solicitacao.objects.create(
|
status_inicial, enviada_em = _status_e_envio_inicial(solicitante)
|
||||||
tipo=TipoSolicitacao.ADMISSAO_SUBSTITUICAO,
|
create_kwargs = {
|
||||||
solicitante=solicitante,
|
"tipo": TipoSolicitacao.ADMISSAO_SUBSTITUICAO,
|
||||||
funcionario=funcionario_substituido,
|
"solicitante": solicitante,
|
||||||
status=StatusSolicitacao.RASCUNHO,
|
"funcionario": funcionario_substituido,
|
||||||
)
|
"status": status_inicial,
|
||||||
|
}
|
||||||
|
if enviada_em is not None:
|
||||||
|
create_kwargs["enviada_em"] = enviada_em
|
||||||
|
solicitacao = Solicitacao.objects.create(**create_kwargs)
|
||||||
|
|
||||||
AdmissaoSubstituicao.objects.create(
|
AdmissaoSubstituicao.objects.create(
|
||||||
solicitacao=solicitacao,
|
solicitacao=solicitacao,
|
||||||
|
|
@ -286,12 +309,16 @@ def criar_solicitacao_movimentacao(
|
||||||
).exists():
|
).exists():
|
||||||
raise ValidacaoError("Já existe uma solicitação em andamento para este funcionário.")
|
raise ValidacaoError("Já existe uma solicitação em andamento para este funcionário.")
|
||||||
|
|
||||||
solicitacao = Solicitacao.objects.create(
|
status_inicial, enviada_em = _status_e_envio_inicial(solicitante)
|
||||||
tipo=TipoSolicitacao.MOVIMENTACAO,
|
create_kwargs = {
|
||||||
solicitante=solicitante,
|
"tipo": TipoSolicitacao.MOVIMENTACAO,
|
||||||
funcionario=funcionario,
|
"solicitante": solicitante,
|
||||||
status=StatusSolicitacao.RASCUNHO,
|
"funcionario": funcionario,
|
||||||
)
|
"status": status_inicial,
|
||||||
|
}
|
||||||
|
if enviada_em is not None:
|
||||||
|
create_kwargs["enviada_em"] = enviada_em
|
||||||
|
solicitacao = Solicitacao.objects.create(**create_kwargs)
|
||||||
|
|
||||||
Movimentacao.objects.create(
|
Movimentacao.objects.create(
|
||||||
solicitacao=solicitacao,
|
solicitacao=solicitacao,
|
||||||
|
|
@ -319,7 +346,11 @@ def enviar_solicitacao(solicitacao: Solicitacao, usuario: UsuarioSistema) -> Sol
|
||||||
"""
|
"""
|
||||||
if solicitacao.solicitante != usuario:
|
if solicitacao.solicitante != usuario:
|
||||||
raise PermissaoError("Apenas o solicitante pode enviar a solicitação.")
|
raise PermissaoError("Apenas o solicitante pode enviar a solicitação.")
|
||||||
|
|
||||||
|
# Já disparada (ex.: Diretoria cria direto como ENVIADA; cliente pode chamar enviar de novo)
|
||||||
|
if solicitacao.status == StatusSolicitacao.ENVIADA:
|
||||||
|
return solicitacao
|
||||||
|
|
||||||
if not solicitacao.pode_enviar():
|
if not solicitacao.pode_enviar():
|
||||||
raise ValidacaoError(f"A solicitação não pode ser enviada no status atual ({solicitacao.get_status_display()}).")
|
raise ValidacaoError(f"A solicitação não pode ser enviada no status atual ({solicitacao.get_status_display()}).")
|
||||||
|
|
||||||
|
|
@ -352,15 +383,28 @@ def registrar_parecer(
|
||||||
if not solicitacao.pode_dar_parecer(usuario):
|
if not solicitacao.pode_dar_parecer(usuario):
|
||||||
raise PermissaoError("Usuário não pode dar parecer nesta solicitação.")
|
raise PermissaoError("Usuário não pode dar parecer nesta solicitação.")
|
||||||
|
|
||||||
# Mapeia perfil para etapa
|
etapas_aptas = []
|
||||||
mapa_perfil_etapa = {
|
if usuario.tem_perfil(UsuarioSistema.Perfil.GG):
|
||||||
UsuarioSistema.Perfil.GG: EtapaAprovacao.GG,
|
etapas_aptas.append(EtapaAprovacao.GG)
|
||||||
UsuarioSistema.Perfil.CONTROLADORIA: EtapaAprovacao.CONTROLADORIA,
|
if usuario.tem_perfil(UsuarioSistema.Perfil.CONTROLADORIA):
|
||||||
}
|
etapas_aptas.append(EtapaAprovacao.CONTROLADORIA)
|
||||||
etapa = mapa_perfil_etapa.get(usuario.perfil)
|
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN):
|
||||||
|
# ADMIN atua como super-perfil: assume a primeira etapa pendente.
|
||||||
|
if not Parecer.objects.filter(solicitacao=solicitacao, etapa=EtapaAprovacao.GG).exists():
|
||||||
|
etapa = EtapaAprovacao.GG
|
||||||
|
elif not Parecer.objects.filter(solicitacao=solicitacao, etapa=EtapaAprovacao.CONTROLADORIA).exists():
|
||||||
|
etapa = EtapaAprovacao.CONTROLADORIA
|
||||||
|
else:
|
||||||
|
raise ValidacaoError("Esta solicitação já possui todos os pareceres técnicos.")
|
||||||
|
else:
|
||||||
|
etapa = next(
|
||||||
|
(e for e in etapas_aptas if not Parecer.objects.filter(solicitacao=solicitacao, etapa=e).exists()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
if not etapa:
|
if not etapa:
|
||||||
raise PermissaoError("Apenas GG e Controladoria podem dar parecer.")
|
raise PermissaoError("Apenas GG, Controladoria ou Admin podem dar parecer.")
|
||||||
|
|
||||||
# Verifica se já existe parecer para esta etapa
|
# Verifica se já existe parecer para esta etapa
|
||||||
if Parecer.objects.filter(solicitacao=solicitacao, etapa=etapa).exists():
|
if Parecer.objects.filter(solicitacao=solicitacao, etapa=etapa).exists():
|
||||||
|
|
@ -406,8 +450,11 @@ def aprovar_reprovar_solicitacao(
|
||||||
- Verifica se a solicitação está aguardando aprovação da Diretoria
|
- Verifica se a solicitação está aguardando aprovação da Diretoria
|
||||||
- Justificativa é obrigatória apenas para reprovação
|
- Justificativa é obrigatória apenas para reprovação
|
||||||
"""
|
"""
|
||||||
if aprovador.perfil != UsuarioSistema.Perfil.DIRETORIA:
|
if not (
|
||||||
raise PermissaoError("Apenas a Diretoria pode aprovar/reprovar solicitações.")
|
aprovador.tem_perfil(UsuarioSistema.Perfil.DIRETORIA)
|
||||||
|
or aprovador.tem_perfil(UsuarioSistema.Perfil.ADMIN)
|
||||||
|
):
|
||||||
|
raise PermissaoError("Apenas a Diretoria ou Admin pode aprovar/reprovar solicitações.")
|
||||||
|
|
||||||
if solicitacao.status != StatusSolicitacao.AGUARDANDO_DIRETORIA:
|
if solicitacao.status != StatusSolicitacao.AGUARDANDO_DIRETORIA:
|
||||||
raise ValidacaoError("A solicitação não está aguardando aprovação da Diretoria.")
|
raise ValidacaoError("A solicitação não está aguardando aprovação da Diretoria.")
|
||||||
|
|
@ -457,8 +504,11 @@ def aprovar_reprovar_por_head(
|
||||||
Se aprovado: status → ENVIADA e enviada_em é preenchido.
|
Se aprovado: status → ENVIADA e enviada_em é preenchido.
|
||||||
Se reprovado: status → REPROVADA e finalizada_em é preenchido.
|
Se reprovado: status → REPROVADA e finalizada_em é preenchido.
|
||||||
"""
|
"""
|
||||||
if aprovador.perfil != UsuarioSistema.Perfil.HEAD:
|
if not (
|
||||||
raise PermissaoError("Apenas o Head pode aprovar/reprovar solicitações nesta etapa.")
|
aprovador.tem_perfil(UsuarioSistema.Perfil.HEAD)
|
||||||
|
or aprovador.tem_perfil(UsuarioSistema.Perfil.ADMIN)
|
||||||
|
):
|
||||||
|
raise PermissaoError("Apenas o Head ou Admin pode aprovar/reprovar solicitações nesta etapa.")
|
||||||
|
|
||||||
if solicitacao.status != StatusSolicitacao.AGUARDANDO_HEAD:
|
if solicitacao.status != StatusSolicitacao.AGUARDANDO_HEAD:
|
||||||
raise ValidacaoError("A solicitação não está aguardando aprovação do Head.")
|
raise ValidacaoError("A solicitação não está aguardando aprovação do Head.")
|
||||||
|
|
@ -492,3 +542,171 @@ def aprovar_reprovar_por_head(
|
||||||
return solicitacao
|
return solicitacao
|
||||||
|
|
||||||
|
|
||||||
|
def _status_apos_remover_pareceres(solicitacao: Solicitacao) -> str:
|
||||||
|
"""Define status após remoção de um parecer (GG/Ctrl): volta para ENVIADA até ambos existirem de novo."""
|
||||||
|
tem_gg = Parecer.objects.filter(
|
||||||
|
solicitacao=solicitacao, etapa=EtapaAprovacao.GG
|
||||||
|
).exists()
|
||||||
|
tem_ctrl = Parecer.objects.filter(
|
||||||
|
solicitacao=solicitacao, etapa=EtapaAprovacao.CONTROLADORIA
|
||||||
|
).exists()
|
||||||
|
if tem_gg and tem_ctrl:
|
||||||
|
return StatusSolicitacao.AGUARDANDO_DIRETORIA
|
||||||
|
return StatusSolicitacao.ENVIADA
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def reverter_ultima_acao(solicitacao: Solicitacao, dry_run: bool = False) -> dict:
|
||||||
|
"""
|
||||||
|
Desfaz um passo do fluxo, da situação atual para trás (última ação gravada).
|
||||||
|
|
||||||
|
Ordem de reversão:
|
||||||
|
1. FINALIZADA com Aprovacao DIRETORIA → remove decisão da Diretoria.
|
||||||
|
2. AGUARDANDO_DIRETORIA → remove o parecer mais recente (GG ou Controladoria).
|
||||||
|
3. ENVIADA → remove o parecer mais recente, se houver; senão remove Aprovacao HEAD (volta AGUARDANDO_HEAD).
|
||||||
|
4. AGUARDANDO_HEAD (sem aprovação Head) → volta para RASCUNHO (desfaz envio ao Head).
|
||||||
|
|
||||||
|
Não trata REPROVADA nem estados legados (APROVADA_*); use o Admin ou script dedicado.
|
||||||
|
|
||||||
|
Retorna dict com chaves: acao (str), detalhe (str).
|
||||||
|
Se dry_run=True, não grava alterações.
|
||||||
|
"""
|
||||||
|
pk = solicitacao.pk
|
||||||
|
s = Solicitacao.objects.select_for_update().get(pk=pk) if not dry_run else Solicitacao.objects.get(pk=pk)
|
||||||
|
|
||||||
|
st = s.status
|
||||||
|
acao = ""
|
||||||
|
detalhe = ""
|
||||||
|
|
||||||
|
# 1) Decisão da Diretoria
|
||||||
|
if st == StatusSolicitacao.FINALIZADA:
|
||||||
|
qs_dir = Aprovacao.objects.filter(solicitacao=s, etapa=EtapaAprovacao.DIRETORIA)
|
||||||
|
if not qs_dir.exists():
|
||||||
|
raise ValidacaoError(
|
||||||
|
"FINALIZADA sem registro de Aprovacao na DIRETORIA — estado inconsistente; corrija manualmente."
|
||||||
|
)
|
||||||
|
novo = _status_apos_remover_pareceres(s)
|
||||||
|
acao = "remover_aprovacao_diretoria"
|
||||||
|
detalhe = f"Remover decisão DIRETORIA; status → {novo}"
|
||||||
|
if not dry_run:
|
||||||
|
qs_dir.delete()
|
||||||
|
s.finalizada_em = None
|
||||||
|
s.status = novo
|
||||||
|
s.save()
|
||||||
|
logger.warning(
|
||||||
|
"reverter_ultima_acao: removida Aprovacao DIRETORIA da solicitação %s → %s",
|
||||||
|
s.id,
|
||||||
|
novo,
|
||||||
|
)
|
||||||
|
return {"acao": acao, "detalhe": detalhe, "novo_status": novo}
|
||||||
|
|
||||||
|
# 2) Estava aguardando Diretoria: desfaz último parecer técnico
|
||||||
|
if st == StatusSolicitacao.AGUARDANDO_DIRETORIA:
|
||||||
|
ultimo = (
|
||||||
|
Parecer.objects.filter(solicitacao=s)
|
||||||
|
.order_by("-criado_em", "-id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not ultimo:
|
||||||
|
raise ValidacaoError(
|
||||||
|
"AGUARDANDO_DIRETORIA sem pareceres — inconsistente; corrija manualmente."
|
||||||
|
)
|
||||||
|
et = ultimo.etapa
|
||||||
|
acao = "remover_parecer"
|
||||||
|
detalhe = f"Remover parecer {et} (mais recente); status → ENVIADA"
|
||||||
|
novo = StatusSolicitacao.ENVIADA
|
||||||
|
if not dry_run:
|
||||||
|
ultimo.delete()
|
||||||
|
s.status = novo
|
||||||
|
s.save()
|
||||||
|
logger.warning(
|
||||||
|
"reverter_ultima_acao: removido Parecer %s da solicitação %s → ENVIADA",
|
||||||
|
et,
|
||||||
|
s.id,
|
||||||
|
)
|
||||||
|
return {"acao": acao, "detalhe": detalhe, "novo_status": novo}
|
||||||
|
|
||||||
|
# 3) ENVIADA: parecer mais recente OU aprovação Head
|
||||||
|
if st == StatusSolicitacao.ENVIADA:
|
||||||
|
ultimo_p = (
|
||||||
|
Parecer.objects.filter(solicitacao=s)
|
||||||
|
.order_by("-criado_em", "-id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if ultimo_p:
|
||||||
|
et = ultimo_p.etapa
|
||||||
|
acao = "remover_parecer"
|
||||||
|
if dry_run:
|
||||||
|
c = Counter(
|
||||||
|
Parecer.objects.filter(solicitacao=s).values_list("etapa", flat=True)
|
||||||
|
)
|
||||||
|
c[et] -= 1
|
||||||
|
tem_gg = c[EtapaAprovacao.GG] > 0
|
||||||
|
tem_ctrl = c[EtapaAprovacao.CONTROLADORIA] > 0
|
||||||
|
novo = (
|
||||||
|
StatusSolicitacao.AGUARDANDO_DIRETORIA
|
||||||
|
if (tem_gg and tem_ctrl)
|
||||||
|
else StatusSolicitacao.ENVIADA
|
||||||
|
)
|
||||||
|
detalhe = f"Remover parecer {et} (mais recente); status → {novo}"
|
||||||
|
return {"acao": acao, "detalhe": detalhe, "novo_status": novo}
|
||||||
|
ultimo_p.delete()
|
||||||
|
s.refresh_from_db()
|
||||||
|
s.status = _status_apos_remover_pareceres(s)
|
||||||
|
s.save()
|
||||||
|
logger.warning(
|
||||||
|
"reverter_ultima_acao: removido Parecer %s da solicitação %s → %s",
|
||||||
|
et,
|
||||||
|
s.id,
|
||||||
|
s.status,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"acao": acao,
|
||||||
|
"detalhe": f"Remover parecer {et}; status → {s.status}",
|
||||||
|
"novo_status": s.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
ap_head = Aprovacao.objects.filter(solicitacao=s, etapa=EtapaAprovacao.HEAD).first()
|
||||||
|
if ap_head:
|
||||||
|
acao = "remover_aprovacao_head"
|
||||||
|
detalhe = "Remover aprovação HEAD; status → AGUARDANDO_HEAD; limpar enviada_em"
|
||||||
|
novo = StatusSolicitacao.AGUARDANDO_HEAD
|
||||||
|
if not dry_run:
|
||||||
|
ap_head.delete()
|
||||||
|
s.status = novo
|
||||||
|
s.enviada_em = None
|
||||||
|
s.save()
|
||||||
|
logger.warning(
|
||||||
|
"reverter_ultima_acao: removida Aprovacao HEAD da solicitação %s → AGUARDANDO_HEAD",
|
||||||
|
s.id,
|
||||||
|
)
|
||||||
|
return {"acao": acao, "detalhe": detalhe, "novo_status": novo}
|
||||||
|
|
||||||
|
raise ValidacaoError(
|
||||||
|
"ENVIADA sem pareceres e sem Aprovacao HEAD — nada a reverter (ex.: criada pela Diretoria já neste status)."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4) Aguardando Head: desfaz o envio (volta rascunho)
|
||||||
|
if st == StatusSolicitacao.AGUARDANDO_HEAD:
|
||||||
|
if Aprovacao.objects.filter(solicitacao=s, etapa=EtapaAprovacao.HEAD).exists():
|
||||||
|
raise ValidacaoError(
|
||||||
|
"AGUARDANDO_HEAD com Aprovacao HEAD já registrada — estado inconsistente; use reversão após corrigir status."
|
||||||
|
)
|
||||||
|
acao = "desfazer_envio_head"
|
||||||
|
detalhe = "Voltar de AGUARDANDO_HEAD para RASCUNHO (desfaz envio ao Head)"
|
||||||
|
novo = StatusSolicitacao.RASCUNHO
|
||||||
|
if not dry_run:
|
||||||
|
s.status = novo
|
||||||
|
s.save()
|
||||||
|
logger.warning(
|
||||||
|
"reverter_ultima_acao: solicitação %s voltou de AGUARDANDO_HEAD → RASCUNHO",
|
||||||
|
s.id,
|
||||||
|
)
|
||||||
|
return {"acao": acao, "detalhe": detalhe, "novo_status": novo}
|
||||||
|
|
||||||
|
raise ValidacaoError(
|
||||||
|
f"Reversão não suportada para o status atual ({st}). "
|
||||||
|
"Estados como REPROVADA ou APROVADA_* legados exigem correção manual."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from ..acesso import usuario_pode_gerenciar_permissoes
|
||||||
|
from ..models import ConfiguracaoSGMP, UsuarioSistema
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class UsuarioPodeGerenciarPermissoesLogicTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.cfg = ConfiguracaoSGMP.load()
|
||||||
|
self.cfg.perfis_gerenciar_permissoes = [UsuarioSistema.Perfil.ADMIN]
|
||||||
|
self.cfg.save()
|
||||||
|
|
||||||
|
def test_gestor_negado_quando_so_admin(self):
|
||||||
|
u = UsuarioSistema(
|
||||||
|
matricula="1",
|
||||||
|
nome="Gestor",
|
||||||
|
perfil=UsuarioSistema.Perfil.GESTOR,
|
||||||
|
ativo=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(usuario_pode_gerenciar_permissoes(u))
|
||||||
|
|
||||||
|
def test_admin_permitido(self):
|
||||||
|
u = UsuarioSistema(
|
||||||
|
matricula="2",
|
||||||
|
nome="Admin",
|
||||||
|
perfil=UsuarioSistema.Perfil.ADMIN,
|
||||||
|
ativo=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(usuario_pode_gerenciar_permissoes(u))
|
||||||
|
|
||||||
|
def test_gg_permitido_quando_config_inclui_gg(self):
|
||||||
|
self.cfg.perfis_gerenciar_permissoes = [
|
||||||
|
UsuarioSistema.Perfil.ADMIN,
|
||||||
|
UsuarioSistema.Perfil.GG,
|
||||||
|
]
|
||||||
|
self.cfg.save()
|
||||||
|
u = UsuarioSistema(
|
||||||
|
matricula="3",
|
||||||
|
nome="GG",
|
||||||
|
perfil=UsuarioSistema.Perfil.GG,
|
||||||
|
ativo=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(usuario_pode_gerenciar_permissoes(u))
|
||||||
|
|
||||||
|
def test_inativo_negado(self):
|
||||||
|
u = UsuarioSistema(
|
||||||
|
matricula="4",
|
||||||
|
nome="Admin off",
|
||||||
|
perfil=UsuarioSistema.Perfil.ADMIN,
|
||||||
|
ativo=False,
|
||||||
|
)
|
||||||
|
self.assertFalse(usuario_pode_gerenciar_permissoes(u))
|
||||||
|
|
||||||
|
|
||||||
|
class GerenciarPermissoesViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
ConfiguracaoSGMP.objects.all().delete()
|
||||||
|
self.cfg = ConfiguracaoSGMP.load()
|
||||||
|
self.cfg.perfis_gerenciar_permissoes = [UsuarioSistema.Perfil.ADMIN]
|
||||||
|
self.cfg.save()
|
||||||
|
|
||||||
|
User.objects.create_user(username="10", password="secret")
|
||||||
|
UsuarioSistema.objects.create(
|
||||||
|
matricula="10",
|
||||||
|
nome="Gestor",
|
||||||
|
perfil=UsuarioSistema.Perfil.GESTOR,
|
||||||
|
ativo=True,
|
||||||
|
)
|
||||||
|
User.objects.create_user(username="20", password="secret")
|
||||||
|
UsuarioSistema.objects.create(
|
||||||
|
matricula="20",
|
||||||
|
nome="Admin",
|
||||||
|
perfil=UsuarioSistema.Perfil.ADMIN,
|
||||||
|
ativo=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.url = reverse("solicitacoes:gerenciar_permissoes")
|
||||||
|
self.client = Client()
|
||||||
|
|
||||||
|
def test_gestor_redireciona_dashboard(self):
|
||||||
|
self.client.login(username="10", password="secret")
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 302)
|
||||||
|
self.assertEqual(r["Location"], reverse("solicitacoes:dashboard"))
|
||||||
|
|
||||||
|
def test_admin_ok(self):
|
||||||
|
self.client.login(username="20", password="secret")
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from unittest import TestCase as AssertionsMixin
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest import TestCase as AssertionsMixin
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
|
|
@ -14,9 +15,12 @@ from ..models import (
|
||||||
Solicitacao,
|
Solicitacao,
|
||||||
Desligamento,
|
Desligamento,
|
||||||
Aprovacao,
|
Aprovacao,
|
||||||
|
Parecer,
|
||||||
StatusSolicitacao,
|
StatusSolicitacao,
|
||||||
DecisaoAprovacao,
|
DecisaoAprovacao,
|
||||||
EtapaAprovacao
|
EtapaAprovacao,
|
||||||
|
TipoDesligamento,
|
||||||
|
TipoAvisoPrevio,
|
||||||
)
|
)
|
||||||
from .. import services
|
from .. import services
|
||||||
|
|
||||||
|
|
@ -130,6 +134,8 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
|
||||||
solicitacao = services.criar_solicitacao_desligamento(
|
solicitacao = services.criar_solicitacao_desligamento(
|
||||||
solicitante=self.solicitante,
|
solicitante=self.solicitante,
|
||||||
funcionario=self.funcionario,
|
funcionario=self.funcionario,
|
||||||
|
tipo_desligamento=TipoDesligamento.OUTROS,
|
||||||
|
aviso_previo=TipoAvisoPrevio.TRABALHADO,
|
||||||
motivo="Teste de criação",
|
motivo="Teste de criação",
|
||||||
data_prevista_desligamento=date.today(),
|
data_prevista_desligamento=date.today(),
|
||||||
)
|
)
|
||||||
|
|
@ -145,6 +151,28 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
|
||||||
self.assertEqual(solicitacao.desligamento.motivo, "Teste de criação")
|
self.assertEqual(solicitacao.desligamento.motivo, "Teste de criação")
|
||||||
self._add_step("Validação dos atributos e status inicial da solicitação.")
|
self._add_step("Validação dos atributos e status inicial da solicitação.")
|
||||||
|
|
||||||
|
@patch("solicitacoes.services.verificar_estabilidades_colaborador", return_value=[])
|
||||||
|
def test_diretoria_cria_direto_enviada_e_enviar_idempotente(self, _mock_estabilidades):
|
||||||
|
"""
|
||||||
|
Diretoria cria em ENVIADA com enviada_em; enviar_solicitacao é no-op.
|
||||||
|
"""
|
||||||
|
diretoria = UsuarioSistema.objects.create(
|
||||||
|
matricula="405", nome="Diretoria Criadora", perfil=UsuarioSistema.Perfil.DIRETORIA
|
||||||
|
)
|
||||||
|
solicitacao = services.criar_solicitacao_desligamento(
|
||||||
|
solicitante=diretoria,
|
||||||
|
funcionario=self.funcionario,
|
||||||
|
tipo_desligamento=TipoDesligamento.OUTROS,
|
||||||
|
aviso_previo=TipoAvisoPrevio.TRABALHADO,
|
||||||
|
motivo="Diretoria cria direto",
|
||||||
|
data_prevista_desligamento=date.today(),
|
||||||
|
)
|
||||||
|
self.assertEqual(solicitacao.status, StatusSolicitacao.ENVIADA)
|
||||||
|
self.assertIsNotNone(solicitacao.enviada_em)
|
||||||
|
services.enviar_solicitacao(solicitacao, usuario=diretoria)
|
||||||
|
solicitacao.refresh_from_db()
|
||||||
|
self.assertEqual(solicitacao.status, StatusSolicitacao.ENVIADA)
|
||||||
|
|
||||||
def test_fluxo_aprovacao_completo_sucesso(self):
|
def test_fluxo_aprovacao_completo_sucesso(self):
|
||||||
"""
|
"""
|
||||||
Simula o fluxo completo de aprovação de um desligamento,
|
Simula o fluxo completo de aprovação de um desligamento,
|
||||||
|
|
@ -152,8 +180,12 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
|
||||||
"""
|
"""
|
||||||
# Etapa 1: Criação e Envio (gestor envia → AGUARDANDO_HEAD)
|
# Etapa 1: Criação e Envio (gestor envia → AGUARDANDO_HEAD)
|
||||||
solicitacao = services.criar_solicitacao_desligamento(
|
solicitacao = services.criar_solicitacao_desligamento(
|
||||||
solicitante=self.solicitante, funcionario=self.funcionario,
|
solicitante=self.solicitante,
|
||||||
motivo="Fluxo completo", data_prevista_desligamento=date.today()
|
funcionario=self.funcionario,
|
||||||
|
tipo_desligamento=TipoDesligamento.OUTROS,
|
||||||
|
aviso_previo=TipoAvisoPrevio.TRABALHADO,
|
||||||
|
motivo="Fluxo completo",
|
||||||
|
data_prevista_desligamento=date.today(),
|
||||||
)
|
)
|
||||||
services.enviar_solicitacao(solicitacao, usuario=self.solicitante)
|
services.enviar_solicitacao(solicitacao, usuario=self.solicitante)
|
||||||
solicitacao.refresh_from_db()
|
solicitacao.refresh_from_db()
|
||||||
|
|
@ -170,22 +202,23 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
|
||||||
self.assertIsNotNone(solicitacao.enviada_em)
|
self.assertIsNotNone(solicitacao.enviada_em)
|
||||||
self._add_step("Aprovação pelo Head (status: ENVIADA).")
|
self._add_step("Aprovação pelo Head (status: ENVIADA).")
|
||||||
|
|
||||||
# Etapa 2: Aprovação GG
|
# Etapa 2: Parecer GG (status permanece ENVIADA até ambos os pareceres)
|
||||||
services.aprovar_reprovar_solicitacao(
|
services.registrar_parecer(solicitacao, self.aprovador_gg, "OK GG")
|
||||||
solicitacao, self.aprovador_gg, DecisaoAprovacao.APROVADO, "OK GG"
|
|
||||||
)
|
|
||||||
solicitacao.refresh_from_db()
|
solicitacao.refresh_from_db()
|
||||||
self.assertEqual(solicitacao.status, StatusSolicitacao.APROVADA_GG)
|
self.assertEqual(solicitacao.status, StatusSolicitacao.ENVIADA)
|
||||||
self.assertTrue(Aprovacao.objects.filter(solicitacao=solicitacao, etapa=EtapaAprovacao.GG).exists())
|
self.assertTrue(
|
||||||
self._add_step("Aprovação por Gente & Gestão (status: APROVADA_GG).")
|
Parecer.objects.filter(solicitacao=solicitacao, etapa=EtapaAprovacao.GG).exists()
|
||||||
|
|
||||||
# Etapa 3: Aprovação Controladoria
|
|
||||||
services.aprovar_reprovar_solicitacao(
|
|
||||||
solicitacao, self.aprovador_controladoria, DecisaoAprovacao.APROVADO, "OK Controladoria"
|
|
||||||
)
|
)
|
||||||
|
self._add_step("Parecer GG registrado (status: ENVIADA).")
|
||||||
|
|
||||||
|
# Etapa 3: Parecer Controladoria → AGUARDANDO_DIRETORIA
|
||||||
|
services.registrar_parecer(solicitacao, self.aprovador_controladoria, "OK Controladoria")
|
||||||
solicitacao.refresh_from_db()
|
solicitacao.refresh_from_db()
|
||||||
self.assertEqual(solicitacao.status, StatusSolicitacao.APROVADA_CONTROLADORIA)
|
self.assertEqual(solicitacao.status, StatusSolicitacao.AGUARDANDO_DIRETORIA)
|
||||||
self._add_step("Aprovação pela Controladoria (status: APROVADA_CONTROLADORIA).")
|
self.assertTrue(
|
||||||
|
Parecer.objects.filter(solicitacao=solicitacao, etapa=EtapaAprovacao.CONTROLADORIA).exists()
|
||||||
|
)
|
||||||
|
self._add_step("Parecer Controladoria (status: AGUARDANDO_DIRETORIA).")
|
||||||
|
|
||||||
# Etapa 4: Aprovação Diretoria (Finalização)
|
# Etapa 4: Aprovação Diretoria (Finalização)
|
||||||
services.aprovar_reprovar_solicitacao(
|
services.aprovar_reprovar_solicitacao(
|
||||||
|
|
@ -202,8 +235,12 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
|
||||||
para o status 'REPROVADA' e a finaliza.
|
para o status 'REPROVADA' e a finaliza.
|
||||||
"""
|
"""
|
||||||
solicitacao = services.criar_solicitacao_desligamento(
|
solicitacao = services.criar_solicitacao_desligamento(
|
||||||
solicitante=self.solicitante, funcionario=self.funcionario,
|
solicitante=self.solicitante,
|
||||||
motivo="Teste de reprovação", data_prevista_desligamento=date.today()
|
funcionario=self.funcionario,
|
||||||
|
tipo_desligamento=TipoDesligamento.OUTROS,
|
||||||
|
aviso_previo=TipoAvisoPrevio.TRABALHADO,
|
||||||
|
motivo="Teste de reprovação",
|
||||||
|
data_prevista_desligamento=date.today(),
|
||||||
)
|
)
|
||||||
services.enviar_solicitacao(solicitacao, usuario=self.solicitante)
|
services.enviar_solicitacao(solicitacao, usuario=self.solicitante)
|
||||||
self._add_step("Solicitação criada e enviada (AGUARDANDO_HEAD).")
|
self._add_step("Solicitação criada e enviada (AGUARDANDO_HEAD).")
|
||||||
|
|
@ -229,16 +266,24 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
|
||||||
"""
|
"""
|
||||||
# Cria a primeira solicitação (válida)
|
# Cria a primeira solicitação (válida)
|
||||||
services.criar_solicitacao_desligamento(
|
services.criar_solicitacao_desligamento(
|
||||||
solicitante=self.solicitante, funcionario=self.funcionario,
|
solicitante=self.solicitante,
|
||||||
motivo="Primeira solicitação", data_prevista_desligamento=date.today()
|
funcionario=self.funcionario,
|
||||||
|
tipo_desligamento=TipoDesligamento.OUTROS,
|
||||||
|
aviso_previo=TipoAvisoPrevio.TRABALHADO,
|
||||||
|
motivo="Primeira solicitação",
|
||||||
|
data_prevista_desligamento=date.today(),
|
||||||
)
|
)
|
||||||
self._add_step("Primeira solicitação criada com sucesso.")
|
self._add_step("Primeira solicitação criada com sucesso.")
|
||||||
|
|
||||||
# Tenta criar a segunda (inválida)
|
# Tenta criar a segunda (inválida)
|
||||||
with self.assertRaises(services.ValidacaoError) as ctx:
|
with self.assertRaises(services.ValidacaoError) as ctx:
|
||||||
services.criar_solicitacao_desligamento(
|
services.criar_solicitacao_desligamento(
|
||||||
solicitante=self.solicitante, funcionario=self.funcionario,
|
solicitante=self.solicitante,
|
||||||
motivo="Segunda solicitação (inválida)", data_prevista_desligamento=date.today()
|
funcionario=self.funcionario,
|
||||||
|
tipo_desligamento=TipoDesligamento.OUTROS,
|
||||||
|
aviso_previo=TipoAvisoPrevio.TRABALHADO,
|
||||||
|
motivo="Segunda solicitação (inválida)",
|
||||||
|
data_prevista_desligamento=date.today(),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn("Já existe uma solicitação em andamento", str(ctx.exception))
|
self.assertIn("Já existe uma solicitação em andamento", str(ctx.exception))
|
||||||
|
|
@ -251,8 +296,12 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
|
||||||
o solicitante original tenta enviar a solicitação.
|
o solicitante original tenta enviar a solicitação.
|
||||||
"""
|
"""
|
||||||
solicitacao = services.criar_solicitacao_desligamento(
|
solicitacao = services.criar_solicitacao_desligamento(
|
||||||
solicitante=self.solicitante, funcionario=self.funcionario,
|
solicitante=self.solicitante,
|
||||||
motivo="Teste de permissão", data_prevista_desligamento=date.today()
|
funcionario=self.funcionario,
|
||||||
|
tipo_desligamento=TipoDesligamento.OUTROS,
|
||||||
|
aviso_previo=TipoAvisoPrevio.TRABALHADO,
|
||||||
|
motivo="Teste de permissão",
|
||||||
|
data_prevista_desligamento=date.today(),
|
||||||
)
|
)
|
||||||
self._add_step("Solicitação criada pelo solicitante original.")
|
self._add_step("Solicitação criada pelo solicitante original.")
|
||||||
|
|
||||||
|
|
@ -267,8 +316,12 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
|
||||||
inadequado tenta aprovar a etapa do Head.
|
inadequado tenta aprovar a etapa do Head.
|
||||||
"""
|
"""
|
||||||
solicitacao = services.criar_solicitacao_desligamento(
|
solicitacao = services.criar_solicitacao_desligamento(
|
||||||
solicitante=self.solicitante, funcionario=self.funcionario,
|
solicitante=self.solicitante,
|
||||||
motivo="Teste de perfil", data_prevista_desligamento=date.today()
|
funcionario=self.funcionario,
|
||||||
|
tipo_desligamento=TipoDesligamento.OUTROS,
|
||||||
|
aviso_previo=TipoAvisoPrevio.TRABALHADO,
|
||||||
|
motivo="Teste de perfil",
|
||||||
|
data_prevista_desligamento=date.today(),
|
||||||
)
|
)
|
||||||
services.enviar_solicitacao(solicitacao, usuario=self.solicitante)
|
services.enviar_solicitacao(solicitacao, usuario=self.solicitante)
|
||||||
self._add_step("Solicitação criada e enviada, aguardando aprovação do Head.")
|
self._add_step("Solicitação criada e enviada, aguardando aprovação do Head.")
|
||||||
|
|
@ -300,8 +353,10 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
|
||||||
services.criar_solicitacao_desligamento(
|
services.criar_solicitacao_desligamento(
|
||||||
solicitante=self.solicitante,
|
solicitante=self.solicitante,
|
||||||
funcionario=self.funcionario,
|
funcionario=self.funcionario,
|
||||||
|
tipo_desligamento=TipoDesligamento.OUTROS,
|
||||||
|
aviso_previo=TipoAvisoPrevio.TRABALHADO,
|
||||||
motivo="Teste de falha atômica",
|
motivo="Teste de falha atômica",
|
||||||
data_prevista_desligamento="DATA_INVALIDA", # Isso causará um erro
|
data_prevista_desligamento="DATA_INVALIDA", # Isso causará um erro
|
||||||
)
|
)
|
||||||
self._add_step("Execução do service com dados inválidos levantou uma exceção.")
|
self._add_step("Execução do service com dados inválidos levantou uma exceção.")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,14 @@ urlpatterns = [
|
||||||
name="registrar_parecer",
|
name="registrar_parecer",
|
||||||
),
|
),
|
||||||
# =========================
|
# =========================
|
||||||
|
# COMPROVANTE PDF
|
||||||
|
# =========================
|
||||||
|
path(
|
||||||
|
"solicitacao/<uuid:solicitacao_id>/comprovante.pdf",
|
||||||
|
views.solicitacao_comprovante_pdf,
|
||||||
|
name="solicitacao_comprovante_pdf",
|
||||||
|
),
|
||||||
|
# =========================
|
||||||
# Autenticação
|
# Autenticação
|
||||||
# =========================
|
# =========================
|
||||||
path("login/", views.login_view, name="login"),
|
path("login/", views.login_view, name="login"),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
#SGMP_PROD/solicitacoes/views.py
|
#SGMP_PROD/solicitacoes/views.py
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from django.contrib.auth import login, logout, get_user_model
|
from django.contrib.auth import login, logout, get_user_model
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
|
from django.template.loader import render_to_string
|
||||||
from .models import (
|
from .models import (
|
||||||
HeadGestor,
|
HeadGestor,
|
||||||
PessoaRM,
|
PessoaRM,
|
||||||
|
|
@ -20,11 +24,98 @@ from .models import (
|
||||||
UsuarioPerfilExtra,
|
UsuarioPerfilExtra,
|
||||||
)
|
)
|
||||||
from . import services
|
from . import services
|
||||||
|
from .acesso import requer_acesso_gerenciar_permissoes
|
||||||
from .decorators import pode_criar_solicitacao, requer_perfil
|
from .decorators import pode_criar_solicitacao, requer_perfil
|
||||||
from solicitacoes.intf_winthor import autenticar_usuario, buscar_colaborador_oracle
|
from solicitacoes.intf_winthor import autenticar_usuario, buscar_colaborador_oracle
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario):
|
||||||
|
"""Fila do papel OR solicitações em que o usuário é o solicitante (distinct)."""
|
||||||
|
qs_minhas = Solicitacao.objects.filter(solicitante=usuario)
|
||||||
|
return (qs_fila | qs_minhas).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def _queryset_dashboard_solicitacoes(usuario: UsuarioSistema):
|
||||||
|
"""
|
||||||
|
Queryset visível no dashboard para o usuário (antes de order_by/paginação).
|
||||||
|
|
||||||
|
Usa :meth:`UsuarioSistema.tem_perfil` (perfil principal + ``UsuarioPerfilExtra``),
|
||||||
|
não apenas ``usuario.perfil``, para alinhar com perfis secundários.
|
||||||
|
|
||||||
|
**Precedência** (primeiro ramo que se aplica; papéis operacionais antes de GESTOR):
|
||||||
|
1. ADMIN — todas as solicitações
|
||||||
|
2. GG ou CONTROLADORIA — fila ENVIADA (com excludes de parecer já dado) ∪ minhas
|
||||||
|
3. HEAD — fila AGUARDANDO_HEAD (gestores vinculados) ∪ minhas
|
||||||
|
4. DIRETORIA — fila AGUARDANDO_DIRETORIA ∪ minhas
|
||||||
|
5. GESTOR — apenas ``solicitante=usuario``
|
||||||
|
6. Demais — vazio
|
||||||
|
|
||||||
|
Total e pendentes no dashboard devem usar ``.count()`` sobre este mesmo queryset
|
||||||
|
(pendentes = excluir FINALIZADA e REPROVADA).
|
||||||
|
"""
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN):
|
||||||
|
return Solicitacao.objects.all()
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.GG) or usuario.tem_perfil(
|
||||||
|
UsuarioSistema.Perfil.CONTROLADORIA
|
||||||
|
):
|
||||||
|
qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.ENVIADA)
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.GG) and not usuario.tem_perfil(
|
||||||
|
UsuarioSistema.Perfil.CONTROLADORIA
|
||||||
|
):
|
||||||
|
qs_fila = qs_fila.exclude(
|
||||||
|
pareceres__etapa=EtapaAprovacao.GG, pareceres__usuario=usuario
|
||||||
|
)
|
||||||
|
elif usuario.tem_perfil(UsuarioSistema.Perfil.CONTROLADORIA) and not usuario.tem_perfil(
|
||||||
|
UsuarioSistema.Perfil.GG
|
||||||
|
):
|
||||||
|
qs_fila = qs_fila.exclude(
|
||||||
|
pareceres__etapa=EtapaAprovacao.CONTROLADORIA,
|
||||||
|
pareceres__usuario=usuario,
|
||||||
|
)
|
||||||
|
return _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario)
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.HEAD):
|
||||||
|
matriculas = matriculas_gestores_do_head(usuario)
|
||||||
|
qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.AGUARDANDO_HEAD)
|
||||||
|
if matriculas:
|
||||||
|
qs_fila = qs_fila.filter(solicitante__matricula__in=matriculas)
|
||||||
|
return _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario)
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.DIRETORIA):
|
||||||
|
qs_fila = Solicitacao.objects.filter(status=StatusSolicitacao.AGUARDANDO_DIRETORIA)
|
||||||
|
return _qs_dashboard_unir_fila_com_minhas(qs_fila, usuario)
|
||||||
|
if usuario.tem_perfil(UsuarioSistema.Perfil.GESTOR):
|
||||||
|
return Solicitacao.objects.filter(solicitante=usuario)
|
||||||
|
return Solicitacao.objects.none()
|
||||||
|
|
||||||
|
|
||||||
|
# #region agent log
|
||||||
|
_DEBUG_LOG_PATH = "/home/f3lipe/dev/.cursor/debug-ca90b5.log"
|
||||||
|
|
||||||
|
|
||||||
|
def _debug_log(hypothesis_id: str, location: str, message: str, data: dict) -> None:
|
||||||
|
try:
|
||||||
|
with open(_DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
|
||||||
|
f.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"sessionId": "ca90b5",
|
||||||
|
"hypothesisId": hypothesis_id,
|
||||||
|
"location": location,
|
||||||
|
"message": message,
|
||||||
|
"data": data,
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# #endregion
|
||||||
|
|
||||||
def get_usuario_sistema(request) -> UsuarioSistema:
|
def get_usuario_sistema(request) -> UsuarioSistema:
|
||||||
"""
|
"""
|
||||||
Resolve o usuário autenticado do Django para o UsuarioSistema do SGMP.
|
Resolve o usuário autenticado do Django para o UsuarioSistema do SGMP.
|
||||||
|
|
@ -149,6 +240,7 @@ def criar_admissao_substituicao(request, pessoa_id):
|
||||||
"secoes": secoes,
|
"secoes": secoes,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@pode_criar_solicitacao
|
@pode_criar_solicitacao
|
||||||
def criar_admissao_aumento_quadro(request):
|
def criar_admissao_aumento_quadro(request):
|
||||||
|
|
@ -209,6 +301,7 @@ def criar_admissao_aumento_quadro(request):
|
||||||
"coligadas": coligadas,
|
"coligadas": coligadas,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@pode_criar_solicitacao
|
@pode_criar_solicitacao
|
||||||
def criar_movimentacao(request, pessoa_id):
|
def criar_movimentacao(request, pessoa_id):
|
||||||
|
|
@ -263,6 +356,7 @@ def criar_movimentacao(request, pessoa_id):
|
||||||
"secoes": secoes,
|
"secoes": secoes,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def enviar_solicitacao(request, solicitacao_id):
|
def enviar_solicitacao(request, solicitacao_id):
|
||||||
solicitacao = get_object_or_404(Solicitacao, id=solicitacao_id)
|
solicitacao = get_object_or_404(Solicitacao, id=solicitacao_id)
|
||||||
|
|
@ -276,8 +370,13 @@ def enviar_solicitacao(request, solicitacao_id):
|
||||||
messages.error(request, "Não foi possível enviar a solicitação. Tente novamente ou contate o suporte.")
|
messages.error(request, "Não foi possível enviar a solicitação. Tente novamente ou contate o suporte.")
|
||||||
|
|
||||||
return redirect("solicitacoes:solicitacao_detalhe", solicitacao_id=solicitacao.id)
|
return redirect("solicitacoes:solicitacao_detalhe", solicitacao_id=solicitacao.id)
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@requer_perfil(UsuarioSistema.Perfil.HEAD, UsuarioSistema.Perfil.DIRETORIA)
|
@requer_perfil(
|
||||||
|
UsuarioSistema.Perfil.HEAD,
|
||||||
|
UsuarioSistema.Perfil.DIRETORIA,
|
||||||
|
UsuarioSistema.Perfil.ADMIN,
|
||||||
|
)
|
||||||
def decidir_solicitacao(request, solicitacao_id):
|
def decidir_solicitacao(request, solicitacao_id):
|
||||||
"""
|
"""
|
||||||
View para HEAD ou DIRETORIA aprovar/reprovar solicitações.
|
View para HEAD ou DIRETORIA aprovar/reprovar solicitações.
|
||||||
|
|
@ -293,7 +392,10 @@ def decidir_solicitacao(request, solicitacao_id):
|
||||||
try:
|
try:
|
||||||
if (
|
if (
|
||||||
solicitacao.status == StatusSolicitacao.AGUARDANDO_HEAD
|
solicitacao.status == StatusSolicitacao.AGUARDANDO_HEAD
|
||||||
and usuario.tem_perfil(UsuarioSistema.Perfil.HEAD)
|
and (
|
||||||
|
usuario.tem_perfil(UsuarioSistema.Perfil.HEAD)
|
||||||
|
or usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN)
|
||||||
|
)
|
||||||
):
|
):
|
||||||
services.aprovar_reprovar_por_head(
|
services.aprovar_reprovar_por_head(
|
||||||
solicitacao=solicitacao,
|
solicitacao=solicitacao,
|
||||||
|
|
@ -304,7 +406,10 @@ def decidir_solicitacao(request, solicitacao_id):
|
||||||
messages.success(request, "Decisão registrada com sucesso.")
|
messages.success(request, "Decisão registrada com sucesso.")
|
||||||
elif (
|
elif (
|
||||||
solicitacao.status == StatusSolicitacao.AGUARDANDO_DIRETORIA
|
solicitacao.status == StatusSolicitacao.AGUARDANDO_DIRETORIA
|
||||||
and usuario.tem_perfil(UsuarioSistema.Perfil.DIRETORIA)
|
and (
|
||||||
|
usuario.tem_perfil(UsuarioSistema.Perfil.DIRETORIA)
|
||||||
|
or usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN)
|
||||||
|
)
|
||||||
):
|
):
|
||||||
services.aprovar_reprovar_solicitacao(
|
services.aprovar_reprovar_solicitacao(
|
||||||
solicitacao=solicitacao,
|
solicitacao=solicitacao,
|
||||||
|
|
@ -322,7 +427,11 @@ def decidir_solicitacao(request, solicitacao_id):
|
||||||
return redirect("solicitacoes:solicitacao_detalhe", solicitacao_id=solicitacao.id)
|
return redirect("solicitacoes:solicitacao_detalhe", solicitacao_id=solicitacao.id)
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@requer_perfil(UsuarioSistema.Perfil.GG, UsuarioSistema.Perfil.CONTROLADORIA)
|
@requer_perfil(
|
||||||
|
UsuarioSistema.Perfil.GG,
|
||||||
|
UsuarioSistema.Perfil.CONTROLADORIA,
|
||||||
|
UsuarioSistema.Perfil.ADMIN,
|
||||||
|
)
|
||||||
def registrar_parecer_view(request, solicitacao_id):
|
def registrar_parecer_view(request, solicitacao_id):
|
||||||
"""
|
"""
|
||||||
View para GG e CONTROLADORIA registrarem pareceres.
|
View para GG e CONTROLADORIA registrarem pareceres.
|
||||||
|
|
@ -351,6 +460,7 @@ def registrar_parecer_view(request, solicitacao_id):
|
||||||
messages.error(request, "Não foi possível registrar o parecer. Tente novamente ou contate o suporte.")
|
messages.error(request, "Não foi possível registrar o parecer. Tente novamente ou contate o suporte.")
|
||||||
|
|
||||||
return redirect("solicitacoes:solicitacao_detalhe", solicitacao_id=solicitacao.id)
|
return redirect("solicitacoes:solicitacao_detalhe", solicitacao_id=solicitacao.id)
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def solicitacao_detalhe(request, solicitacao_id):
|
def solicitacao_detalhe(request, solicitacao_id):
|
||||||
solicitacao = get_object_or_404(Solicitacao, id=solicitacao_id)
|
solicitacao = get_object_or_404(Solicitacao, id=solicitacao_id)
|
||||||
|
|
@ -410,6 +520,7 @@ def solicitacao_detalhe(request, solicitacao_id):
|
||||||
"solicitacoes/solicitacao_detalhe.html",
|
"solicitacoes/solicitacao_detalhe.html",
|
||||||
{
|
{
|
||||||
"solicitacao": solicitacao,
|
"solicitacao": solicitacao,
|
||||||
|
"status_display_viewer": solicitacao.get_status_display_para_usuario(usuario),
|
||||||
"is_solicitante": is_solicitante,
|
"is_solicitante": is_solicitante,
|
||||||
"pode_aprovar": pode_aprovar,
|
"pode_aprovar": pode_aprovar,
|
||||||
"pode_dar_parecer": pode_dar_parecer,
|
"pode_dar_parecer": pode_dar_parecer,
|
||||||
|
|
@ -421,11 +532,80 @@ def solicitacao_detalhe(request, solicitacao_id):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def solicitacao_comprovante_pdf(request, solicitacao_id):
|
||||||
|
"""
|
||||||
|
Gera um comprovante em PDF (download) a partir do contexto da solicitação.
|
||||||
|
|
||||||
|
Gera PDF com WeasyPrint a partir do HTML renderizado do comprovante.
|
||||||
|
"""
|
||||||
|
from weasyprint import HTML
|
||||||
|
|
||||||
|
solicitacao = get_object_or_404(Solicitacao, id=solicitacao_id)
|
||||||
|
|
||||||
|
pareceres_gg = solicitacao.pareceres.filter(etapa=EtapaAprovacao.GG)
|
||||||
|
pareceres_controladoria = solicitacao.pareceres.filter(etapa=EtapaAprovacao.CONTROLADORIA)
|
||||||
|
|
||||||
|
horas_banco_horas = None
|
||||||
|
if solicitacao.funcionario and solicitacao.funcionario.saldo_banco_horas_minutos is not None:
|
||||||
|
try:
|
||||||
|
horas_banco_horas = float(solicitacao.funcionario.saldo_banco_horas_minutos) / 60.0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
horas_banco_horas = None
|
||||||
|
|
||||||
|
dados_winthor = None
|
||||||
|
dados_winthor_organizados = None
|
||||||
|
if solicitacao.funcionario and solicitacao.funcionario.cpf:
|
||||||
|
try:
|
||||||
|
dados_winthor = buscar_colaborador_oracle(solicitacao.funcionario.cpf)
|
||||||
|
if dados_winthor:
|
||||||
|
dados_winthor_organizados = {
|
||||||
|
"basicos": {
|
||||||
|
"matricula": dados_winthor.get("matricula"),
|
||||||
|
"nome": dados_winthor.get("nome"),
|
||||||
|
"cpf": dados_winthor.get("cpf"),
|
||||||
|
},
|
||||||
|
"admissao": {
|
||||||
|
"admissao": dados_winthor.get("admissao"),
|
||||||
|
"situacao": dados_winthor.get("situacao"),
|
||||||
|
"dt_exclusao": dados_winthor.get("dt_exclusao"),
|
||||||
|
},
|
||||||
|
"endereco": {
|
||||||
|
"endereco": dados_winthor.get("endereco"),
|
||||||
|
"bairro": dados_winthor.get("bairro"),
|
||||||
|
"cidade": dados_winthor.get("cidade"),
|
||||||
|
"estado": dados_winthor.get("estado"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erro ao buscar dados do Winthor no comprovante PDF")
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"solicitacao": solicitacao,
|
||||||
|
"pareceres_gg": pareceres_gg,
|
||||||
|
"pareceres_controladoria": pareceres_controladoria,
|
||||||
|
"horas_banco_horas": horas_banco_horas,
|
||||||
|
"dados_winthor": dados_winthor,
|
||||||
|
"dados_winthor_organizados": dados_winthor_organizados,
|
||||||
|
"gerado_em": timezone.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
html_string = render_to_string("solicitacoes/solicitacao_comprovante_pdf.html", context)
|
||||||
|
|
||||||
|
short_id = str(solicitacao.id)[:8]
|
||||||
|
filename = f"comprovante-{short_id}.pdf"
|
||||||
|
try:
|
||||||
|
pdf_bytes = HTML(string=html_string, base_url=request.build_absolute_uri()).write_pdf()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erro ao gerar PDF do comprovante da solicitação %s", solicitacao.id)
|
||||||
|
return HttpResponse("Erro ao gerar PDF do comprovante.", status=500)
|
||||||
|
|
||||||
|
response = HttpResponse(pdf_bytes, content_type="application/pdf")
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
# auth
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
def login_view(request):
|
def login_view(request):
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
# CORREÇÃO AQUI: adicionado solicitacoes:
|
# CORREÇÃO AQUI: adicionado solicitacoes:
|
||||||
|
|
@ -434,6 +614,21 @@ def login_view(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
login_input = request.POST.get("username", "").strip()
|
login_input = request.POST.get("username", "").strip()
|
||||||
senha = request.POST.get("password", "").strip()
|
senha = request.POST.get("password", "").strip()
|
||||||
|
next_get = request.GET.get("next")
|
||||||
|
next_post = request.POST.get("next")
|
||||||
|
# #region agent log
|
||||||
|
_debug_log(
|
||||||
|
"H2",
|
||||||
|
"views.login_view:POST",
|
||||||
|
"login_post_received",
|
||||||
|
{
|
||||||
|
"has_user": bool(login_input),
|
||||||
|
"has_pass": bool(senha),
|
||||||
|
"next_from_get": next_get,
|
||||||
|
"next_from_post": next_post,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# #endregion
|
||||||
|
|
||||||
if not login_input or not senha:
|
if not login_input or not senha:
|
||||||
messages.error(request, "Informe usuário e senha.")
|
messages.error(request, "Informe usuário e senha.")
|
||||||
|
|
@ -444,6 +639,14 @@ def login_view(request):
|
||||||
dados = autenticar_usuario(login_input, senha)
|
dados = autenticar_usuario(login_input, senha)
|
||||||
|
|
||||||
if not dados:
|
if not dados:
|
||||||
|
# #region agent log
|
||||||
|
_debug_log(
|
||||||
|
"H3",
|
||||||
|
"views.login_view:POST",
|
||||||
|
"winthor_auth_failed",
|
||||||
|
{"matricula_len": len(login_input)},
|
||||||
|
)
|
||||||
|
# #endregion
|
||||||
messages.error(request, "Usuário ou senha inválidos no Winthor.")
|
messages.error(request, "Usuário ou senha inválidos no Winthor.")
|
||||||
return render(request, "auth/login.html")
|
return render(request, "auth/login.html")
|
||||||
|
|
||||||
|
|
@ -480,6 +683,20 @@ def login_view(request):
|
||||||
messages.success(request, f"Bem-vindo, {dados['nome']}!")
|
messages.success(request, f"Bem-vindo, {dados['nome']}!")
|
||||||
# O 'next' pega a url que o usuário tentou acessar antes de logar
|
# O 'next' pega a url que o usuário tentou acessar antes de logar
|
||||||
next_url = request.GET.get("next", "solicitacoes:dashboard")
|
next_url = request.GET.get("next", "solicitacoes:dashboard")
|
||||||
|
# #region agent log
|
||||||
|
_debug_log(
|
||||||
|
"H1",
|
||||||
|
"views.login_view:POST",
|
||||||
|
"before_redirect",
|
||||||
|
{
|
||||||
|
"next_url_resolved": str(next_url)[:500],
|
||||||
|
"next_post_ignored": (request.POST.get("next") or "")[:200],
|
||||||
|
"session_key": getattr(request.session, "session_key", None),
|
||||||
|
"user_auth": request.user.is_authenticated,
|
||||||
|
"user_id": getattr(request.user, "id", None),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# #endregion
|
||||||
|
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
|
|
||||||
|
|
@ -494,48 +711,13 @@ def logout_view(request):
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard_view(request):
|
def dashboard_view(request):
|
||||||
usuario = get_usuario_sistema(request)
|
usuario = get_usuario_sistema(request)
|
||||||
|
qs_base = _queryset_dashboard_solicitacoes(usuario)
|
||||||
# Busca solicitações baseado no perfil
|
solicitacoes = qs_base.order_by("-criado_em").prefetch_related("pareceres")
|
||||||
if usuario.perfil == UsuarioSistema.Perfil.GESTOR:
|
|
||||||
# Gestores veem suas próprias solicitações (todas, incluindo rascunhos)
|
|
||||||
qs_base = Solicitacao.objects.filter(solicitante=usuario)
|
|
||||||
solicitacoes = qs_base.order_by('-criado_em')
|
|
||||||
elif usuario.perfil == UsuarioSistema.Perfil.HEAD:
|
|
||||||
# Head vê solicitações AGUARDANDO_HEAD cujo solicitante está na sua lista de gestores
|
|
||||||
matriculas = matriculas_gestores_do_head(usuario)
|
|
||||||
qs_base = Solicitacao.objects.filter(status=StatusSolicitacao.AGUARDANDO_HEAD)
|
|
||||||
if matriculas:
|
|
||||||
qs_base = qs_base.filter(solicitante__matricula__in=matriculas)
|
|
||||||
solicitacoes = qs_base.order_by('-criado_em')
|
|
||||||
elif usuario.perfil in [UsuarioSistema.Perfil.GG, UsuarioSistema.Perfil.CONTROLADORIA]:
|
|
||||||
# GG e Controladoria veem solicitações ENVIADAS para dar parecer
|
|
||||||
# Verifica se já deram parecer para filtrar
|
|
||||||
qs_base = Solicitacao.objects.filter(status=StatusSolicitacao.ENVIADA)
|
|
||||||
# Filtra para mostrar apenas solicitações onde o usuário ainda não deu parecer
|
|
||||||
etapa_esperada = EtapaAprovacao.GG if usuario.perfil == UsuarioSistema.Perfil.GG else EtapaAprovacao.CONTROLADORIA
|
|
||||||
qs_base = qs_base.exclude(pareceres__etapa=etapa_esperada, pareceres__usuario=usuario)
|
|
||||||
solicitacoes = qs_base.order_by('-enviada_em', '-criado_em')
|
|
||||||
elif usuario.perfil == UsuarioSistema.Perfil.DIRETORIA:
|
|
||||||
# Diretoria vê solicitações AGUARDANDO_DIRETORIA para aprovar/reprovar
|
|
||||||
qs_base = Solicitacao.objects.filter(status=StatusSolicitacao.AGUARDANDO_DIRETORIA)
|
|
||||||
solicitacoes = qs_base.order_by('-enviada_em', '-criado_em')
|
|
||||||
else:
|
|
||||||
qs_base = Solicitacao.objects.none()
|
|
||||||
solicitacoes = Solicitacao.objects.none()
|
|
||||||
|
|
||||||
# Calcula contadores baseado no queryset
|
|
||||||
finalizados = [StatusSolicitacao.FINALIZADA, StatusSolicitacao.REPROVADA]
|
finalizados = [StatusSolicitacao.FINALIZADA, StatusSolicitacao.REPROVADA]
|
||||||
|
|
||||||
total = qs_base.count()
|
total = qs_base.count()
|
||||||
pendentes = qs_base.exclude(status__in=finalizados).count()
|
pendentes = qs_base.exclude(status__in=finalizados).count()
|
||||||
|
|
||||||
# Para gestores, ajusta os contadores considerando todos os status
|
|
||||||
if usuario.perfil == UsuarioSistema.Perfil.GESTOR:
|
|
||||||
# Total: todas as solicitações
|
|
||||||
total = qs_base.count()
|
|
||||||
# Pendentes: rascunho, enviada, aprovadas em etapas intermediárias
|
|
||||||
pendentes = qs_base.exclude(status__in=[StatusSolicitacao.FINALIZADA, StatusSolicitacao.REPROVADA]).count()
|
|
||||||
|
|
||||||
# Paginação
|
# Paginação
|
||||||
paginator = Paginator(solicitacoes, 10)
|
paginator = Paginator(solicitacoes, 10)
|
||||||
page = request.GET.get('page')
|
page = request.GET.get('page')
|
||||||
|
|
@ -547,7 +729,7 @@ def dashboard_view(request):
|
||||||
is_solicitante = solicitacao.solicitante.id == usuario.id
|
is_solicitante = solicitacao.solicitante.id == usuario.id
|
||||||
pode_aprovar = solicitacao.pode_aprovar(usuario)
|
pode_aprovar = solicitacao.pode_aprovar(usuario)
|
||||||
pode_dar_parecer = solicitacao.pode_dar_parecer(usuario)
|
pode_dar_parecer = solicitacao.pode_dar_parecer(usuario)
|
||||||
|
|
||||||
# Busca dados do Winthor se houver funcionário com CPF
|
# Busca dados do Winthor se houver funcionário com CPF
|
||||||
dados_winthor_organizados = None
|
dados_winthor_organizados = None
|
||||||
if solicitacao.funcionario and solicitacao.funcionario.cpf:
|
if solicitacao.funcionario and solicitacao.funcionario.cpf:
|
||||||
|
|
@ -578,6 +760,7 @@ def dashboard_view(request):
|
||||||
|
|
||||||
solicitacoes_com_acao.append({
|
solicitacoes_com_acao.append({
|
||||||
'solicitacao': solicitacao,
|
'solicitacao': solicitacao,
|
||||||
|
'status_display_viewer': solicitacao.get_status_display_para_usuario(usuario),
|
||||||
'pode_aprovar': pode_aprovar,
|
'pode_aprovar': pode_aprovar,
|
||||||
'pode_dar_parecer': pode_dar_parecer,
|
'pode_dar_parecer': pode_dar_parecer,
|
||||||
'is_solicitante': is_solicitante,
|
'is_solicitante': is_solicitante,
|
||||||
|
|
@ -591,18 +774,17 @@ def dashboard_view(request):
|
||||||
"pendentes": pendentes,
|
"pendentes": pendentes,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def todas_solicitacoes_view(request):
|
def todas_solicitacoes_view(request):
|
||||||
"""Listagem de solicitações: Gestor não acessa; Head vê só dos gestores vinculados a ele; GG/Controladoria/Diretoria veem todas."""
|
"""Listagem de solicitações: Gestor não acessa; Head vê só dos gestores vinculados a ele; GG/Controladoria/Diretoria veem todas."""
|
||||||
usuario = get_usuario_sistema(request)
|
usuario = get_usuario_sistema(request)
|
||||||
if usuario.perfil == UsuarioSistema.Perfil.GESTOR:
|
if usuario.tem_perfil(UsuarioSistema.Perfil.GESTOR) and not usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN):
|
||||||
return redirect("solicitacoes:dashboard")
|
return redirect("solicitacoes:dashboard")
|
||||||
|
|
||||||
qs_base = Solicitacao.objects.all().order_by("-criado_em")
|
qs_base = Solicitacao.objects.all().order_by("-criado_em")
|
||||||
|
|
||||||
# Head: apenas solicitações dos gestores que ele aprova (subordinados imediatos)
|
# Head: apenas solicitações dos gestores que ele aprova (subordinados imediatos)
|
||||||
if usuario.perfil == UsuarioSistema.Perfil.HEAD:
|
if usuario.tem_perfil(UsuarioSistema.Perfil.HEAD) and not usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN):
|
||||||
matriculas = matriculas_gestores_do_head(usuario)
|
matriculas = matriculas_gestores_do_head(usuario)
|
||||||
if matriculas:
|
if matriculas:
|
||||||
qs_base = qs_base.filter(solicitante__matricula__in=matriculas)
|
qs_base = qs_base.filter(solicitante__matricula__in=matriculas)
|
||||||
|
|
@ -654,6 +836,7 @@ def todas_solicitacoes_view(request):
|
||||||
pass
|
pass
|
||||||
solicitacoes_com_acao.append({
|
solicitacoes_com_acao.append({
|
||||||
"solicitacao": solicitacao,
|
"solicitacao": solicitacao,
|
||||||
|
"status_display_viewer": solicitacao.get_status_display_para_usuario(usuario),
|
||||||
"pode_aprovar": pode_aprovar,
|
"pode_aprovar": pode_aprovar,
|
||||||
"pode_dar_parecer": pode_dar_parecer,
|
"pode_dar_parecer": pode_dar_parecer,
|
||||||
"is_solicitante": is_solicitante,
|
"is_solicitante": is_solicitante,
|
||||||
|
|
@ -676,10 +859,10 @@ def listar_colaboradores(request):
|
||||||
Lista colaboradores para seleção ao criar solicitação.
|
Lista colaboradores para seleção ao criar solicitação.
|
||||||
|
|
||||||
Aceita parâmetro 'tipo' na URL:
|
Aceita parâmetro 'tipo' na URL:
|
||||||
- 'substituicao': busca apenas pessoas DESLIGADAS diretamente do RM (para admissão por substituição)
|
- 'substituicao': busca colaboradores diretamente no RM (todas as situações; admissão por substituição)
|
||||||
- outros ou ausente: busca pessoas que não estão desligadas do banco local (padrão)
|
- outros ou ausente: busca pessoas que não estão desligadas do banco local (padrão)
|
||||||
"""
|
"""
|
||||||
from .intf_sqlserver import buscar_colaboradores_rm_desligados
|
from .intf_sqlserver import buscar_colaboradores_rm
|
||||||
from .models import PessoaRM
|
from .models import PessoaRM
|
||||||
|
|
||||||
# Verifica se é para admissão por substituição
|
# Verifica se é para admissão por substituição
|
||||||
|
|
@ -688,10 +871,10 @@ def listar_colaboradores(request):
|
||||||
|
|
||||||
busca = request.GET.get('q', '')
|
busca = request.GET.get('q', '')
|
||||||
|
|
||||||
# Para admissão por substituição: busca direto no RM (como no sgmp)
|
# Para admissão por substituição: busca direto no RM (todas as situações)
|
||||||
if apenas_desligados:
|
if apenas_desligados:
|
||||||
# Busca direto no SQL Server para garantir dados atualizados
|
# Busca direto no SQL Server para garantir dados atualizados
|
||||||
resultados_rm = buscar_colaboradores_rm_desligados(nome=busca if busca else None)
|
resultados_rm = buscar_colaboradores_rm(nome=busca if busca else None)
|
||||||
|
|
||||||
# Converte resultados do RM para formato compatível com o template
|
# Converte resultados do RM para formato compatível com o template
|
||||||
colaboradores_list = []
|
colaboradores_list = []
|
||||||
|
|
@ -748,7 +931,7 @@ def listar_colaboradores(request):
|
||||||
"apenas_desligados": apenas_desligados,
|
"apenas_desligados": apenas_desligados,
|
||||||
})
|
})
|
||||||
|
|
||||||
@login_required
|
@requer_acesso_gerenciar_permissoes
|
||||||
def gerenciar_permissoes(request):
|
def gerenciar_permissoes(request):
|
||||||
"""View para gerenciar permissões de usuários. Para perfil HEAD, permite vincular gestores (em relação a quem o Head aprova)."""
|
"""View para gerenciar permissões de usuários. Para perfil HEAD, permite vincular gestores (em relação a quem o Head aprova)."""
|
||||||
usuario_atual = get_usuario_sistema(request)
|
usuario_atual = get_usuario_sistema(request)
|
||||||
|
|
|
||||||
|
|
@ -33,29 +33,54 @@
|
||||||
.sidebar.collapsed .link-text { opacity: 0; width: 0; display: none; overflow: hidden; }
|
.sidebar.collapsed .link-text { opacity: 0; width: 0; display: none; overflow: hidden; }
|
||||||
.sidebar.collapsed .user-info { display: none; }
|
.sidebar.collapsed .user-info { display: none; }
|
||||||
.sidebar.collapsed .user-section { padding: 16px 0; justify-content: center; }
|
.sidebar.collapsed .user-section { padding: 16px 0; justify-content: center; }
|
||||||
.wizard-overlay { display: none; }
|
/* display:none !important evita que utilitários Tailwind (.flex) deixem o overlay no DOM invisível mas clicável */
|
||||||
.wizard-overlay.show { opacity: 1; }
|
.wizard-overlay { display: none !important; }
|
||||||
|
.wizard-overlay.show {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
.wizard-overlay.show .wizard-box { transform: scale(1); }
|
.wizard-overlay.show .wizard-box { transform: scale(1); }
|
||||||
.wizard-step { display: none; }
|
.wizard-step { display: none; }
|
||||||
.wizard-step.active { display: block; }
|
.wizard-step.active { display: block; }
|
||||||
|
/* Mobile: sidebar fora do fluxo flex (evita faixa estreita ~80px no main) + scroll tátil no main */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar { position: absolute; height: 100%; width: 260px; transform: translateX(-100%); }
|
.layout-root { min-width: 0; }
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
height: 100dvh;
|
||||||
|
width: 260px;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
.sidebar.active { transform: translateX(0); }
|
.sidebar.active { transform: translateX(0); }
|
||||||
.sidebar.collapsed { width: 260px; transform: translateX(-100%); }
|
.sidebar.collapsed { width: 260px; transform: translateX(-100%); }
|
||||||
.sidebar-overlay { display: none; }
|
.sidebar-overlay { display: none; }
|
||||||
.sidebar.active + .sidebar-overlay { display: block; }
|
.sidebar.active + .sidebar-overlay { display: block; }
|
||||||
|
.main-content {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
touch-action: pan-y;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% block css %}{% endblock %}
|
{% block css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans bg-slate-100 text-slate-700 m-0 p-0 h-screen overflow-hidden antialiased">
|
<body class="font-sans bg-slate-100 text-slate-700 m-0 p-0 h-screen overflow-hidden antialiased">
|
||||||
|
|
||||||
<div class="flex w-full h-screen">
|
<div class="layout-root flex w-full h-screen min-h-0">
|
||||||
<button type="button" class="mobile-menu-btn block md:hidden fixed top-4 left-4 z-40 bg-white border border-slate-200 rounded-md p-2 shadow md:hidden" onclick="toggleSidebarMobile()">
|
<button type="button" class="mobile-menu-btn block md:hidden fixed top-4 left-4 z-40 bg-white border border-slate-200 rounded-md p-2 shadow md:hidden" onclick="toggleSidebarMobile()">
|
||||||
<span class="text-lg">☰</span>
|
<span class="text-lg">☰</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<nav id="sidebar" class="sidebar flex flex-col w-[260px] flex-shrink-0 bg-slate-900 text-white border-r border-slate-800 relative z-50 transition-[width] duration-300 ease-out">
|
<nav id="sidebar" class="sidebar flex flex-col w-[260px] flex-shrink-0 bg-slate-900 text-white border-r border-slate-800 md:relative z-50 transition-[width] duration-300 ease-out">
|
||||||
<div class="sidebar-header h-16 flex items-center justify-between px-5 border-b border-slate-800">
|
<div class="sidebar-header h-16 flex items-center justify-between px-5 border-b border-slate-800">
|
||||||
<div class="brand text-lg font-bold tracking-tight text-white whitespace-nowrap overflow-hidden">SGMP <span>CORP</span></div>
|
<div class="brand text-lg font-bold tracking-tight text-white whitespace-nowrap overflow-hidden">SGMP <span>CORP</span></div>
|
||||||
<button type="button" class="toggle-btn bg-transparent border-none text-slate-400 text-xl flex items-center justify-center p-1 rounded hover:text-white hover:bg-slate-800 transition-colors" onclick="toggleSidebarDesktop()" title="Recolher Menu">
|
<button type="button" class="toggle-btn bg-transparent border-none text-slate-400 text-xl flex items-center justify-center p-1 rounded hover:text-white hover:bg-slate-800 transition-colors" onclick="toggleSidebarDesktop()" title="Recolher Menu">
|
||||||
|
|
@ -68,13 +93,13 @@
|
||||||
<span class="link-icon text-base min-w-[24px] flex items-center justify-center">📊</span>
|
<span class="link-icon text-base min-w-[24px] flex items-center justify-center">📊</span>
|
||||||
<span class="link-text ml-3 whitespace-nowrap overflow-hidden">Dashboard</span>
|
<span class="link-text ml-3 whitespace-nowrap overflow-hidden">Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
{% if user.is_authenticated and usuario_sistema and usuario_sistema.perfil != 'GESTOR' %}
|
{% if user.is_authenticated and usuario_sistema and usuario_pode_ver_todas_solicitacoes %}
|
||||||
<a href="{% url 'solicitacoes:todas_solicitacoes' %}" class="nav-item flex items-center py-2.5 px-3 rounded-md text-slate-400 text-sm font-medium no-underline border-none bg-transparent w-full cursor-pointer transition-all hover:text-white hover:bg-slate-800" title="Todas as solicitações">
|
<a href="{% url 'solicitacoes:todas_solicitacoes' %}" class="nav-item flex items-center py-2.5 px-3 rounded-md text-slate-400 text-sm font-medium no-underline border-none bg-transparent w-full cursor-pointer transition-all hover:text-white hover:bg-slate-800" title="Todas as solicitações">
|
||||||
<span class="link-icon text-base min-w-[24px] flex items-center justify-center">📑</span>
|
<span class="link-icon text-base min-w-[24px] flex items-center justify-center">📑</span>
|
||||||
<span class="link-text ml-3 whitespace-nowrap overflow-hidden">Todas as solicitações</span>
|
<span class="link-text ml-3 whitespace-nowrap overflow-hidden">Todas as solicitações</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.is_authenticated and usuario_sistema and usuario_sistema.perfil == 'GESTOR' %}
|
{% if user.is_authenticated and usuario_sistema and usuario_pode_criar_solicitacao %}
|
||||||
<button type="button" onclick="openWizard()" class="nav-item flex items-center py-2.5 px-3 rounded-md text-slate-400 text-sm font-medium border-none bg-transparent w-full cursor-pointer transition-all hover:text-white hover:bg-slate-800 text-left" title="Criar Solicitação">
|
<button type="button" onclick="openWizard()" class="nav-item flex items-center py-2.5 px-3 rounded-md text-slate-400 text-sm font-medium border-none bg-transparent w-full cursor-pointer transition-all hover:text-white hover:bg-slate-800 text-left" title="Criar Solicitação">
|
||||||
<span class="link-icon text-base min-w-[24px] flex items-center justify-center">➕</span>
|
<span class="link-icon text-base min-w-[24px] flex items-center justify-center">➕</span>
|
||||||
<span class="link-text ml-3 whitespace-nowrap overflow-hidden">Nova Solicitação</span>
|
<span class="link-text ml-3 whitespace-nowrap overflow-hidden">Nova Solicitação</span>
|
||||||
|
|
@ -100,14 +125,14 @@
|
||||||
|
|
||||||
<div class="sidebar-overlay fixed inset-0 bg-black/50 z-[45] backdrop-blur-sm md:hidden" onclick="toggleSidebarMobile()"></div>
|
<div class="sidebar-overlay fixed inset-0 bg-black/50 z-[45] backdrop-blur-sm md:hidden" onclick="toggleSidebarMobile()"></div>
|
||||||
|
|
||||||
<main class="main-content flex-grow overflow-y-auto h-screen bg-slate-100 relative">
|
<main class="main-content flex-grow overflow-y-auto h-screen min-w-0 bg-slate-100 relative">
|
||||||
<div class="container max-w-[1200px] mx-auto py-8 px-8">
|
<div class="container max-w-[1200px] mx-auto w-full max-md:max-w-none px-4 sm:px-6 lg:px-8 py-6 pt-14 md:pt-8 md:py-8">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="createWizard" class="wizard-overlay fixed inset-0 bg-slate-900/60 z-[1000] flex items-center justify-center backdrop-blur-sm opacity-0 transition-opacity duration-200">
|
<div id="createWizard" class="wizard-overlay fixed inset-0 bg-slate-900/60 z-[1000] backdrop-blur-sm opacity-0 transition-opacity duration-200">
|
||||||
<div class="wizard-box bg-white w-full max-w-2xl rounded-xl shadow-2xl overflow-hidden flex flex-col max-h-[85vh] scale-95 transition-transform duration-200">
|
<div class="wizard-box bg-white w-full max-w-2xl rounded-xl shadow-2xl overflow-hidden flex flex-col max-h-[85vh] scale-95 transition-transform duration-200">
|
||||||
<div class="wizard-header py-5 px-6 border-b border-slate-200 flex justify-between items-center bg-white">
|
<div class="wizard-header py-5 px-6 border-b border-slate-200 flex justify-between items-center bg-white">
|
||||||
<h3 id="wizardTitle" class="m-0 text-lg font-semibold text-slate-800">Iniciar Novo Processo</h3>
|
<h3 id="wizardTitle" class="m-0 text-lg font-semibold text-slate-800">Iniciar Novo Processo</h3>
|
||||||
|
|
@ -115,29 +140,41 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="wizard-body p-6 overflow-y-auto bg-white">
|
<div class="wizard-body p-6 overflow-y-auto bg-white">
|
||||||
<div id="step1" class="wizard-step active">
|
<div id="step1" class="wizard-step active">
|
||||||
<p class="text-slate-600 mb-5">Qual tipo de movimentação você deseja realizar hoje?</p>
|
<p class="text-slate-600 mb-5">Qual tipo de processo você deseja iniciar hoje?</p>
|
||||||
<div class="type-grid grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="type-grid grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div class="type-card bg-white border border-slate-200 p-5 rounded-lg cursor-pointer transition-all text-center flex flex-col items-center gap-3 hover:border-primary hover:bg-blue-50 hover:-translate-y-0.5 hover:shadow" onclick="goToStep2('desligamento', 'Desligamento')">
|
<div class="type-card bg-white border border-slate-200 p-5 rounded-lg cursor-pointer transition-all text-center flex flex-col items-center gap-3 hover:border-primary hover:bg-blue-50 hover:-translate-y-0.5 hover:shadow" onclick="selectCategoria('desligamento')">
|
||||||
<span class="type-icon text-3xl">🚫</span>
|
<span class="type-icon text-3xl">🚫</span>
|
||||||
<span class="type-title font-semibold text-slate-800 text-sm">Desligamento</span>
|
<span class="type-title font-semibold text-slate-800 text-sm">Desligamento</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="type-card bg-white border border-slate-200 p-5 rounded-lg cursor-pointer transition-all text-center flex flex-col items-center gap-3 hover:border-primary hover:bg-blue-50 hover:-translate-y-0.5 hover:shadow" onclick="goToStep2('movimentacao', 'Movimentação Interna')">
|
<div class="type-card bg-white border border-slate-200 p-5 rounded-lg cursor-pointer transition-all text-center flex flex-col items-center gap-3 hover:border-primary hover:bg-blue-50 hover:-translate-y-0.5 hover:shadow" onclick="selectCategoria('movimentacao')">
|
||||||
<span class="type-icon text-3xl">🔄</span>
|
<span class="type-icon text-3xl">🔄</span>
|
||||||
<span class="type-title font-semibold text-slate-800 text-sm">Movimentação</span>
|
<span class="type-title font-semibold text-slate-800 text-sm">Movimentação</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="type-card bg-white border border-slate-200 p-5 rounded-lg cursor-pointer transition-all text-center flex flex-col items-center gap-3 hover:border-primary hover:bg-blue-50 hover:-translate-y-0.5 hover:shadow" onclick="goToStep2('aumento-quadro', 'Aumento de Quadro')">
|
<div class="type-card bg-white border border-slate-200 p-5 rounded-lg cursor-pointer transition-all text-center flex flex-col items-center gap-3 hover:border-primary hover:bg-blue-50 hover:-translate-y-0.5 hover:shadow" onclick="selectCategoria('admissao')">
|
||||||
|
<span class="type-icon text-3xl">📈</span>
|
||||||
|
<span class="type-title font-semibold text-slate-800 text-sm">Admissão</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="step-admissao" class="wizard-step">
|
||||||
|
<div class="back-link text-slate-500 cursor-pointer text-sm mb-5 inline-flex items-center gap-1 font-medium hover:text-primary" onclick="voltarParaCategorias()">
|
||||||
|
<span>←</span> Voltar para categorias
|
||||||
|
</div>
|
||||||
|
<p class="text-slate-600 mb-5">Qual tipo de admissão você deseja iniciar?</p>
|
||||||
|
<div class="type-grid grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div class="type-card bg-white border border-slate-200 p-5 rounded-lg cursor-pointer transition-all text-center flex flex-col items-center gap-3 hover:border-primary hover:bg-blue-50 hover:-translate-y-0.5 hover:shadow" onclick="selectAdmissaoTipo('aumento_quadro')">
|
||||||
<span class="type-icon text-3xl">📈</span>
|
<span class="type-icon text-3xl">📈</span>
|
||||||
<span class="type-title font-semibold text-slate-800 text-sm">Aumento de Quadro</span>
|
<span class="type-title font-semibold text-slate-800 text-sm">Aumento de Quadro</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="type-card bg-white border border-slate-200 p-5 rounded-lg cursor-pointer transition-all text-center flex flex-col items-center gap-3 hover:border-primary hover:bg-blue-50 hover:-translate-y-0.5 hover:shadow" onclick="goToStep2('substituicao', 'Substituição')">
|
<div class="type-card bg-white border border-slate-200 p-5 rounded-lg cursor-pointer transition-all text-center flex flex-col items-center gap-3 hover:border-primary hover:bg-blue-50 hover:-translate-y-0.5 hover:shadow" onclick="selectAdmissaoTipo('substituicao')">
|
||||||
<span class="type-icon text-3xl">👥</span>
|
<span class="type-icon text-3xl">👥</span>
|
||||||
<span class="type-title font-semibold text-slate-800 text-sm">Substituição</span>
|
<span class="type-title font-semibold text-slate-800 text-sm">Substituição</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="step2" class="wizard-step">
|
<div id="step2" class="wizard-step">
|
||||||
<div class="back-link text-slate-500 cursor-pointer text-sm mb-5 inline-flex items-center gap-1 font-medium hover:text-primary" onclick="backToStep1()">
|
<div class="back-link text-slate-500 cursor-pointer text-sm mb-5 inline-flex items-center gap-1 font-medium hover:text-primary" onclick="backFromBusca()">
|
||||||
<span>←</span> Voltar para seleção
|
<span>←</span> Voltar
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-4 text-slate-800">Selecione o colaborador para o processo de <strong id="selectedTypeLabel"></strong>:</p>
|
<p class="mb-4 text-slate-800">Selecione o colaborador para o processo de <strong id="selectedTypeLabel"></strong>:</p>
|
||||||
<div class="search-container flex flex-col gap-4">
|
<div class="search-container flex flex-col gap-4">
|
||||||
|
|
@ -168,35 +205,109 @@
|
||||||
document.getElementById('sidebar').classList.toggle('active');
|
document.getElementById('sidebar').classList.toggle('active');
|
||||||
}
|
}
|
||||||
let currentTypeSlug = '';
|
let currentTypeSlug = '';
|
||||||
|
let currentFlow = ''; // 'desligamento' | 'movimentacao' | 'admissao'
|
||||||
|
let currentAdmissaoTipo = ''; // 'aumento_quadro' | 'substituicao'
|
||||||
|
|
||||||
function openWizard() {
|
function openWizard() {
|
||||||
const overlay = document.getElementById('createWizard');
|
const overlay = document.getElementById('createWizard');
|
||||||
overlay.style.display = 'flex';
|
overlay.style.removeProperty('display');
|
||||||
setTimeout(() => overlay.classList.add('show'), 10);
|
setTimeout(() => overlay.classList.add('show'), 10);
|
||||||
backToStep1();
|
|
||||||
|
// reset estado
|
||||||
|
currentTypeSlug = '';
|
||||||
|
currentFlow = '';
|
||||||
|
currentAdmissaoTipo = '';
|
||||||
|
|
||||||
|
// mostra categorias iniciais
|
||||||
|
document.getElementById('step1').classList.add('active');
|
||||||
|
document.getElementById('step-admissao').classList.remove('active');
|
||||||
|
document.getElementById('step2').classList.remove('active');
|
||||||
}
|
}
|
||||||
function closeWizard() {
|
function closeWizard() {
|
||||||
const overlay = document.getElementById('createWizard');
|
const overlay = document.getElementById('createWizard');
|
||||||
overlay.classList.remove('show');
|
overlay.classList.remove('show');
|
||||||
setTimeout(() => { overlay.style.display = 'none'; }, 200);
|
setTimeout(() => overlay.style.removeProperty('display'), 200);
|
||||||
}
|
}
|
||||||
document.getElementById('createWizard').addEventListener('click', function(e) { if (e.target === this) closeWizard(); });
|
document.getElementById('createWizard').addEventListener('click', function(e) { if (e.target === this) closeWizard(); });
|
||||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeWizard(); });
|
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeWizard(); });
|
||||||
function goToStep2(slug, label) {
|
function selectCategoria(categoria) {
|
||||||
if (slug === 'aumento-quadro') {
|
currentFlow = categoria; // 'desligamento' | 'movimentacao' | 'admissao'
|
||||||
|
currentAdmissaoTipo = '';
|
||||||
|
|
||||||
|
if (categoria === 'admissao') {
|
||||||
|
// Vai para o step de tipos de admissão
|
||||||
|
document.getElementById('step1').classList.remove('active');
|
||||||
|
document.getElementById('step-admissao').classList.add('active');
|
||||||
|
document.getElementById('step2').classList.remove('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desligamento ou Movimentação vão direto para o passo de busca
|
||||||
|
const categoriaToSlug = {
|
||||||
|
'desligamento': 'desligamento',
|
||||||
|
'movimentacao': 'movimentacao',
|
||||||
|
};
|
||||||
|
const slug = categoriaToSlug[categoria];
|
||||||
|
if (slug) {
|
||||||
|
goToBusca(slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAdmissaoTipo(tipo) {
|
||||||
|
// tipo: 'aumento_quadro' | 'substituicao'
|
||||||
|
currentAdmissaoTipo = tipo;
|
||||||
|
|
||||||
|
if (tipo === 'aumento_quadro') {
|
||||||
|
// Admissão por aumento de quadro não depende de colaborador prévio
|
||||||
window.location.href = "{% url 'solicitacoes:criar_admissao_aumento_quadro' %}";
|
window.location.href = "{% url 'solicitacoes:criar_admissao_aumento_quadro' %}";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tipo === 'substituicao') {
|
||||||
|
// Admissão por substituição precisa escolher colaborador (desligado)
|
||||||
|
goToBusca('substituicao');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToBusca(slug) {
|
||||||
currentTypeSlug = slug;
|
currentTypeSlug = slug;
|
||||||
document.getElementById('selectedTypeLabel').textContent = label;
|
|
||||||
|
const labelMap = {
|
||||||
|
'desligamento': 'Desligamento',
|
||||||
|
'movimentacao': 'Movimentação Interna',
|
||||||
|
'substituicao': 'Substituição',
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('selectedTypeLabel').textContent = labelMap[slug] || '';
|
||||||
|
|
||||||
document.getElementById('step1').classList.remove('active');
|
document.getElementById('step1').classList.remove('active');
|
||||||
|
document.getElementById('step-admissao').classList.remove('active');
|
||||||
document.getElementById('step2').classList.add('active');
|
document.getElementById('step2').classList.add('active');
|
||||||
|
|
||||||
document.getElementById('wizardSearchInput').value = '';
|
document.getElementById('wizardSearchInput').value = '';
|
||||||
document.getElementById('searchResults').innerHTML = '';
|
document.getElementById('searchResults').innerHTML = '';
|
||||||
setTimeout(() => document.getElementById('wizardSearchInput').focus(), 100);
|
setTimeout(() => document.getElementById('wizardSearchInput').focus(), 100);
|
||||||
}
|
}
|
||||||
function backToStep1() {
|
|
||||||
|
function voltarParaCategorias() {
|
||||||
|
// voltar do step de admissão para o primeiro step
|
||||||
|
document.getElementById('step-admissao').classList.remove('active');
|
||||||
document.getElementById('step2').classList.remove('active');
|
document.getElementById('step2').classList.remove('active');
|
||||||
document.getElementById('step1').classList.add('active');
|
document.getElementById('step1').classList.add('active');
|
||||||
|
currentFlow = '';
|
||||||
|
currentAdmissaoTipo = '';
|
||||||
|
currentTypeSlug = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function backFromBusca() {
|
||||||
|
// Decide para onde voltar a partir do passo de busca
|
||||||
|
document.getElementById('step2').classList.remove('active');
|
||||||
|
|
||||||
|
if (currentFlow === 'admissao' && currentAdmissaoTipo === 'substituicao') {
|
||||||
|
document.getElementById('step-admissao').classList.add('active');
|
||||||
|
} else {
|
||||||
|
document.getElementById('step1').classList.add('active');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function doSearch() {
|
async function doSearch() {
|
||||||
const term = document.getElementById('wizardSearchInput').value.trim();
|
const term = document.getElementById('wizardSearchInput').value.trim();
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if usuario_sistema.perfil == 'GESTOR' %}
|
{% if usuario_eh_gestor and not usuario_eh_admin %}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-6 flex gap-3 items-start">
|
<div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-6 flex gap-3 items-start">
|
||||||
<span class="text-2xl">💡</span>
|
<span class="text-2xl">💡</span>
|
||||||
|
|
@ -61,11 +61,11 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="metrics-grid grid grid-cols-2 md:grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4 md:gap-6 mb-10">
|
<div class="metrics-grid grid grid-cols-2 md:grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4 md:gap-6 mb-10">
|
||||||
<div class="metric-card bg-white p-6 rounded-xl border border-slate-200 shadow-sm border-l-4 border-l-blue-500 cursor-pointer transition-all hover:-translate-y-1 hover:shadow-md active" onclick="filtrarTabela('todos', this)">
|
<div class="metric-card bg-white p-4 sm:p-6 rounded-xl border border-slate-200 shadow-sm border-l-4 border-l-blue-500 cursor-pointer transition-all hover:-translate-y-1 hover:shadow-md active" onclick="filtrarTabela('todos', this)">
|
||||||
<div class="metric-label text-xs uppercase tracking-wider text-slate-500 font-bold mb-2">Total</div>
|
<div class="metric-label text-xs uppercase tracking-wider text-slate-500 font-bold mb-2">Total</div>
|
||||||
<div class="metric-value text-3xl font-extrabold leading-none text-blue-500">{{ total }}</div>
|
<div class="metric-value text-3xl font-extrabold leading-none text-blue-500">{{ total }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card bg-white p-6 rounded-xl border border-slate-200 shadow-sm border-l-4 border-l-amber-500 cursor-pointer transition-all hover:-translate-y-1 hover:shadow-md" onclick="filtrarTabela('pendente', this)">
|
<div class="metric-card bg-white p-4 sm:p-6 rounded-xl border border-slate-200 shadow-sm border-l-4 border-l-amber-500 cursor-pointer transition-all hover:-translate-y-1 hover:shadow-md" onclick="filtrarTabela('pendente', this)">
|
||||||
<div class="metric-label text-xs uppercase tracking-wider text-slate-500 font-bold mb-2">Pendentes</div>
|
<div class="metric-label text-xs uppercase tracking-wider text-slate-500 font-bold mb-2">Pendentes</div>
|
||||||
<div class="metric-value text-3xl font-extrabold leading-none text-amber-600">{{ pendentes }}</div>
|
<div class="metric-value text-3xl font-extrabold leading-none text-amber-600">{{ pendentes }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -73,50 +73,85 @@
|
||||||
|
|
||||||
<div class="section mb-8">
|
<div class="section mb-8">
|
||||||
<h3 class="text-slate-800 text-xl font-semibold mb-4 flex items-center gap-2">
|
<h3 class="text-slate-800 text-xl font-semibold mb-4 flex items-center gap-2">
|
||||||
{% if usuario_sistema.perfil == 'GESTOR' %}📋 Minhas Solicitações{% else %}⏳ Pendentes de Aprovação{% endif %}
|
{% if usuario_eh_admin %}📑 Visão Geral de Solicitações{% elif usuario_eh_gestor %}📋 Minhas Solicitações{% else %}⏳ Pendentes de Aprovação{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{% if usuario_sistema.perfil != 'GESTOR' %}
|
{% if usuario_eh_admin %}
|
||||||
<div class="bg-blue-50 border border-blue-200 rounded-lg py-3 px-4 mb-5 text-blue-800 text-sm flex items-center gap-2">
|
<div class="bg-blue-50 border border-blue-200 rounded-lg py-3 px-4 mb-5 text-blue-800 text-sm flex items-center gap-2">
|
||||||
ℹ️ Você está vendo solicitações com status <strong>Enviada</strong> aguardando sua análise.
|
ℹ️ Você está com visão administrativa completa e pode atuar conforme o status de cada solicitação.
|
||||||
|
</div>
|
||||||
|
{% elif usuario_eh_gg or usuario_eh_controladoria %}
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg py-3 px-4 mb-5 text-blue-800 text-sm flex items-center gap-2">
|
||||||
|
ℹ️ Sua fila inclui solicitações <strong>Enviadas</strong> nas quais falta parecer de Gente e Gestão ou Controladoria. A coluna <strong>Status</strong> indica qual etapa está pendente.
|
||||||
|
</div>
|
||||||
|
{% elif usuario_eh_head %}
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg py-3 px-4 mb-5 text-blue-800 text-sm flex items-center gap-2">
|
||||||
|
ℹ️ Sua fila inclui solicitações <strong>Aguardando Head</strong> dos gestores vinculados a você.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if solicitacoes %}
|
{% if solicitacoes %}
|
||||||
<div class="table-responsive w-full overflow-x-auto bg-white rounded-xl shadow border border-slate-200">
|
<div id="dashboard-cards-mobile" class="md:hidden space-y-3">
|
||||||
<table id="tabela-solicitacoes" class="w-full border-collapse whitespace-nowrap">
|
{% for item in solicitacoes_com_acao %}
|
||||||
|
{% with solicitacao=item.solicitacao %}
|
||||||
|
<article class="solicitacao-list-item bg-white rounded-xl shadow border border-slate-200 p-4"
|
||||||
|
data-id="{{ solicitacao.id }}"
|
||||||
|
data-status="{% if solicitacao.status == 'FINALIZADA' %}aprovado{% elif solicitacao.status == 'REPROVADA' %}reprovado{% else %}pendente{% endif %}">
|
||||||
|
<div class="flex items-start justify-between gap-3 mb-2 min-w-0">
|
||||||
|
<p class="font-semibold text-slate-900 leading-tight text-sm min-w-0 flex-1">{{ solicitacao.get_tipo_display }}</p>
|
||||||
|
<span class="status-badge inline-flex items-center shrink-0 max-w-[min(100%,14rem)] text-left break-words py-1 px-2.5 rounded-full text-xs font-bold leading-snug status-{{ solicitacao.status }}">{{ item.status_display_viewer }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-700 font-medium break-words">{% if solicitacao.funcionario %}{{ solicitacao.funcionario.nome }}{% else %}<span class="text-slate-400">N/A</span>{% endif %}</p>
|
||||||
|
<p class="text-sm text-slate-500 mt-1">{{ solicitacao.criado_em|date:"d/m/Y H:i" }}</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mt-3">
|
||||||
|
<a href="{% url 'solicitacoes:solicitacao_detalhe' solicitacao.id %}" class="text-primary font-semibold text-sm no-underline hover:underline">Página completa</a>
|
||||||
|
{% if item.pode_dar_parecer %}
|
||||||
|
<a href="{% url 'solicitacoes:solicitacao_detalhe' solicitacao.id %}" class="inline-flex items-center gap-1.5 py-2 px-3 rounded-md text-sm font-semibold bg-primary text-white border border-primary-hover hover:bg-primary-hover no-underline">📝 Parecer</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.pode_aprovar %}
|
||||||
|
<button type="button" class="py-1.5 px-2.5 text-sm bg-emerald-500 text-white border border-emerald-600 rounded hover:bg-emerald-600" onclick="abrirModalAprovacao('{{ solicitacao.id }}', 'APROVADO')" title="Aprovar">✅</button>
|
||||||
|
<button type="button" class="py-1.5 px-2.5 text-sm bg-white text-red-500 border border-red-500 rounded hover:bg-red-50" onclick="abrirModalAprovacao('{{ solicitacao.id }}', 'REPROVADO')" title="Reprovar">❌</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive hidden md:block w-full overflow-x-auto bg-white rounded-xl shadow border border-slate-200">
|
||||||
|
<table id="tabela-solicitacoes" class="w-full border-collapse min-w-[640px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">Tipo</th>
|
<th class="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide whitespace-nowrap">Tipo</th>
|
||||||
<th class="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">Colaborador</th>
|
<th class="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">Colaborador</th>
|
||||||
<th class="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">Status</th>
|
<th class="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">Status</th>
|
||||||
<th class="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">Data</th>
|
<th class="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide whitespace-nowrap">Data</th>
|
||||||
<th class="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">Ações</th>
|
<th class="p-4 text-left border-b border-slate-200 bg-slate-50 font-semibold text-slate-600 text-xs uppercase tracking-wide">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in solicitacoes_com_acao %}
|
{% for item in solicitacoes_com_acao %}
|
||||||
{% with solicitacao=item.solicitacao %}
|
{% with solicitacao=item.solicitacao %}
|
||||||
<tr class="request-row border-b border-slate-200 hover:bg-slate-50 cursor-pointer"
|
<tr class="request-row solicitacao-list-item border-b border-slate-200 hover:bg-slate-50 cursor-pointer"
|
||||||
data-id="{{ solicitacao.id }}"
|
data-id="{{ solicitacao.id }}"
|
||||||
data-status="{% if solicitacao.status == 'FINALIZADA' %}aprovado{% elif solicitacao.status == 'REPROVADA' %}reprovado{% else %}pendente{% endif %}"
|
data-status="{% if solicitacao.status == 'FINALIZADA' %}aprovado{% elif solicitacao.status == 'REPROVADA' %}reprovado{% else %}pendente{% endif %}"
|
||||||
onclick="toggleDetails('{{ solicitacao.id }}', this)">
|
onclick="toggleDetails('{{ solicitacao.id }}', this)">
|
||||||
<td class="p-4"><strong>{{ solicitacao.get_tipo_display }}</strong></td>
|
<td class="p-4 min-w-0 max-w-[12rem]"><strong class="break-words">{{ solicitacao.get_tipo_display }}</strong></td>
|
||||||
<td class="p-4">
|
<td class="p-4 min-w-0 max-w-[14rem] break-words">
|
||||||
{% if solicitacao.funcionario %}{{ solicitacao.funcionario.nome }}{% else %}<span class="text-slate-400">N/A</span>{% endif %}
|
{% if solicitacao.funcionario %}{{ solicitacao.funcionario.nome }}{% else %}<span class="text-slate-400">N/A</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-4">
|
<td class="p-4 min-w-0">
|
||||||
<span class="status-badge inline-flex items-center py-1 px-2.5 rounded-full text-xs font-bold status-{{ solicitacao.status }}">{{ solicitacao.get_status_display }}</span>
|
<span class="status-badge inline-flex items-center py-1 px-2.5 rounded-full text-xs font-bold leading-snug break-words max-w-xs status-{{ solicitacao.status }}">{{ item.status_display_viewer }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-4 text-slate-500">{{ solicitacao.criado_em|date:"d/m/Y H:i" }}</td>
|
<td class="p-4 text-slate-500 whitespace-nowrap">{{ solicitacao.criado_em|date:"d/m/Y H:i" }}</td>
|
||||||
<td class="p-4">
|
<td class="p-4 min-w-[10rem]">
|
||||||
<div class="flex gap-3 items-center">
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
<span class="expand-btn text-blue-600 font-semibold text-sm">Detalhes <span class="chevron-icon inline-block text-xs transition-transform">▼</span></span>
|
<span class="expand-btn text-blue-600 font-semibold text-sm whitespace-nowrap">Detalhes <span class="chevron-icon inline-block text-xs transition-transform">▼</span></span>
|
||||||
{% if item.pode_dar_parecer %}
|
{% if item.pode_dar_parecer %}
|
||||||
<a href="{% url 'solicitacoes:solicitacao_detalhe' solicitacao.id %}" class="btn-action btn-action-primary inline-flex items-center gap-1.5 py-2.5 px-4 rounded-md text-sm font-semibold bg-primary text-white border border-primary-hover hover:bg-primary-hover no-underline" title="Fornecer parecer técnico">📝 Parecer</a>
|
<a href="{% url 'solicitacoes:solicitacao_detalhe' solicitacao.id %}" class="btn-action btn-action-primary inline-flex items-center gap-1.5 py-2.5 px-4 rounded-md text-sm font-semibold bg-primary text-white border border-primary-hover hover:bg-primary-hover no-underline" title="Fornecer parecer técnico" onclick="event.stopPropagation();">📝 Parecer</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.pode_aprovar %}
|
{% if item.pode_aprovar %}
|
||||||
<div class="flex gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<button type="button" class="btn-action btn-action-success py-1 px-2 text-sm bg-emerald-500 text-white border border-emerald-600 rounded hover:bg-emerald-600" onclick="event.stopPropagation(); abrirModalAprovacao('{{ solicitacao.id }}', 'APROVADO')" title="Aprovar">✅</button>
|
<button type="button" class="btn-action btn-action-success py-1 px-2 text-sm bg-emerald-500 text-white border border-emerald-600 rounded hover:bg-emerald-600" onclick="event.stopPropagation(); abrirModalAprovacao('{{ solicitacao.id }}', 'APROVADO')" title="Aprovar">✅</button>
|
||||||
<button type="button" class="btn-action btn-action-danger py-1 px-2 text-sm bg-white text-red-500 border border-red-500 rounded hover:bg-red-50" onclick="event.stopPropagation(); abrirModalAprovacao('{{ solicitacao.id }}', 'REPROVADO')" title="Reprovar">❌</button>
|
<button type="button" class="btn-action btn-action-danger py-1 px-2 text-sm bg-white text-red-500 border border-red-500 rounded hover:bg-red-50" onclick="event.stopPropagation(); abrirModalAprovacao('{{ solicitacao.id }}', 'REPROVADO')" title="Reprovar">❌</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -126,18 +161,21 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="details-{{ solicitacao.id }}" class="details-row bg-slate-50" style="display:none;">
|
<tr id="details-{{ solicitacao.id }}" class="details-row bg-slate-50" style="display:none;">
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
<div class="details-wrapper p-8" id="wrapper-{{ solicitacao.id }}">
|
<div class="details-wrapper p-4 sm:p-6 md:p-8" id="wrapper-{{ solicitacao.id }}">
|
||||||
<div class="tab-nav flex border-b border-slate-200 mb-6 gap-8 overflow-x-auto">
|
<div class="flex flex-col sm:flex-row sm:items-stretch sm:justify-between gap-2 border-b border-slate-200 mb-6">
|
||||||
<button type="button" class="tab-btn active pb-3 border-b-2 border-transparent text-slate-500 text-sm font-medium whitespace-nowrap hover:text-primary border-b-primary text-primary font-semibold" onclick="switchTab(event, 'solicitacao', '{{ solicitacao.id }}')">📄 Solicitação</button>
|
<div class="tab-nav flex flex-1 min-w-0 gap-6 md:gap-8 overflow-x-auto pb-0 -mb-px">
|
||||||
{% if solicitacao.funcionario %}
|
<button type="button" class="tab-btn active pb-3 border-b-2 border-transparent text-slate-500 text-sm font-medium whitespace-nowrap hover:text-primary border-b-primary text-primary font-semibold" onclick="switchTab(event, 'solicitacao', '{{ solicitacao.id }}')">📄 Solicitação</button>
|
||||||
<button type="button" class="tab-btn pb-3 border-b-2 border-transparent text-slate-500 text-sm font-medium whitespace-nowrap hover:text-primary" onclick="switchTab(event, 'rm', '{{ solicitacao.id }}')">🏢 RM (Totvs)</button>
|
{% if solicitacao.funcionario %}
|
||||||
{% endif %}
|
<button type="button" class="tab-btn pb-3 border-b-2 border-transparent text-slate-500 text-sm font-medium whitespace-nowrap hover:text-primary" onclick="switchTab(event, 'rm', '{{ solicitacao.id }}')">🏢 RM (Totvs)</button>
|
||||||
{% for item_acao in solicitacoes_com_acao %}
|
|
||||||
{% if item_acao.solicitacao.id == solicitacao.id and item_acao.dados_winthor_organizados %}
|
|
||||||
<button type="button" class="tab-btn pb-3 border-b-2 border-transparent text-slate-500 text-sm font-medium whitespace-nowrap hover:text-primary" onclick="switchTab(event, 'winthor', '{{ solicitacao.id }}')">💼 Winthor</button>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% for item_acao in solicitacoes_com_acao %}
|
||||||
<button type="button" class="tab-btn pb-3 border-b-2 border-transparent text-slate-500 text-sm font-medium whitespace-nowrap hover:text-primary" onclick="switchTab(event, 'auditoria', '{{ solicitacao.id }}')">📝 Histórico & Auditoria</button>
|
{% if item_acao.solicitacao.id == solicitacao.id and item_acao.dados_winthor_organizados %}
|
||||||
|
<button type="button" class="tab-btn pb-3 border-b-2 border-transparent text-slate-500 text-sm font-medium whitespace-nowrap hover:text-primary" onclick="switchTab(event, 'winthor', '{{ solicitacao.id }}')">💼 Winthor</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<button type="button" class="tab-btn pb-3 border-b-2 border-transparent text-slate-500 text-sm font-medium whitespace-nowrap hover:text-primary" onclick="switchTab(event, 'auditoria', '{{ solicitacao.id }}')">📝 Histórico & Auditoria</button>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'solicitacoes:solicitacao_detalhe' solicitacao.id %}" class="tab-open-page shrink-0 self-end sm:self-auto inline-flex items-center gap-1 pb-3 text-sm font-medium text-slate-500 hover:text-primary no-underline border-b-2 border-transparent hover:border-slate-300 transition-colors whitespace-nowrap" onclick="event.stopPropagation();" title="Abrir esta solicitação em página própria">Página completa <span class="text-slate-400" aria-hidden="true">↗</span></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-pane active" data-tab="solicitacao">
|
<div class="tab-pane active" data-tab="solicitacao">
|
||||||
|
|
@ -275,7 +313,7 @@
|
||||||
<div class="details-section mb-6 bg-white p-6 rounded-xl border border-slate-200">
|
<div class="details-section mb-6 bg-white p-6 rounded-xl border border-slate-200">
|
||||||
<h5 class="text-slate-400 text-xs uppercase mt-6 mb-4 font-bold tracking-wider border-b border-slate-100 pb-2 first:mt-0">Status Atual</h5>
|
<h5 class="text-slate-400 text-xs uppercase mt-6 mb-4 font-bold tracking-wider border-b border-slate-100 pb-2 first:mt-0">Status Atual</h5>
|
||||||
<div class="info-grid grid grid-cols-1 md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-6 mb-6">
|
<div class="info-grid grid grid-cols-1 md:grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-6 mb-6">
|
||||||
<div class="info-item flex flex-col"><label class="text-xs text-slate-500 mb-1 font-semibold uppercase">Status</label><span class="status-badge status-{{ solicitacao.status }}">{{ solicitacao.get_status_display }}</span></div>
|
<div class="info-item flex flex-col min-w-0"><label class="text-xs text-slate-500 mb-1 font-semibold uppercase">Status</label><span class="status-badge inline-block max-w-full break-words leading-snug status-{{ solicitacao.status }}">{{ item.status_display_viewer }}</span></div>
|
||||||
<div class="info-item flex flex-col"><label class="text-xs text-slate-500 mb-1 font-semibold uppercase">Última Modificação</label><span>{{ solicitacao.atualizado_em|date:"d/m/Y H:i" }}</span></div>
|
<div class="info-item flex flex-col"><label class="text-xs text-slate-500 mb-1 font-semibold uppercase">Última Modificação</label><span>{{ solicitacao.atualizado_em|date:"d/m/Y H:i" }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<h5 class="text-slate-400 text-xs uppercase mt-6 mb-4 font-bold tracking-wider border-b border-slate-100 pb-2">Histórico de Aprovações</h5>
|
<h5 class="text-slate-400 text-xs uppercase mt-6 mb-4 font-bold tracking-wider border-b border-slate-100 pb-2">Histórico de Aprovações</h5>
|
||||||
|
|
@ -336,7 +374,7 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state text-center py-16 px-5 text-slate-500 bg-white rounded-xl border-2 border-dashed border-slate-300">
|
<div class="empty-state text-center py-16 px-5 text-slate-500 bg-white rounded-xl border-2 border-dashed border-slate-300">
|
||||||
<p class="m-0 text-lg">🎉 Nenhuma solicitação encontrada.</p>
|
<p class="m-0 text-lg">🎉 Nenhuma solicitação encontrada.</p>
|
||||||
{% if usuario_sistema.perfil == 'GESTOR' %}<p class="text-sm mt-2">Use o botão "Nova Solicitação" para começar.</p>{% endif %}
|
{% if usuario_eh_gestor or usuario_eh_admin %}<p class="text-sm mt-2">Use o botão "Nova Solicitação" para começar.</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -348,21 +386,34 @@
|
||||||
function filtrarTabela(status, cardElement) {
|
function filtrarTabela(status, cardElement) {
|
||||||
document.querySelectorAll('.metric-card').forEach(c => c.classList.remove('active'));
|
document.querySelectorAll('.metric-card').forEach(c => c.classList.remove('active'));
|
||||||
if (cardElement) cardElement.classList.add('active');
|
if (cardElement) cardElement.classList.add('active');
|
||||||
const rows = document.querySelectorAll('.request-row');
|
function applyItem(el, isTableRow) {
|
||||||
let visibleCount = 0;
|
const categoria = el.getAttribute('data-status');
|
||||||
rows.forEach(row => {
|
const show = (status === 'todos') || (status === 'pendente' && categoria === 'pendente');
|
||||||
const categoria = row.getAttribute('data-status');
|
if (isTableRow) {
|
||||||
let show = (status === 'todos') || (status === 'pendente' && categoria === 'pendente');
|
if (show) { el.style.display = ''; } else {
|
||||||
if (show) { row.style.display = ''; visibleCount++; } else {
|
el.style.display = 'none';
|
||||||
row.style.display = 'none';
|
const solicitacaoId = el.getAttribute('data-id');
|
||||||
const solicitacaoId = row.getAttribute('data-id');
|
const detailsRow = document.getElementById('details-' + solicitacaoId);
|
||||||
const detailsRow = document.getElementById('details-' + solicitacaoId);
|
if (detailsRow && detailsRow.style.display !== 'none') { detailsRow.style.display = 'none'; el.classList.remove('expanded'); }
|
||||||
if (detailsRow && detailsRow.style.display !== 'none') { detailsRow.style.display = 'none'; row.classList.remove('expanded'); }
|
}
|
||||||
|
} else {
|
||||||
|
el.style.display = show ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
return show;
|
||||||
|
}
|
||||||
|
let visibleCount = 0;
|
||||||
|
document.querySelectorAll('#tabela-solicitacoes .request-row.solicitacao-list-item').forEach(row => {
|
||||||
|
if (applyItem(row, true)) visibleCount++;
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('#dashboard-cards-mobile .solicitacao-list-item').forEach(card => { applyItem(card, false); });
|
||||||
const noResults = document.getElementById('no-results');
|
const noResults = document.getElementById('no-results');
|
||||||
const tableContainer = document.querySelector('.table-responsive');
|
const tableContainer = document.querySelector('.table-responsive');
|
||||||
if (noResults && tableContainer) { noResults.style.display = visibleCount === 0 ? 'block' : 'none'; tableContainer.style.display = visibleCount === 0 ? 'none' : 'block'; }
|
const cardsMobile = document.getElementById('dashboard-cards-mobile');
|
||||||
|
if (noResults && tableContainer) {
|
||||||
|
noResults.style.display = visibleCount === 0 ? 'block' : 'none';
|
||||||
|
tableContainer.style.display = visibleCount === 0 ? 'none' : '';
|
||||||
|
}
|
||||||
|
if (cardsMobile) { cardsMobile.style.display = visibleCount === 0 ? 'none' : ''; }
|
||||||
}
|
}
|
||||||
function toggleDetails(solicitacaoId, rowElement) {
|
function toggleDetails(solicitacaoId, rowElement) {
|
||||||
const detailsRow = document.getElementById('details-' + solicitacaoId);
|
const detailsRow = document.getElementById('details-' + solicitacaoId);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
<style>
|
<style>
|
||||||
|
.perfil-ADMIN { background: #f3e8ff; color: #6b21a8; }
|
||||||
.perfil-GESTOR { background: #dbeafe; color: #1e40af; }
|
.perfil-GESTOR { background: #dbeafe; color: #1e40af; }
|
||||||
.perfil-HEAD { background: #e0e7ff; color: #3730a3; }
|
.perfil-HEAD { background: #e0e7ff; color: #3730a3; }
|
||||||
.perfil-GG { background: #d1fae5; color: #065f46; }
|
.perfil-GG { background: #d1fae5; color: #065f46; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,364 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-br">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Comprovante - Solicitação {{ solicitacao.id }}</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 1cm;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 18px 0 8px 0;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.kv {
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
td, th {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 6px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.section-note {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.box {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.parecer {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed #e5e7eb;
|
||||||
|
}
|
||||||
|
.page-break {
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
.nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: inherit;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Comprovante da Solicitação</h1>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<div class="kv"><span class="label">ID:</span> {{ solicitacao.id }}</div>
|
||||||
|
<div class="kv"><span class="label">Tipo de Processo:</span> {{ solicitacao.get_tipo_display }}</div>
|
||||||
|
<div class="kv"><span class="label">Solicitante:</span> {{ solicitacao.solicitante.nome }} ({{ solicitacao.solicitante.matricula }})</div>
|
||||||
|
<div class="kv"><span class="label">Status:</span> {{ solicitacao.get_status_display }}</div>
|
||||||
|
<div class="kv"><span class="label">Enviada em:</span> {{ solicitacao.enviada_em|date:"d/m/Y H:i"|default:"-" }}</div>
|
||||||
|
<div class="kv"><span class="label">Finalizada em:</span> {{ solicitacao.finalizada_em|date:"d/m/Y H:i"|default:"-" }}</div>
|
||||||
|
<div class="kv muted"><span class="label">Gerado em:</span> {{ gerado_em|date:"d/m/Y H:i" }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if solicitacao.funcionario %}
|
||||||
|
<h2>Dados do Colaborador (Snapshot RM)</h2>
|
||||||
|
<div class="section-note">Os dados abaixo representam um snapshot no momento da criação.</div>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 25%;">Matrícula</th>
|
||||||
|
<td>{{ solicitacao.funcionario.matricula }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nome</th>
|
||||||
|
<td>{{ solicitacao.funcionario.nome }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>CPF</th>
|
||||||
|
<td>{{ solicitacao.funcionario.cpf|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Data de Admissão</th>
|
||||||
|
<td>{{ solicitacao.funcionario.data_admissao|date:"d/m/Y"|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Cargo/Função</th>
|
||||||
|
<td>{{ solicitacao.funcionario.cargo|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Cód. Função</th>
|
||||||
|
<td>{{ solicitacao.funcionario.cod_funcao|default:"N/A" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Setor/Seção</th>
|
||||||
|
<td>{{ solicitacao.funcionario.setor|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Centro de Custo</th>
|
||||||
|
<td>{{ solicitacao.funcionario.centro_custo|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if solicitacao.funcionario.salario %}
|
||||||
|
<tr>
|
||||||
|
<th>Salário Atual</th>
|
||||||
|
<td>R$ {{ solicitacao.funcionario.salario|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if horas_banco_horas is not none %}
|
||||||
|
<tr>
|
||||||
|
<th>Banco de Horas (horas)</th>
|
||||||
|
<td>{{ horas_banco_horas|floatformat:2 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if dados_winthor_organizados %}
|
||||||
|
<h2>Dados do Colaborador (Winthor)</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 25%;">Matrícula</th>
|
||||||
|
<td>{{ dados_winthor_organizados.basicos.matricula|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nome</th>
|
||||||
|
<td>{{ dados_winthor_organizados.basicos.nome|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>CPF</th>
|
||||||
|
<td>{{ dados_winthor_organizados.basicos.cpf|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if dados_winthor_organizados.admissao %}
|
||||||
|
<tr>
|
||||||
|
<th>Data de Admissão</th>
|
||||||
|
<td>
|
||||||
|
{% if dados_winthor_organizados.admissao.admissao %}
|
||||||
|
{% if dados_winthor_organizados.admissao.admissao|date:"d/m/Y" %}
|
||||||
|
{{ dados_winthor_organizados.admissao.admissao|date:"d/m/Y" }}
|
||||||
|
{% else %}
|
||||||
|
{{ dados_winthor_organizados.admissao.admissao }}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Situação</th>
|
||||||
|
<td>{{ dados_winthor_organizados.admissao.situacao|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Detalhes do Processo</h2>
|
||||||
|
{% if solicitacao.tipo == 'DESLIGAMENTO' and solicitacao.desligamento %}
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 30%;">Tipo de Desligamento</th>
|
||||||
|
<td>{{ solicitacao.desligamento.get_tipo_desligamento_display|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Aviso Prévio</th>
|
||||||
|
<td>{{ solicitacao.desligamento.get_aviso_previo_display|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Data Prevista de Saída</th>
|
||||||
|
<td>{{ solicitacao.desligamento.data_prevista_desligamento|date:"d/m/Y"|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Detalhamento / Justificativa</th>
|
||||||
|
<td><pre>{{ solicitacao.desligamento.motivo|default:"-" }}</pre></td>
|
||||||
|
</tr>
|
||||||
|
{% if solicitacao.desligamento.arquivo_pedido %}
|
||||||
|
<tr>
|
||||||
|
<th>Carta de Pedido</th>
|
||||||
|
<td>{{ solicitacao.desligamento.arquivo_pedido.name }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if solicitacao.desligamento.observacoes %}
|
||||||
|
<tr>
|
||||||
|
<th>Observações</th>
|
||||||
|
<td><pre>{{ solicitacao.desligamento.observacoes }}</pre></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
{% elif solicitacao.tipo == 'MOVIMENTACAO' and solicitacao.movimentacao %}
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 30%;">Data Efetivação</th>
|
||||||
|
<td>{{ solicitacao.movimentacao.data_efetivacao|date:"d/m/Y"|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nova Função</th>
|
||||||
|
<td>
|
||||||
|
{% if solicitacao.movimentacao.altera_funcao %}
|
||||||
|
{{ solicitacao.movimentacao.novo_cod_funcao|default:"-" }}
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nova Seção</th>
|
||||||
|
<td>
|
||||||
|
{% if solicitacao.movimentacao.altera_centro_custo %}
|
||||||
|
{{ solicitacao.movimentacao.novo_cod_secao|default:"-" }}
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if solicitacao.movimentacao.novo_salario %}
|
||||||
|
<tr>
|
||||||
|
<th>Novo Salário</th>
|
||||||
|
<td>R$ {{ solicitacao.movimentacao.novo_salario }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<th>Justificativa</th>
|
||||||
|
<td><pre>{{ solicitacao.movimentacao.justificativa|default:"-" }}</pre></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% elif solicitacao.tipo == 'ADM_SUBSTITUICAO' and solicitacao.admissao_substituicao %}
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 30%;">Data Prevista</th>
|
||||||
|
<td>{{ solicitacao.admissao_substituicao.data_previsao_contratacao|date:"d/m/Y"|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Coligada/Filial Destino</th>
|
||||||
|
<td>{{ solicitacao.admissao_substituicao.cod_coligada_destino }} / {{ solicitacao.admissao_substituicao.cod_filial_destino }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Seção Destino</th>
|
||||||
|
<td>{{ solicitacao.admissao_substituicao.cod_secao_destino|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Função Destino</th>
|
||||||
|
<td>{{ solicitacao.admissao_substituicao.cod_funcao_destino|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Justificativa</th>
|
||||||
|
<td><pre>{{ solicitacao.admissao_substituicao.justificativa|default:"-" }}</pre></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% elif solicitacao.tipo == 'ADM_AUMENTO' and solicitacao.admissao_aumento %}
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 30%;">Data Prevista</th>
|
||||||
|
<td>{{ solicitacao.admissao_aumento.data_previsao_contratacao|date:"d/m/Y"|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Local Destino</th>
|
||||||
|
<td>Col: {{ solicitacao.admissao_aumento.cod_coligada_destino }} | Fil: {{ solicitacao.admissao_aumento.cod_filial_destino }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Seção Destino</th>
|
||||||
|
<td>{{ solicitacao.admissao_aumento.cod_secao_destino|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Função Destino</th>
|
||||||
|
<td>{{ solicitacao.admissao_aumento.cod_funcao_destino|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Justificativa Estratégica</th>
|
||||||
|
<td><pre>{{ solicitacao.admissao_aumento.justificativa_estrategica|default:"-" }}</pre></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="box">Detalhes não disponíveis para este tipo de solicitação.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Pareceres Técnicos</h2>
|
||||||
|
<div class="box">
|
||||||
|
<div class="label">Gente e Gestão</div>
|
||||||
|
{% if pareceres_gg %}
|
||||||
|
{% for parecer in pareceres_gg %}
|
||||||
|
<div class="parecer">
|
||||||
|
<div><strong>{{ parecer.usuario.nome }}</strong> <span class="muted">({{ parecer.criado_em|date:"d/m/Y H:i" }})</span></div>
|
||||||
|
<div style="margin-top:6px;"><pre>{{ parecer.texto|default:"" }}</pre></div>
|
||||||
|
{% if parecer.anexo %}
|
||||||
|
<div class="section-note">Anexo: {{ parecer.anexo.name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="section-note">Aguardando parecer de GG...</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<div class="label">Controladoria</div>
|
||||||
|
{% if pareceres_controladoria %}
|
||||||
|
{% for parecer in pareceres_controladoria %}
|
||||||
|
<div class="parecer">
|
||||||
|
<div><strong>{{ parecer.usuario.nome }}</strong> <span class="muted">({{ parecer.criado_em|date:"d/m/Y H:i" }})</span></div>
|
||||||
|
<div style="margin-top:6px;"><pre>{{ parecer.texto|default:"" }}</pre></div>
|
||||||
|
{% if parecer.anexo %}
|
||||||
|
<div class="section-note">Anexo: {{ parecer.anexo.name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="section-note">Aguardando parecer da Controladoria...</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Histórico de Aprovações</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 20%;">Etapa</th>
|
||||||
|
<th style="width: 20%;">Decisão</th>
|
||||||
|
<th style="width: 30%;">Aprovador</th>
|
||||||
|
<th style="width: 15%;">Data</th>
|
||||||
|
<th>Justificativa</th>
|
||||||
|
</tr>
|
||||||
|
{% for aprovacao in solicitacao.aprovacoes.all %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ aprovacao.get_etapa_display|default:aprovacao.etapa }}</td>
|
||||||
|
<td>
|
||||||
|
{% if aprovacao.decisao == 'APROVADO' %}
|
||||||
|
APROVADO
|
||||||
|
{% else %}
|
||||||
|
REPROVADO
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ aprovacao.usuario.nome }}</td>
|
||||||
|
<td>{{ aprovacao.decidido_em|date:"d/m/Y H:i" }}</td>
|
||||||
|
<td><pre>{{ aprovacao.justificativa|default:"-" }}</pre></td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="muted">Nenhuma decisão registrada ainda.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
|
@ -29,9 +29,12 @@
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-slate-800 tracking-tight m-0">Detalhe da Solicitação #{{ solicitacao.id }}</h1>
|
<h1 class="text-2xl font-bold text-slate-800 tracking-tight m-0">Detalhe da Solicitação #{{ solicitacao.id }}</h1>
|
||||||
<div class="text-slate-500 text-sm mt-1">Criado em {{ solicitacao.criado_em|date:"d/m/Y H:i" }} por <strong>{{ solicitacao.solicitante.nome }}</strong></div>
|
<div class="text-slate-500 text-sm mt-1">Criado em {{ solicitacao.criado_em|date:"d/m/Y H:i" }} por <strong>{{ solicitacao.solicitante.nome }}</strong></div>
|
||||||
<div class="inline-flex items-center py-1.5 px-3.5 rounded-full text-sm font-bold mt-2 status-badge status-{{ solicitacao.status }}">{{ solicitacao.get_status_display }}</div>
|
<div class="inline-flex items-center py-1.5 px-3.5 rounded-full text-sm font-bold mt-2 status-badge status-{{ solicitacao.status }}">{{ status_display_viewer }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="{% url 'solicitacoes:dashboard' %}" class="inline-flex items-center gap-1.5 py-2 px-3 rounded-md font-medium text-slate-600 bg-white border border-slate-200 no-underline hover:bg-slate-50 hover:text-slate-800 hover:border-slate-300 transition-colors"><span>←</span> Voltar ao Dashboard</a>
|
||||||
|
<a href="{% url 'solicitacoes:solicitacao_comprovante_pdf' solicitacao.id %}" class="inline-flex items-center gap-1.5 py-2 px-3 rounded-md font-medium text-white bg-primary border border-primary-hover no-underline hover:bg-primary-hover transition-colors">Download PDF</a>
|
||||||
</div>
|
</div>
|
||||||
<a href="{% url 'solicitacoes:dashboard' %}" class="inline-flex items-center gap-1.5 py-2 px-3 rounded-md font-medium text-slate-600 bg-white border border-slate-200 no-underline hover:bg-slate-50 hover:text-slate-800 hover:border-slate-300 transition-colors"><span>←</span> Voltar ao Dashboard</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
<span class="meta-info block text-xs text-slate-500">{{ solicitacao.solicitante.setor|default:"Sem setor" }}</span>
|
<span class="meta-info block text-xs text-slate-500">{{ solicitacao.solicitante.setor|default:"Sem setor" }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3 md:p-3.5 text-left align-middle">
|
<td class="p-3 md:p-3.5 text-left align-middle">
|
||||||
<span class="status-badge inline-flex items-center gap-1 py-1 px-2.5 rounded-full text-xs font-semibold status-{{ solicitacao.status }}">{{ solicitacao.get_status_display }}</span>
|
<span class="status-badge inline-flex items-center gap-1 py-1 px-2.5 rounded-full text-xs font-semibold status-{{ solicitacao.status }}">{{ item.status_display_viewer }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3 md:p-3.5 text-left align-middle text-xs text-slate-500"><span class="meta-info">{{ solicitacao.criado_em|timesince }} atrás</span></td>
|
<td class="p-3 md:p-3.5 text-left align-middle text-xs text-slate-500"><span class="meta-info">{{ solicitacao.criado_em|timesince }} atrás</span></td>
|
||||||
<td class="p-3 md:p-3.5 text-left align-middle">
|
<td class="p-3 md:p-3.5 text-left align-middle">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue