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:
Felipe Araujo 2026-04-14 22:44:49 -03:00
parent 542b9d03ca
commit 81c3dfab1e
62 changed files with 14145 additions and 332 deletions

View File

@ -11,15 +11,17 @@ Status: RASCUNHO
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
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
Se aprovada → Status: APROVADA_CONTROLADORIA
Status: AGUARDANDO_DIRETORIA
DIRETORIA analisa e APROVA/REPROVA
@ -32,34 +34,57 @@ Se reprovada → Status: REPROVADA
#### 1. Modelos (`models.py`)
- **`Solicitacao`**: Entidade central
- 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 `etapa_atual()`: Retorna a etapa atual baseada no status.
- 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
- Campos: `solicitacao`, `etapa`, `decisao`, `usuario`, `justificativa`, `decidido_em`
- Unique constraint: `(solicitacao, etapa)` - garante uma aprovação por etapa
- **`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
- `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`)
- **`aprovar_reprovar_solicitacao()`**: Função principal que:
1. Valida se a solicitação está em uma etapa válida
2. Valida se o perfil do usuário corresponde à etapa atual
3. Cria registro de `Aprovacao`
4. Atualiza o status da solicitação conforme a decisão
5. Se reprovado, finaliza a solicitação
6. Se aprovado, avança para o próximo status
- **`aprovar_reprovar_por_head()`** decisão da etapa HEAD:
1. Valida se a solicitação está em `AGUARDANDO_HEAD`.
2. Valida se o usuário tem perfil de Head (`tem_perfil('HEAD')`) e está vinculado como head do gestor solicitante.
3. Cria `Aprovacao` na etapa HEAD.
4. Atualiza o status para a próxima etapa (tipicamente `ENVIADA`) ou `REPROVADA`.
- **`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`)
- **`decidir_solicitacao()`**: View que recebe POST com decisão e justificativa
- Decorator: `@requer_perfil(GG, CONTROLADORIA, DIRETORIA)`
- Chama `aprovar_reprovar_solicitacao()`
- Decorator: `@requer_perfil(HEAD, DIRETORIA)`
- 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
- 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
@ -84,6 +109,10 @@ Status: RASCUNHO
GESTOR envia para aprovação
Status: AGUARDANDO_HEAD (quando aplicável; senão vai direto para ENVIADA)
HEAD aprova/reprova (etapa HEAD)
Status: ENVIADA
GG registra PARECER (não aprova/reprova)

View File

@ -12,7 +12,24 @@ RUN apt-get update && \
unzip \
gcc \
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/*
# 2. Oracle Instant Client

58
MIGRACAO.md Normal file
View File

@ -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**

View File

@ -77,56 +77,109 @@ Para criar usuários manualmente ou ajustar dados:
## 🎭 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)
**Permissões:**
**Permissões principais:**
- ✅ Criar solicitações (desligamento, admissão, movimentação)
- ✅ Visualizar suas próprias solicitações
- ✅ Editar solicitações em status "Rascunho"
- ✅ Enviar solicitações para aprovação
- ✅ Gerenciar permissões de todos os usuários
- ❌ Não pode aprovar/reprovar solicitações
- ✅ Visualizar e editar **suas próprias solicitações** em status `RASCUNHO`
- ✅ Enviar suas solicitações para aprovação
- ✅ Acessar a página de permissões `/permissoes/` para alterar perfis
- ❌ Não aprova solicitações por padrão (a menos que também tenha outro perfil, como HEAD ou DIRETORIA)
**Uso:** Gestores de equipe que iniciam processos de RH.
---
### 2. GG (Gente e Gestão)
### 2. HEAD
**Permissões:**
- ✅ Visualizar solicitações com status "ENVIADA"
- ✅ Aprovar/reprovar solicitações na etapa GG
- ✅ Visualizar detalhes completos das solicitações
- ✅ Gerenciar permissões de todos os usuários
- ❌ Não pode criar novas solicitações
**Permissões principais:**
- ✅ Visualizar solicitações com status `AGUARDANDO_HEAD`
- ✅ Aprovar ou reprovar como Head quando:
- A solicitação está em `AGUARDANDO_HEAD`, e
- O solicitante é um Gestor vinculado a esse Head em `HeadGestor` (ou o próprio, se configurado)
- ✅ 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:**
- ✅ Visualizar solicitações aprovadas pela GG (status "APROVADA_GG")
- ✅ Aprovar/reprovar solicitações na etapa Controladoria
**Permissões principais:**
- ✅ Visualizar solicitações com status `ENVIADA`
- ✅ Registrar **parecer técnico** (modelo `Parecer`) na etapa GG
- ✅ Visualizar detalhes completos das solicitações
- ✅ Gerenciar permissões de todos os usuários
- ❌ Não pode criar novas solicitações
- ✅ Acessar a página de permissões `/permissoes/`
- ❌ 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:**
- ✅ Visualizar solicitações aprovadas pela Controladoria (status "APROVADA_CONTROLADORIA")
- ✅ Aprovar/reprovar solicitações na etapa Diretoria (aprovação final)
**Permissões principais:**
- ✅ Visualizar solicitações com status `ENVIADA`
- ✅ Registrar **parecer técnico** na etapa Controladoria
- ✅ Visualizar detalhes completos das solicitações
- ✅ Gerenciar permissões de todos os usuários
- ❌ Não pode criar novas solicitações
- ✅ Acessar a página de permissões `/permissoes/`
- ❌ 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.
@ -146,16 +199,18 @@ A página de permissões permite:
- **Visualizar todos os usuários** do sistema
- **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
- **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
2. Na coluna "Alterar Perfil", selecione o novo perfil no dropdown
3. Clique em **"Atualizar"**
4. Uma mensagem de confirmação será exibida
1. Na tabela de usuários, localize o usuário desejado.
2. Na coluna **Alterar Perfil**, escolha o **perfil principal** no dropdown.
3. Logo abaixo, marque/desmarque as **checkboxes de Perfis adicionais** (HEAD, GG, CONTROLADORIA, DIRETORIA, etc.).
4. Clique em **"Atualizar"**.
5. Uma mensagem de confirmação será exibida.
### Regras Importantes
@ -173,18 +228,25 @@ A página de permissões permite:
O dashboard exibe diferentes informações conforme o perfil:
#### GESTOR
- Vê apenas suas próprias solicitações
- Pode filtrar por status (Total, Pendentes, Aprovadas, Reprovadas)
- Vê solicitações em todos os status (incluindo "Rascunho")
- Vê apenas **suas próprias solicitações**.
- Pode filtrar por status (Total, Pendentes, Aprovadas, Reprovadas).
- Vê solicitações em todos os status (incluindo `RASCUNHO`).
#### GG, CONTROLADORIA, DIRETORIA
- Vê apenas solicitações pendentes na sua etapa
- Não vê solicitações em "Rascunho"
- Não vê solicitações já finalizadas ou reprovadas
#### HEAD
- Vê solicitações em `AGUARDANDO_HEAD` cujo solicitante é um Gestor vinculado a ele em `HeadGestor`.
- Visualiza o contexto completo da solicitação para poder decidir.
#### 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
- **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
### Visualização de Detalhes
@ -194,9 +256,11 @@ O dashboard exibe diferentes informações conforme o perfil:
### Aprovação/Reprovação
- **GESTOR** não pode aprovar/reprovar suas próprias solicitações (após sair de "Rascunho")
- Cada perfil só pode aprovar na sua etapa específica do fluxo
- O solicitante não pode aprovar sua própria solicitação
- **HEAD** pode aprovar/reprovar solicitações em `AGUARDANDO_HEAD` desde que tenha perfil de Head (principal ou extra) e esteja vinculado ao gestor solicitante.
- **DIRETORIA** pode aprovar/reprovar solicitações em `AGUARDANDO_DIRETORIA`.
- **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
1. **RASCUNHO**: Solicitação criada, ainda não enviada
2. **ENVIADA**: Enviada para aprovação (visível para GG)
3. **APROVADA_GG**: Aprovada pela GG (visível para Controladoria)
4. **APROVADA_CONTROLADORIA**: Aprovada pela Controladoria (visível para Diretoria)
5. **FINALIZADA**: Aprovada pela Diretoria (processo concluído)
6. **REPROVADA**: Reprovação em qualquer etapa (processo encerrado)
1. **RASCUNHO**: Solicitação criada, ainda não enviada.
2. **AGUARDANDO_HEAD**: Aguardando decisão do Head (quando aplicável).
3. **ENVIADA**: Enviada para parecer de GG/Controladoria.
4. **APROVADA_GG** / **APROVADA_CONTROLADORIA**: estados intermediários registrados para histórico.
5. **AGUARDANDO_DIRETORIA**: Aguardando decisão final da Diretoria.
6. **FINALIZADA**: Aprovada pela Diretoria (processo concluído).
7. **REPROVADA**: Reprovação em qualquer etapa (processo encerrado).
### Fluxo Completo
@ -220,27 +285,29 @@ Status: RASCUNHO
GESTOR envia para aprovação
Status: AGUARDANDO_HEAD (quando há etapa de Head)
HEAD analisa e aprova/reprova
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 e aprova/reprova
DIRETORIA analisa pareceres e aprova/reprova
Se aprovada → Status: FINALIZADA
```
### Regras de Aprovação
- Cada etapa só pode ser aprovada pelo perfil correspondente
- Uma reprovação em qualquer etapa encerra o processo
- O solicitante não pode aprovar sua própria solicitação
- Solicitações em "RascUNHO" só são visíveis para o criador
- Apenas perfis com permissão de decisão podem aprovar (HEAD e DIRETORIA, conforme etapa/status).
- GG e CONTROLADORIA registram pareceres, não aprovam.
- Uma reprovação em qualquer etapa de decisão encerra o processo.
- Solicitações em `RASCUNHO` só são visíveis para o criador.
---

View File

@ -5,6 +5,7 @@ from django.conf.urls.static import static
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("solicitacoes.api_urls")),
path("", include("solicitacoes.urls")),
]

41
frontend/.gitignore vendored Normal file
View File

@ -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

5
frontend/AGENTS.md Normal file
View File

@ -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 -->

1
frontend/CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

36
frontend/README.md Normal file
View File

@ -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.

View File

@ -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,
});
}

View File

@ -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ão visíveis para
você. Lembre-se de clicar em{" "}
<strong>&quot;Enviar para Aprovação&quot;</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>
);
}

BIN
frontend/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

26
frontend/app/globals.css Normal file
View File

@ -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;
}

33
frontend/app/layout.tsx Normal file
View File

@ -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>
);
}

View File

@ -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>
);
}

37
frontend/app/page.tsx Normal file
View File

@ -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 disponíveis no Next.js.
</p>
</main>
</div>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1,7 @@
export default function SolicitacoesPage() {
return(
<div>
<h1> Solicitações</h1>
</div>
)
}

View File

@ -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>
);
}

View File

@ -0,0 +1,7 @@
export default function UsuariosPage() {
return(
<div>
<h1> usuarios</h1>
</div>
)
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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;
}

7
frontend/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
};
export default nextConfig;

6616
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View File

@ -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

View File

@ -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

1
frontend/public/next.svg Normal file
View File

@ -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

View File

@ -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

View File

@ -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

2502
frontend/resumo.txt Normal file

File diff suppressed because it is too large Load Diff

34
frontend/tsconfig.json Normal file
View File

@ -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"]
}

View File

@ -9,3 +9,4 @@ pymssql==2.2.7
python-dotenv
django-cors-headers
python-dateutil
weasyprint>=60.0

60
solicitacoes/acesso.py Normal file
View File

@ -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

View File

@ -1,12 +1,65 @@
from django import forms
from django.contrib import admin, messages
from django.urls import path
from django.core.exceptions import ValidationError
from django.shortcuts import redirect
from django.urls import path, reverse
from django.utils import timezone
from .models import HeadGestor, PessoaRM, UsuarioSistema
from .intf_sqlserver import listar_para_selecionar_colaborador
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)

17
solicitacoes/api_urls.py Normal file
View File

@ -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),
]

803
solicitacoes/api_views.py Normal file
View File

@ -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)),
})

View File

@ -1,5 +1,6 @@
# /SGMP_PROD/solicitacoes/context_processors.py
from .acesso import usuario_pode_gerenciar_permissoes
from .models import UsuarioSistema
@ -13,8 +14,53 @@ def usuario_sistema(request):
matricula=request.user.username,
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:
return {'usuario_sistema': None}
return {'usuario_sistema': None}
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,
}
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,
}

View File

@ -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

View File

@ -31,6 +31,10 @@ def requer_perfil(*perfis_permitidos):
messages.error(request, "Usuário não encontrado no sistema.")
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,
# considerando perfil principal e perfis extras.
if not any(usuario.tem_perfil(p) for p in perfis_permitidos):
@ -52,7 +56,7 @@ def requer_perfil(*perfis_permitidos):
def pode_criar_solicitacao(view_func):
"""
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)
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.")
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(
request,
"Apenas gestores podem criar solicitações."
"Apenas gestores, diretoria e administradores podem criar solicitações."
)
return redirect("solicitacoes:dashboard")

View File

@ -94,19 +94,17 @@ def listar_para_selecionar_colaborador(apenas_desligados=False):
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:
- CODSITUACAO = 'D'
- DATADEMISSAO IS NOT NULL
Inclui todas as situações (ativos, desligados, etc.), sem filtrar por CODSITUACAO.
Args:
nome (str, optional): Nome para filtrar (busca parcial, case-insensitive)
Returns:
list: Lista de dicionários com dados dos colaboradores desligados
list: Lista de dicionários com dados dos colaboradores
"""
query = """
SELECT
@ -132,9 +130,7 @@ def buscar_colaboradores_rm_desligados(nome: str = None):
JOIN PFUNCAO PU
ON PU.CODCOLIGADA = PF.CODCOLIGADA
AND PU.CODIGO = PF.CODFUNCAO
WHERE
PF.CODSITUACAO = 'D'
AND PF.DATADEMISSAO IS NOT NULL
WHERE 1=1
"""
params = []

View File

@ -5,36 +5,45 @@ import oracledb
# 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)
# 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
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
if _oracle_client_initialized:
return
oracle_lib_dir = os.getenv("ORACLE_LIB_DIR", "/opt/oracle/instantclient_21_13")
if os.getenv("ORACLE_USE_THIN", "").strip().lower() in ("1", "true", "yes", "on"):
_oracle_client_initialized = True
return
# Tenta inicializar com o caminho do Docker primeiro
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)
_DatabaseError = getattr(oracledb, "DatabaseError", Exception)
# Tenta inicializar com o caminho do Docker / env primeiro
try:
oracledb.init_oracle_client(lib_dir=oracle_lib_dir)
_oracle_client_initialized = True
return
except (oracledb.exceptions.DatabaseError, Exception):
except (_DatabaseError, OSError, Exception):
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":
try:
oracledb.init_oracle_client(lib_dir="/usr/lib/oracle/23/client64/lib")
_oracle_client_initialized = True
return
except (oracledb.exceptions.DatabaseError, Exception):
except (_DatabaseError, OSError, Exception):
pass
# Se ambos falharem, marca como inicializado para não tentar novamente
# O erro será lançado quando tentar usar as funções
# Sem thick: segue em modo thin (sem segunda tentativa de init neste processo)
_oracle_client_initialized = True
def get_oracle_connection():

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -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."))

View File

@ -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,
),
),
]

View File

@ -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',
},
),
]

View File

@ -106,6 +106,7 @@ class UsuarioSistema(BaseModel):
ativo = models.BooleanField(default=True)
class Perfil(models.TextChoices):
ADMIN = "ADMIN", _("Admin")
GESTOR = "GESTOR", _("Gestor")
HEAD = "HEAD", _("Head")
GG = "GG", _("Gente e Gestão")
@ -123,10 +124,12 @@ class UsuarioSistema(BaseModel):
Verifica se o usuário possui o perfil informado, considerando
o perfil principal e perfis extras.
"""
if self.perfil == self.Perfil.ADMIN:
return True
if self.perfil == perfil:
return True
# 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):
"""
@ -200,12 +203,53 @@ class HeadGestor(BaseModel):
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:
"""
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.
"""
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 list(
HeadGestor.objects.filter(head=usuario).values_list("gestor__matricula", flat=True)
@ -478,11 +522,17 @@ class Solicitacao(BaseModel):
return False
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 True
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 True
@ -496,23 +546,84 @@ class Solicitacao(BaseModel):
if self.status != StatusSolicitacao.ENVIADA:
return False
# GG e CONTROLADORIA podem dar parecer
if usuario.perfil not in [UsuarioSistema.Perfil.GG, UsuarioSistema.Perfil.CONTROLADORIA]:
if usuario.tem_perfil(UsuarioSistema.Perfil.ADMIN):
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
# Verifica se já deu parecer
etapa_esperada = None
if usuario.perfil == UsuarioSistema.Perfil.GG:
etapa_esperada = EtapaAprovacao.GG
elif usuario.perfil == UsuarioSistema.Perfil.CONTROLADORIA:
etapa_esperada = EtapaAprovacao.CONTROLADORIA
# Pode dar parecer se houver ao menos uma etapa apta sem parecer ainda.
return any(not self.pareceres.filter(etapa=etapa).exists() for etapa in etapas_aptas)
if etapa_esperada:
parecer_existente = self.pareceres.filter(etapa=etapa_esperada).exists()
if parecer_existente:
return False # Já deu parecer
def _rotulo_enviada_observador(self):
"""
Texto para quem acompanha ENVIADA sem atuar como GG/Ctrl (ex.: Admin, Diretoria).
Reflete em qual etapa de parecer o processo está.
"""
tem_gg = self.pareceres.filter(etapa=EtapaAprovacao.GG).exists()
tem_ctrl = self.pareceres.filter(etapa=EtapaAprovacao.CONTROLADORIA).exists()
if not tem_gg:
return _("Aguardando parecer Gente e Gestão")
if not tem_ctrl:
return _("Aguardando parecer Controladoria")
return _("Pareceres concluídos — aguardando decisão da Diretoria")
return True
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):
"""

View File

@ -1,8 +1,9 @@
# /SGMP_PROD/solicitacoes/services.py
import logging
from datetime import date
from typing import Dict, Any, Tuple
from collections import Counter
from datetime import date, datetime
from typing import Any, Dict, Optional, Tuple
from django.db import transaction
from django.utils import timezone
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}.")
return criados, atualizados
def _status_e_envio_inicial(solicitante: UsuarioSistema) -> Tuple[str, Optional[datetime]]:
"""
Diretoria cria 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) ---
@transaction.atomic
def criar_solicitacao_desligamento(
@ -161,12 +173,16 @@ def criar_solicitacao_desligamento(
"\n".join(f"{msg}" for msg in mensagens)
)
solicitacao = Solicitacao.objects.create(
tipo=TipoSolicitacao.DESLIGAMENTO,
solicitante=solicitante,
funcionario=funcionario,
status=StatusSolicitacao.RASCUNHO
)
status_inicial, enviada_em = _status_e_envio_inicial(solicitante)
create_kwargs = {
"tipo": TipoSolicitacao.DESLIGAMENTO,
"solicitante": solicitante,
"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(
solicitacao=solicitacao,
@ -192,13 +208,16 @@ def criar_solicitacao_aumento_quadro(
Este tipo de solicitação não possui um 'funcionario' vinculado
inicialmente, pois representa a criação de uma nova vaga.
"""
solicitacao = Solicitacao.objects.create(
tipo=TipoSolicitacao.ADMISSAO_AUMENTO,
solicitante=solicitante,
# 'funcionario' é None por definição aqui
funcionario=None,
status=StatusSolicitacao.RASCUNHO
)
status_inicial, enviada_em = _status_e_envio_inicial(solicitante)
create_kwargs = {
"tipo": TipoSolicitacao.ADMISSAO_AUMENTO,
"solicitante": solicitante,
"funcionario": None,
"status": status_inicial,
}
if enviada_em is not None:
create_kwargs["enviada_em"] = enviada_em
solicitacao = Solicitacao.objects.create(**create_kwargs)
AdmissaoAumentoQuadro.objects.create(
solicitacao=solicitacao,
@ -238,12 +257,16 @@ def criar_solicitacao_substituicao(
).exists():
raise ValidacaoError("Já existe uma solicitação em andamento para este funcionário.")
solicitacao = Solicitacao.objects.create(
tipo=TipoSolicitacao.ADMISSAO_SUBSTITUICAO,
solicitante=solicitante,
funcionario=funcionario_substituido,
status=StatusSolicitacao.RASCUNHO,
)
status_inicial, enviada_em = _status_e_envio_inicial(solicitante)
create_kwargs = {
"tipo": TipoSolicitacao.ADMISSAO_SUBSTITUICAO,
"solicitante": solicitante,
"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(
solicitacao=solicitacao,
@ -286,12 +309,16 @@ def criar_solicitacao_movimentacao(
).exists():
raise ValidacaoError("Já existe uma solicitação em andamento para este funcionário.")
solicitacao = Solicitacao.objects.create(
tipo=TipoSolicitacao.MOVIMENTACAO,
solicitante=solicitante,
funcionario=funcionario,
status=StatusSolicitacao.RASCUNHO,
)
status_inicial, enviada_em = _status_e_envio_inicial(solicitante)
create_kwargs = {
"tipo": TipoSolicitacao.MOVIMENTACAO,
"solicitante": solicitante,
"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(
solicitacao=solicitacao,
@ -320,6 +347,10 @@ def enviar_solicitacao(solicitacao: Solicitacao, usuario: UsuarioSistema) -> Sol
if solicitacao.solicitante != usuario:
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():
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):
raise PermissaoError("Usuário não pode dar parecer nesta solicitação.")
# Mapeia perfil para etapa
mapa_perfil_etapa = {
UsuarioSistema.Perfil.GG: EtapaAprovacao.GG,
UsuarioSistema.Perfil.CONTROLADORIA: EtapaAprovacao.CONTROLADORIA,
}
etapa = mapa_perfil_etapa.get(usuario.perfil)
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 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:
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
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
- Justificativa é obrigatória apenas para reprovação
"""
if aprovador.perfil != UsuarioSistema.Perfil.DIRETORIA:
raise PermissaoError("Apenas a Diretoria pode aprovar/reprovar solicitações.")
if not (
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:
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 reprovado: status REPROVADA e finalizada_em é preenchido.
"""
if aprovador.perfil != UsuarioSistema.Perfil.HEAD:
raise PermissaoError("Apenas o Head pode aprovar/reprovar solicitações nesta etapa.")
if not (
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:
raise ValidacaoError("A solicitação não está aguardando aprovação do Head.")
@ -492,3 +542,171 @@ def aprovar_reprovar_por_head(
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."
)

View File

@ -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)

View File

@ -2,8 +2,9 @@
import json
from datetime import date, timedelta
from unittest import TestCase as AssertionsMixin
from pathlib import Path
from unittest import TestCase as AssertionsMixin
from unittest.mock import patch
from django.test import TestCase
from django.db import IntegrityError
@ -14,9 +15,12 @@ from ..models import (
Solicitacao,
Desligamento,
Aprovacao,
Parecer,
StatusSolicitacao,
DecisaoAprovacao,
EtapaAprovacao
EtapaAprovacao,
TipoDesligamento,
TipoAvisoPrevio,
)
from .. import services
@ -130,6 +134,8 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
solicitacao = services.criar_solicitacao_desligamento(
solicitante=self.solicitante,
funcionario=self.funcionario,
tipo_desligamento=TipoDesligamento.OUTROS,
aviso_previo=TipoAvisoPrevio.TRABALHADO,
motivo="Teste de criação",
data_prevista_desligamento=date.today(),
)
@ -145,6 +151,28 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
self.assertEqual(solicitacao.desligamento.motivo, "Teste de criaçã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):
"""
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)
solicitacao = services.criar_solicitacao_desligamento(
solicitante=self.solicitante, funcionario=self.funcionario,
motivo="Fluxo completo", data_prevista_desligamento=date.today()
solicitante=self.solicitante,
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)
solicitacao.refresh_from_db()
@ -170,22 +202,23 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
self.assertIsNotNone(solicitacao.enviada_em)
self._add_step("Aprovação pelo Head (status: ENVIADA).")
# Etapa 2: Aprovação GG
services.aprovar_reprovar_solicitacao(
solicitacao, self.aprovador_gg, DecisaoAprovacao.APROVADO, "OK GG"
)
# Etapa 2: Parecer GG (status permanece ENVIADA até ambos os pareceres)
services.registrar_parecer(solicitacao, self.aprovador_gg, "OK GG")
solicitacao.refresh_from_db()
self.assertEqual(solicitacao.status, StatusSolicitacao.APROVADA_GG)
self.assertTrue(Aprovacao.objects.filter(solicitacao=solicitacao, etapa=EtapaAprovacao.GG).exists())
self._add_step("Aprovação por Gente & Gestão (status: APROVADA_GG).")
self.assertEqual(solicitacao.status, StatusSolicitacao.ENVIADA)
self.assertTrue(
Parecer.objects.filter(solicitacao=solicitacao, etapa=EtapaAprovacao.GG).exists()
)
self._add_step("Parecer GG registrado (status: ENVIADA).")
# Etapa 3: Aprovação Controladoria
services.aprovar_reprovar_solicitacao(
solicitacao, self.aprovador_controladoria, DecisaoAprovacao.APROVADO, "OK Controladoria"
)
# Etapa 3: Parecer Controladoria → AGUARDANDO_DIRETORIA
services.registrar_parecer(solicitacao, self.aprovador_controladoria, "OK Controladoria")
solicitacao.refresh_from_db()
self.assertEqual(solicitacao.status, StatusSolicitacao.APROVADA_CONTROLADORIA)
self._add_step("Aprovação pela Controladoria (status: APROVADA_CONTROLADORIA).")
self.assertEqual(solicitacao.status, StatusSolicitacao.AGUARDANDO_DIRETORIA)
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)
services.aprovar_reprovar_solicitacao(
@ -202,8 +235,12 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
para o status 'REPROVADA' e a finaliza.
"""
solicitacao = services.criar_solicitacao_desligamento(
solicitante=self.solicitante, funcionario=self.funcionario,
motivo="Teste de reprovação", data_prevista_desligamento=date.today()
solicitante=self.solicitante,
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)
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)
services.criar_solicitacao_desligamento(
solicitante=self.solicitante, funcionario=self.funcionario,
motivo="Primeira solicitação", data_prevista_desligamento=date.today()
solicitante=self.solicitante,
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.")
# Tenta criar a segunda (inválida)
with self.assertRaises(services.ValidacaoError) as ctx:
services.criar_solicitacao_desligamento(
solicitante=self.solicitante, funcionario=self.funcionario,
motivo="Segunda solicitação (inválida)", data_prevista_desligamento=date.today()
solicitante=self.solicitante,
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))
@ -251,8 +296,12 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
o solicitante original tenta enviar a solicitação.
"""
solicitacao = services.criar_solicitacao_desligamento(
solicitante=self.solicitante, funcionario=self.funcionario,
motivo="Teste de permissão", data_prevista_desligamento=date.today()
solicitante=self.solicitante,
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.")
@ -267,8 +316,12 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
inadequado tenta aprovar a etapa do Head.
"""
solicitacao = services.criar_solicitacao_desligamento(
solicitante=self.solicitante, funcionario=self.funcionario,
motivo="Teste de perfil", data_prevista_desligamento=date.today()
solicitante=self.solicitante,
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)
self._add_step("Solicitação criada e enviada, aguardando aprovação do Head.")
@ -300,6 +353,8 @@ class DesligamentoServiceTests(TestResultLogger, TestCase):
services.criar_solicitacao_desligamento(
solicitante=self.solicitante,
funcionario=self.funcionario,
tipo_desligamento=TipoDesligamento.OUTROS,
aviso_previo=TipoAvisoPrevio.TRABALHADO,
motivo="Teste de falha atômica",
data_prevista_desligamento="DATA_INVALIDA", # Isso causará um erro
)

View File

@ -82,6 +82,14 @@ urlpatterns = [
name="registrar_parecer",
),
# =========================
# COMPROVANTE PDF
# =========================
path(
"solicitacao/<uuid:solicitacao_id>/comprovante.pdf",
views.solicitacao_comprovante_pdf,
name="solicitacao_comprovante_pdf",
),
# =========================
# Autenticação
# =========================
path("login/", views.login_view, name="login"),

View File

@ -1,12 +1,16 @@
#SGMP_PROD/solicitacoes/views.py
import json
import logging
import time
from datetime import date
from django.contrib.auth import login, logout, get_user_model
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from django.http import HttpResponse
from django.utils import timezone
from django.core.paginator import Paginator
from django.template.loader import render_to_string
from .models import (
HeadGestor,
PessoaRM,
@ -20,11 +24,98 @@ from .models import (
UsuarioPerfilExtra,
)
from . import services
from .acesso import requer_acesso_gerenciar_permissoes
from .decorators import pode_criar_solicitacao, requer_perfil
from solicitacoes.intf_winthor import autenticar_usuario, buscar_colaborador_oracle
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 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:
"""
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,
},
)
@login_required
@pode_criar_solicitacao
def criar_admissao_aumento_quadro(request):
@ -209,6 +301,7 @@ def criar_admissao_aumento_quadro(request):
"coligadas": coligadas,
},
)
@login_required
@pode_criar_solicitacao
def criar_movimentacao(request, pessoa_id):
@ -263,6 +356,7 @@ def criar_movimentacao(request, pessoa_id):
"secoes": secoes,
},
)
@login_required
def enviar_solicitacao(request, 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.")
return redirect("solicitacoes:solicitacao_detalhe", solicitacao_id=solicitacao.id)
@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):
"""
View para HEAD ou DIRETORIA aprovar/reprovar solicitações.
@ -293,7 +392,10 @@ def decidir_solicitacao(request, solicitacao_id):
try:
if (
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(
solicitacao=solicitacao,
@ -304,7 +406,10 @@ def decidir_solicitacao(request, solicitacao_id):
messages.success(request, "Decisão registrada com sucesso.")
elif (
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(
solicitacao=solicitacao,
@ -322,7 +427,11 @@ def decidir_solicitacao(request, solicitacao_id):
return redirect("solicitacoes:solicitacao_detalhe", solicitacao_id=solicitacao.id)
@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):
"""
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.")
return redirect("solicitacoes:solicitacao_detalhe", solicitacao_id=solicitacao.id)
@login_required
def solicitacao_detalhe(request, 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",
{
"solicitacao": solicitacao,
"status_display_viewer": solicitacao.get_status_display_para_usuario(usuario),
"is_solicitante": is_solicitante,
"pode_aprovar": pode_aprovar,
"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()
def login_view(request):
if request.user.is_authenticated:
# CORREÇÃO AQUI: adicionado solicitacoes:
@ -434,6 +614,21 @@ def login_view(request):
if request.method == "POST":
login_input = request.POST.get("username", "").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:
messages.error(request, "Informe usuário e senha.")
@ -444,6 +639,14 @@ def login_view(request):
dados = autenticar_usuario(login_input, senha)
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.")
return render(request, "auth/login.html")
@ -480,6 +683,20 @@ def login_view(request):
messages.success(request, f"Bem-vindo, {dados['nome']}!")
# O 'next' pega a url que o usuário tentou acessar antes de logar
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)
@ -494,48 +711,13 @@ def logout_view(request):
@login_required
def dashboard_view(request):
usuario = get_usuario_sistema(request)
qs_base = _queryset_dashboard_solicitacoes(usuario)
solicitacoes = qs_base.order_by("-criado_em").prefetch_related("pareceres")
# Busca solicitações baseado no perfil
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]
total = qs_base.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
paginator = Paginator(solicitacoes, 10)
page = request.GET.get('page')
@ -578,6 +760,7 @@ def dashboard_view(request):
solicitacoes_com_acao.append({
'solicitacao': solicitacao,
'status_display_viewer': solicitacao.get_status_display_para_usuario(usuario),
'pode_aprovar': pode_aprovar,
'pode_dar_parecer': pode_dar_parecer,
'is_solicitante': is_solicitante,
@ -591,18 +774,17 @@ def dashboard_view(request):
"pendentes": pendentes,
})
@login_required
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."""
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")
qs_base = Solicitacao.objects.all().order_by("-criado_em")
# 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)
if matriculas:
qs_base = qs_base.filter(solicitante__matricula__in=matriculas)
@ -654,6 +836,7 @@ def todas_solicitacoes_view(request):
pass
solicitacoes_com_acao.append({
"solicitacao": solicitacao,
"status_display_viewer": solicitacao.get_status_display_para_usuario(usuario),
"pode_aprovar": pode_aprovar,
"pode_dar_parecer": pode_dar_parecer,
"is_solicitante": is_solicitante,
@ -676,10 +859,10 @@ def listar_colaboradores(request):
Lista colaboradores para seleção ao criar solicitação.
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)
"""
from .intf_sqlserver import buscar_colaboradores_rm_desligados
from .intf_sqlserver import buscar_colaboradores_rm
from .models import PessoaRM
# Verifica se é para admissão por substituição
@ -688,10 +871,10 @@ def listar_colaboradores(request):
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:
# 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
colaboradores_list = []
@ -748,7 +931,7 @@ def listar_colaboradores(request):
"apenas_desligados": apenas_desligados,
})
@login_required
@requer_acesso_gerenciar_permissoes
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)."""
usuario_atual = get_usuario_sistema(request)

View File

@ -33,29 +33,54 @@
.sidebar.collapsed .link-text { opacity: 0; width: 0; display: none; overflow: hidden; }
.sidebar.collapsed .user-info { display: none; }
.sidebar.collapsed .user-section { padding: 16px 0; justify-content: center; }
.wizard-overlay { display: none; }
.wizard-overlay.show { opacity: 1; }
/* display:none !important evita que utilitários Tailwind (.flex) deixem o overlay no DOM invisível mas clicável */
.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-step { display: none; }
.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) {
.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.collapsed { width: 260px; transform: translateX(-100%); }
.sidebar-overlay { display: none; }
.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>
{% block css %}{% endblock %}
</head>
<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()">
<span class="text-lg"></span>
</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="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">
@ -68,13 +93,13 @@
<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>
</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">
<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>
</a>
{% 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">
<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>
@ -100,14 +125,14 @@
<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">
<div class="container max-w-[1200px] mx-auto py-8 px-8">
<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 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 %}
</div>
</main>
</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-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>
@ -115,29 +140,41 @@
</div>
<div class="wizard-body p-6 overflow-y-auto bg-white">
<div id="step1" class="wizard-step active">
<p class="text-slate-600 mb-5">Qual tipo de movimentação você deseja realizar hoje?</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="goToStep2('desligamento', 'Desligamento')">
<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-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="selectCategoria('desligamento')">
<span class="type-icon text-3xl">🚫</span>
<span class="type-title font-semibold text-slate-800 text-sm">Desligamento</span>
</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-title font-semibold text-slate-800 text-sm">Movimentação</span>
</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-title font-semibold text-slate-800 text-sm">Aumento de Quadro</span>
</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-title font-semibold text-slate-800 text-sm">Substituição</span>
</div>
</div>
</div>
<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()">
<span></span> Voltar para seleção
<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
</div>
<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">
@ -168,35 +205,109 @@
document.getElementById('sidebar').classList.toggle('active');
}
let currentTypeSlug = '';
let currentFlow = ''; // 'desligamento' | 'movimentacao' | 'admissao'
let currentAdmissaoTipo = ''; // 'aumento_quadro' | 'substituicao'
function openWizard() {
const overlay = document.getElementById('createWizard');
overlay.style.display = 'flex';
overlay.style.removeProperty('display');
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() {
const overlay = document.getElementById('createWizard');
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.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeWizard(); });
function goToStep2(slug, label) {
if (slug === 'aumento-quadro') {
function selectCategoria(categoria) {
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' %}";
return;
}
if (tipo === 'substituicao') {
// Admissão por substituição precisa escolher colaborador (desligado)
goToBusca('substituicao');
}
}
function goToBusca(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('step-admissao').classList.remove('active');
document.getElementById('step2').classList.add('active');
document.getElementById('wizardSearchInput').value = '';
document.getElementById('searchResults').innerHTML = '';
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('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() {
const term = document.getElementById('wizardSearchInput').value.trim();

View File

@ -45,7 +45,7 @@
</ul>
{% endif %}
{% if usuario_sistema.perfil == 'GESTOR' %}
{% if usuario_eh_gestor and not usuario_eh_admin %}
<div class="mb-8">
<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>
@ -61,11 +61,11 @@
{% 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="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-value text-3xl font-extrabold leading-none text-blue-500">{{ total }}</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-value text-3xl font-extrabold leading-none text-amber-600">{{ pendentes }}</div>
</div>
@ -73,50 +73,85 @@
<div class="section mb-8">
<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>
{% 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">
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>
{% endif %}
{% if solicitacoes %}
<div class="table-responsive w-full overflow-x-auto bg-white rounded-xl shadow border border-slate-200">
<table id="tabela-solicitacoes" class="w-full border-collapse whitespace-nowrap">
<div id="dashboard-cards-mobile" class="md:hidden space-y-3">
{% 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>
<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">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>
</tr>
</thead>
<tbody>
{% for item in solicitacoes_com_acao %}
{% 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-status="{% if solicitacao.status == 'FINALIZADA' %}aprovado{% elif solicitacao.status == 'REPROVADA' %}reprovado{% else %}pendente{% endif %}"
onclick="toggleDetails('{{ solicitacao.id }}', this)">
<td class="p-4"><strong>{{ solicitacao.get_tipo_display }}</strong></td>
<td class="p-4">
<td class="p-4 min-w-0 max-w-[12rem]"><strong class="break-words">{{ solicitacao.get_tipo_display }}</strong></td>
<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 %}
</td>
<td class="p-4">
<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>
<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 leading-snug break-words max-w-xs status-{{ solicitacao.status }}">{{ item.status_display_viewer }}</span>
</td>
<td class="p-4 text-slate-500">{{ solicitacao.criado_em|date:"d/m/Y H:i" }}</td>
<td class="p-4">
<div class="flex gap-3 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>
<td class="p-4 text-slate-500 whitespace-nowrap">{{ solicitacao.criado_em|date:"d/m/Y H:i" }}</td>
<td class="p-4 min-w-[10rem]">
<div class="flex flex-wrap gap-2 items-center">
<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 %}
<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 %}
{% 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-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>
@ -126,8 +161,9 @@
</tr>
<tr id="details-{{ solicitacao.id }}" class="details-row bg-slate-50" style="display:none;">
<td colspan="5">
<div class="details-wrapper 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="details-wrapper p-4 sm:p-6 md:p-8" id="wrapper-{{ solicitacao.id }}">
<div class="flex flex-col sm:flex-row sm:items-stretch sm:justify-between gap-2 border-b border-slate-200 mb-6">
<div class="tab-nav flex flex-1 min-w-0 gap-6 md:gap-8 overflow-x-auto pb-0 -mb-px">
<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>
{% if solicitacao.funcionario %}
<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>
@ -139,6 +175,8 @@
{% 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 class="tab-pane active" data-tab="solicitacao">
<div class="details-section mb-6 bg-white p-6 rounded-xl border border-slate-200">
@ -275,7 +313,7 @@
<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>
<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>
<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 %}
<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>
{% 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>
{% endif %}
</div>
@ -348,21 +386,34 @@
function filtrarTabela(status, cardElement) {
document.querySelectorAll('.metric-card').forEach(c => c.classList.remove('active'));
if (cardElement) cardElement.classList.add('active');
const rows = document.querySelectorAll('.request-row');
let visibleCount = 0;
rows.forEach(row => {
const categoria = row.getAttribute('data-status');
let show = (status === 'todos') || (status === 'pendente' && categoria === 'pendente');
if (show) { row.style.display = ''; visibleCount++; } else {
row.style.display = 'none';
const solicitacaoId = row.getAttribute('data-id');
function applyItem(el, isTableRow) {
const categoria = el.getAttribute('data-status');
const show = (status === 'todos') || (status === 'pendente' && categoria === 'pendente');
if (isTableRow) {
if (show) { el.style.display = ''; } else {
el.style.display = 'none';
const solicitacaoId = el.getAttribute('data-id');
const detailsRow = document.getElementById('details-' + solicitacaoId);
if (detailsRow && detailsRow.style.display !== 'none') { detailsRow.style.display = 'none'; row.classList.remove('expanded'); }
if (detailsRow && detailsRow.style.display !== 'none') { detailsRow.style.display = 'none'; el.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 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) {
const detailsRow = document.getElementById('details-' + solicitacaoId);

View File

@ -4,6 +4,7 @@
{% block css %}
<style>
.perfil-ADMIN { background: #f3e8ff; color: #6b21a8; }
.perfil-GESTOR { background: #dbeafe; color: #1e40af; }
.perfil-HEAD { background: #e0e7ff; color: #3730a3; }
.perfil-GG { background: #d1fae5; color: #065f46; }

View File

@ -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>

View File

@ -29,9 +29,12 @@
<div>
<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="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>
{% if messages %}

View File

@ -78,7 +78,7 @@
<span class="meta-info block text-xs text-slate-500">{{ solicitacao.solicitante.setor|default:"Sem setor" }}</span>
</td>
<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 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">