Initial commit

This commit is contained in:
Rafael Dias Martins
2026-08-31 14:10:20 -03:00
commit 8fcb5aac66
454 changed files with 60207 additions and 0 deletions
+271
View File
@@ -0,0 +1,271 @@
# Badge/Label Patterns - Course Platform
## Overview
This document defines the standardized badge/label patterns used throughout the course platform application. All badges should follow these patterns to ensure consistency across the UI.
## Base Badge Style
All badges use a modern UI pattern with:
- **Size**: `px-2.5 py-0.5` (horizontal padding 0.625rem, vertical padding 0.125rem)
- **Shape**: `rounded-full` (fully rounded corners)
- **Typography**: `text-[11px] tracking-wide uppercase` (11px font, wide letter spacing, uppercase text)
- **Layout**: `inline-flex items-center` (flexbox with centered items)
- **Transition**: `transition-colors` (smooth color transitions)
## Color Patterns
### Emerald (Success/Active Status)
**Light Mode:**
- Background: `bg-emerald-50`
- Text: `text-emerald-700`
- Border: `border border-emerald-200/60`
**Dark Mode:**
- Background: `dark:bg-emerald-500/10`
- Text: `dark:text-emerald-400`
- Border: `dark:border-emerald-500/20`
**Usage:** Active status, completed items, success states, positive indicators
---
### Blue (Info/Primary Status)
**Light Mode:**
- Background: `bg-blue-50`
- Text: `text-blue-700`
- Border: `border border-blue-200/60`
**Dark Mode:**
- Background: `dark:bg-blue-500/10`
- Text: `dark:text-blue-400`
- Border: `dark:border-blue-500/20`
**Usage:** Informational status, primary actions, neutral indicators
---
### Red (Error/Danger Status)
**Light Mode:**
- Background: `bg-red-50`
- Text: `text-red-700`
- Border: `border border-red-200/60`
**Dark Mode:**
- Background: `dark:bg-red-500/10`
- Text: `dark:text-red-400`
- Border: `dark:border-red-500/20`
**Usage:** Error states, danger warnings, negative indicators, failed status
---
### Amber (Warning Status)
**Light Mode:**
- Background: `bg-amber-50`
- Text: `text-amber-700`
- Border: `border border-amber-200/60`
**Dark Mode:**
- Background: `dark:bg-amber-500/10`
- Text: `dark:text-amber-400`
- Border: `dark:border-amber-500/20`
**Usage:** Warning states, caution indicators, pending actions
---
### Yellow (Caution Status)
**Light Mode:**
- Background: `bg-yellow-50`
- Text: `text-yellow-700`
- Border: `border border-yellow-200/60`
**Dark Mode:**
- Background: `dark:bg-yellow-500/10`
- Text: `dark:text-yellow-400`
- Border: `dark:border-yellow-500/20`
**Usage:** Caution indicators, attention needed, minor warnings
---
### Purple (Special/Featured Status)
**Light Mode:**
- Background: `bg-purple-50`
- Text: `text-purple-700`
- Border: `border border-purple-200/60`
**Dark Mode:**
- Background: `dark:bg-purple-500/10`
- Text: `dark:text-purple-400`
- Border: `dark:border-purple-500/20`
**Usage:** Featured items, special categories, premium content
---
### Indigo (Secondary Status)
**Light Mode:**
- Background: `bg-indigo-50`
- Text: `text-indigo-700`
- Border: `border border-indigo-200/60`
**Dark Mode:**
- Background: `dark:bg-indigo-500/10`
- Text: `dark:text-indigo-400`
- Border: `dark:border-indigo-500/20`
**Usage:** Secondary status, alternative categories, supplementary info
---
### Gray (Neutral/Default Status)
**Light Mode:**
- Background: `bg-gray-50`
- Text: `text-gray-700`
- Border: `border border-gray-200/60`
**Dark Mode:**
- Background: `dark:bg-gray-500/10`
- Text: `dark:text-gray-400`
- Border: `dark:border-gray-500/20`
**Usage:** Default status, neutral indicators, inactive states
---
## Usage with @apply Classes
To reduce HTML class clutter, use the predefined CSS classes in `globals.css`:
```jsx
// Emerald badge
<span className="badge-emerald">Active</span>
// Blue badge
<span className="badge-blue">Info</span>
// Red badge
<span className="badge-red">Error</span>
// Amber badge
<span className="badge-amber">Warning</span>
// Yellow badge
<span className="badge-yellow">Caution</span>
// Purple badge
<span className="badge-purple">Featured</span>
// Indigo badge
<span className="badge-indigo">Secondary</span>
// Gray badge
<span className="badge-gray">Inactive</span>
```
## Full Tailwind Classes (Reference)
If you need to use the full Tailwind classes directly:
```jsx
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] tracking-wide uppercase bg-emerald-50 text-emerald-700 border border-emerald-200/60 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20 transition-colors">
Status
</span>
```
## Common Use Cases
### Status Badges
| Status | Color | Class |
|--------|-------|-------|
| Active | Emerald | `badge-emerald` |
| Inactive | Gray | `badge-gray` |
| Pending | Amber | `badge-amber` |
| Completed | Emerald | `badge-emerald` |
| Cancelled | Red | `badge-red` |
| Draft | Gray | `badge-gray` |
| Published | Emerald | `badge-emerald` |
### Payment Status
| Status | Color | Class |
|--------|-------|-------|
| Paid | Emerald | `badge-emerald` |
| Unpaid | Red | `badge-red` |
| Partial | Amber | `badge-amber` |
| Overdue | Red | `badge-red` |
### Attendance Status
| Status | Color | Class |
|--------|-------|-------|
| Present | Emerald | `badge-emerald` |
| Absent | Red | `badge-red` |
| Late | Amber | `badge-amber` |
| Excused | Blue | `badge-blue` |
### Exam Status
| Status | Color | Class |
|--------|-------|-------|
| Available | Emerald | `badge-emerald` |
| Completed | Blue | `badge-blue` |
| Graded | Purple | `badge-purple` |
| Pending | Amber | `badge-amber` |
### Content Categories
| Category | Color | Class |
|----------|-------|-------|
| Slides | Blue | `badge-blue` |
| Exercises | Emerald | `badge-emerald` |
| Reading | Purple | `badge-purple` |
| Videos | Red | `badge-red` |
| Audio | Amber | `badge-amber` |
| Assignments | Indigo | `badge-indigo` |
| Other | Gray | `badge-gray` |
## Implementation Notes
1. **Always use the @apply classes** (`badge-emerald`, `badge-blue`, etc.) instead of inline Tailwind classes for consistency
2. **Badge text should be short** (1-2 words) and in uppercase
3. **Use semantic colors** based on the meaning (emerald for success, red for danger, etc.)
4. **Ensure accessibility** by maintaining good contrast ratios in both light and dark modes
5. **Test in both themes** to verify proper appearance
## Migration Guide
When updating existing badges:
1. Replace long Tailwind class strings with the appropriate `badge-*` class
2. Ensure text content is uppercase
3. Verify the color matches the semantic meaning
4. Test in both light and dark themes
### Before:
```jsx
<span className="text-xs px-2 py-1 rounded-full border bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:border-emerald-800">
Active
</span>
```
### After:
```jsx
<span className="badge-emerald">Active</span>
```
## Related Files
- `src/app/globals.css` - Contains all badge CSS classes with @apply
- `STYLE_GUIDE.md` - Overall style guide for the application
+432
View File
@@ -0,0 +1,432 @@
# Deploy em Produção
Guia completo para deploy da aplicação Course Plat em VPS/servidor.
## Índice
1. [Requisitos](#requisitos)
2. [Variáveis de Ambiente](#variáveis-de-ambiente)
3. [Deploy com Docker](#deploy-com-docker)
4. [Scripts de Backup](#scripts-de-backup)
5. [Monitoramento e Manutenção](#monitoramento-e-manutenção)
6. [Troubleshooting](#troubleshooting)
---
## Requisitos
### Servidor
- Ubuntu 22.04+ ou similar
- 2GB RAM (mínimo), 4GB+ recomendado
- 20GB+ de disco
- Docker e Docker Compose instalados
### Instalação do Docker
```bash
# Atualizar sistema
sudo apt update && sudo apt upgrade -y
# Instalar Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# Instalar Docker Compose
sudo apt install docker-compose-plugin -y
# Adicionar usuário ao grupo docker
sudo usermod -aG docker $USER
newgrp docker
# Verificar instalação
docker --version
docker compose version
```
---
## Variáveis de Ambiente
### Criar arquivo `.env.production`
```bash
cd /path/to/course-plat
cp .env.example .env.production
nano .env.production
```
### Variáveis Obrigatórias
```bash
# ===== NEXT.JS =====
NODE_ENV=production
NEXTAUTH_URL=https://seu-dominio.com.br
NEXTAUTH_SECRET=gerar-um-segredo-aleatorio-min-32-caracteres
# ===== MONGODB =====
MONGO_USERNAME=admin
MONGO_PASSWORD=senha-forte-aqui
MONGO_DB=course-plat
# ===== STORAGE (MinIO) =====
STORAGE_TYPE=s3 # ou "local" para armazenamento em disco
S3_ENDPOINT=http://minio:9000 # interno do docker
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin # mesmo que MINIO_ROOT_USER
S3_SECRET_KEY=minioadmin # mesmo que MINIO_ROOT_PASSWORD
S3_BUCKET=course-plat
# ===== MINIO =====
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=senha-forte-aqui
MINIO_BUCKET=course-plat
# ===== APP =====
APP_PORT=3000
```
### Gerar NEXTAUTH_SECRET
```bash
openssl rand -base64 32
```
---
## Deploy com Docker
### 1. Preparar Servidor
```bash
# Clonar repositório
git clone seu-repositorio.git /var/www/course-plat
cd /var/www/course-plat
# Criar arquivo de ambiente
cp .env.example .env.production
nano .env.production # preencher variáveis
```
### 2. Buildar Imagem
```bash
# Build da imagem da aplicação
docker build -f docker/production/Dockerfile -t course-plat:latest .
```
### 3. Iniciar Serviços
```bash
# Iniciar todos os serviços
docker compose -f docker/production/docker-compose.yml up -d
# Ver status
docker compose -f docker/production/docker-compose.yml ps
# Ver logs
docker compose -f docker/production/docker-compose.yml logs -f app
```
### 4. Configurar Firewall
```bash
# UFW - Ubuntu/Debian
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
```
### 5. Configurar Reverse Proxy (Opcional)
Se usar Nginx no host (não container):
```nginx
# /etc/nginx/sites-available/course-plat
server {
listen 80;
server_name seu-dominio.com.br;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
```bash
sudo ln -s /etc/nginx/sites-available/course-plat /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### 6. SSL com Certbot (Let's Encrypt)
```bash
# Instalar Certbot
sudo apt install certbot python3-certbot-nginx -y
# Obter certificado
sudo certbot --nginx -d seu-dominio.com.br
# Renovação automática já é configurada
sudo certbot renew --dry-run
```
---
## Scripts de Backup
### Backup MongoDB
Criar `docker/production/backup-mongodb.sh`:
```bash
#!/bin/bash
# Backup MongoDB em produção
BACKUP_DIR="/var/backups/course-plat/mongodb"
RETENTION_DAYS=7
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
CONTAINER_NAME="course-plat-mongodb"
mkdir -p "$BACKUP_DIR"
# Backup
docker exec "$CONTAINER_NAME" mongodump \
--uri="mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@localhost:27017/${MONGO_DB}?authSource=admin" \
--archive="$BACKUP_DIR/mongodb-$TIMESTAMP.gz" \
--gzip
echo "Backup MongoDB concluído: mongodb-$TIMESTAMP.gz"
# Remover backups antigos
find "$BACKUP_DIR" -name "mongodb-*.gz" -mtime +$RETENTION_DAYS -delete
```
### Backup Completo
Criar `docker/production/backup-all.sh`:
```bash
#!/bin/bash
# Backup completo do sistema
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_BASE="/var/backups/course-plat"
# Backup MongoDB
./docker/production/backup-mongodb.sh
# Backup MinIO
./docker/minio/backup-minio.sh
# Backup volumes Docker
docker run --rm \
-v course-plat_mongodb_data:/data/mongo \
-v course-plat_minio_data:/data/minio \
-v course-plat_app_storage:/data/app \
-v "$BACKUP_BASE":/backup \
alpine tar czf "/backup/volumes-$TIMESTAMP.tar.gz" -C /data .
echo "Backup completo concluído!"
```
### Configurar Cron
```bash
# Tornar scripts executáveis
chmod +x docker/production/backup-mongodb.sh
chmod +x docker/production/backup-all.sh
# Editar crontab
crontab -e
# Adicionar:
# 0 2 * * * cd /var/www/course-plat && ./docker/production/backup-all.sh >> /var/log/course-plat-backup.log 2>&1
```
---
## Monitoramento e Manutenção
### Ver Status dos Serviços
```bash
# Status completo
docker compose -f docker/production/docker-compose.yml ps
# Logs em tempo real
docker compose -f docker/production/docker-compose.yml logs -f
# Logs de serviço específico
docker compose -f docker/production/docker-compose.yml logs -f app
docker compose -f docker/production/docker-compose.yml logs -f mongodb
docker compose -f docker/production/docker-compose.yml logs -f minio
```
### Atualizar Aplicação
```bash
cd /var/www/course-plat
# Pull do código
git pull origin main
# Rebuildar
docker build -f docker/production/Dockerfile -t course-plat:latest .
# Recrear container
docker compose -f docker/production/docker-compose.yml up -d --force-recreate app
```
### Acessar MongoDB
```bash
# Terminal do MongoDB
docker exec -it course-plat-mongodb mongosh -u admin -p
# Comando restore
docker exec -i course-plat-mongodb mongorestore \
--uri="mongodb://admin:password@localhost:27017/course-plat?authSource=admin" \
--gzip --archive < backup.gz
```
### Acessar MinIO
```bash
# Terminal do MinIO
docker exec -it course-plat-minio sh
# Usar mc (MinIO Client)
docker exec -it course-plat-minio mc alias set local http://localhost:9000 minioadmin minioadmin
docker exec -it course-plat-minio mc ls local/course-plat
```
---
## Troubleshooting
### App não inicia
```bash
# Ver logs
docker compose -f docker/production/docker-compose.yml logs app
# Entrar no container
docker exec -it course-plat-app sh
# Ver variáveis de ambiente
docker exec course-plat-app env
```
### MongoDB não conecta
```bash
# Ver se container está rodando
docker ps | grep mongo
# Testar conexão
docker exec course-plat-mongodb mongosh --eval "db.adminCommand('ping')"
# Ver logs
docker logs course-plat-mongodb
```
### MinIO com Access Denied
```bash
# Verificar política do bucket
docker exec course-plat-minio mc anonymous get local/course-plat
# Setar como privado (se necessário)
docker exec course-plat-minio mc anonymous set none local/course-plat
# Verificar credenciais
docker exec course-plat-minio mc admin user list local
```
### Espaço em disco
```bash
# Ver uso de disco
df -h
# Limpar volumes não usados
docker volume prune
# Limpar imagens antigas
docker image prune -a
# Ver tamanho dos volumes
docker system df -v
```
### Performance
```bash
# Métricas do container
docker stats course-plat-app course-plat-mongodb course-plat-minio
# Ver processo Node.js
docker exec course-plat-app ps aux
# Memória do MongoDB
docker exec course-plat-mongodb mongosh --eval "db.serverStatus().mem"
```
---
## Comandos Úteis
```bash
# Reiniciar todos os serviços
docker compose -f docker/production/docker-compose.yml restart
# Parar tudo
docker compose -f docker/production/docker-compose.yml down
# Parar e remover volumes
docker compose -f docker/production/docker-compose.yml down -v
# Ver recursos usados
docker system df
# Limpar build cache
docker builder prune
# Backup rápido do MongoDB
docker exec course-plat-mongodb mongodump --gzip --archive > backup-$(date +%Y%m%d).gz
# Restore do MongoDB
docker exec -i course-plat-mongodb mongorestore --gzip --archive < backup-YYYYMMDD.gz
```
---
## Segurança
1. **Senhas fortes**: Use senhas únicas e fortes para todos os serviços
2. **Firewall**: Mantenha apenas portas necessárias abertas
3. **SSL**: Sempre use HTTPS em produção
4. **Backups**: Teste restores regularmente
5. **Atualizações**: Mantenha Docker e sistema atualizados
6. **Monitoring**: Configure alertas para disco, CPU e memória
---
## URLs de Acesso
| Serviço | URL |
|---------|-----|
| Aplicação | https://seu-dominio.com.br |
| MinIO Console (interna) | http://localhost:9001 |
| Mongo Express (opcional) | http://localhost:8081 |
| API Health Check | https://seu-dominio.com.br/api/health |
+484
View File
@@ -0,0 +1,484 @@
# Fluxo de Provas - Course Platform
Este documento explica como funciona o fluxo completo de provas no sistema, desde a criação até a realização e repetição.
## Visão Geral
O sistema de provas funciona em 4 etapas principais:
1. **Criação de Templates** - Professor/Admin cria modelos de prova reutilizáveis
2. **Atribuição de Prova** - Professor/Admin atribui um template a uma turma
3. **Realização pelo Aluno** - Aluno faz a prova dentro do período designado
4. **Correção e Resultados** - Professor corrige e aluno vê os resultados
---
## 1. Criação de Templates de Prova
**Quem pode criar:** Professor ou Admin
**Local:** `/admin/dashboard/exam-templates` ou `/dashboard/teacher/exam-templates`
**O que é um Template:**
- Um modelo de prova reutilizável com questões
- Pode ser usado em múltiplas turmas
- Contém:
- Título da prova
- Descrição
- Instruções
- Lista de questões (múltipla escolha ou texto)
- Pontuação por questão
- Tempo limite (opcional)
**Campos do Template:**
```javascript
{
title: "Prova de Inglês - Unidade 1",
description: "Avaliação sobre verbos e vocabulário",
instructions: "Leia atentamente cada questão antes de responder",
questions: [
{
questionText: "Qual é o passado de 'go'?",
questionType: "multiple_choice",
options: [
{ optionText: "goed", isCorrect: false },
{ optionText: "went", isCorrect: true },
{ optionText: "gone", isCorrect: false }
],
points: 10,
order: 1
}
],
timeLimit: 30, // em minutos
totalPoints: 100
}
```
---
## 2. Atribuição de Prova
**Quem pode atribuir:** Professor ou Admin
**Local:** `/admin/dashboard/assignments` ou `/dashboard/teacher/assignments`
**O que é uma Atribuição (Assignment):**
- Vincula um template de prova a uma turma específica
- Define quando e como a prova pode ser realizada
- Configura regras de tentativas e repetição
**Campos da Atribuição:**
| Campo | Descrição | Exemplo |
|--------|-------------|----------|
| `classId` | Turma que receberá a prova | ID da turma |
| `examTemplateId` | Template de prova a ser usado | ID do template |
| `title` | Título da prova (pode ser diferente do template) | "Prova 1 - Turma A" |
| `description` | Descrição adicional | "Avaliação mensal" |
| `instructions` | Instruções específicas | "Sem consulta" |
| `startDate` | Data/hora de início da disponibilidade | 2025-02-01T08:00 |
| `endDate` | Data/hora de fim da disponibilidade | 2025-02-01T18:00 |
| `timeLimit` | Tempo limite para fazer a prova (minutos) | 30 |
| `allowRetakes` | Permite refazer a prova | true/false |
| `maxAttempts` | Número máximo de tentativas | 3 |
| `showResultsAfterGrading` | Mostra resultados após correção | true/false |
| `status` | Status da prova | "active", "draft", "archived" |
| `useCustomQuestions` | Usa questões customizadas (não do template) | true/false |
| `customQuestions` | Lista de questões customizadas | [...] |
### Configurações de Tentativas e Repetição
**allowRetakes (Permitir Refazer):**
- `true`: Aluno pode fazer a prova mais de uma vez
- `false`: Aluno só pode fazer uma vez
**maxAttempts (Máximo de Tentativas):**
- Define quantas vezes o aluno pode tentar
- Exemplo: `3` = aluno pode tentar até 3 vezes
- Se `allowRetakes` for `false`, deve ser `1`
**Exemplo de Configuração:**
```javascript
// Prova com 3 tentativas permitidas
{
allowRetakes: true,
maxAttempts: 3,
timeLimit: 30,
startDate: "2025-02-01T08:00:00",
endDate: "2025-02-01T18:00:00"
}
```
**Fluxo com Repetição:**
1. Aluno faz a 1ª tentativa → Envia → Resultado: 60/100
2. Aluno clica em "Fazer Prova" novamente
3. Sistema verifica: `tentativas < maxAttempts`? → Sim
4. Sistema cria 2ª tentativa → Aluno faz → Envia → Resultado: 80/100
5. Aluno clica em "Fazer Prova" novamente
6. Sistema verifica: `tentativas < maxAttempts`? → Sim
7. Sistema cria 3ª tentativa → Aluno faz → Envia → Resultado: 90/100
8. Aluno clica em "Fazer Prova" novamente
9. Sistema verifica: `tentativas < maxAttempts`? → Não (3 = 3)
10. Sistema bloqueia: "Maximum attempts (3) reached"
---
## 3. Visualização pelo Aluno
**Local:** `/dashboard/student/class/[classId]`
**Status da Prova:**
| Status | Descrição | Cor |
|--------|-------------|------|
| `upcoming` | Prova ainda não começou | Azul |
| `available` | Prova está disponível para fazer | Verde |
| `expired` | Prova já encerrou | Vermelho |
**Status da Tentativa:**
| Status | Descrição |
|--------|-------------|
| `not_started` | Aluno ainda não iniciou nenhuma tentativa |
| `in_progress` | Aluno está fazendo a prova agora |
| `completed` | Aluno completou a prova |
**O que o aluno vê:**
```
┌─────────────────────────────────────────┐
│ Prova de Inglês - Unidade 1 │
│ [Disponível] │
│ │
│ Início: 01/02/2025 08:00 │
│ Fim: 01/02/2025 18:00 │
│ Tempo: 30 min │
│ Questões: 10 │
│ Pontos: 100 │
│ │
│ Melhor nota: 90/100 (90%) │
│ │
│ [Fazer Prova] [Ver Resultados] │
└─────────────────────────────────────────┘
```
---
## 4. Início da Prova
**Ação:** Aluno clica em "Fazer Prova"
**Verificações do Sistema:**
1. **Verifica Período de Disponibilidade:**
```javascript
const now = new Date();
if (now < assignment.startDate) {
return "Prova ainda não começou";
}
if (now > assignment.endDate) {
return "Prova já encerrou";
}
```
2. **Verifica Tentativas em Andamento:**
```javascript
const inProgressAttempt = attempts.find(a => a.status === "in_progress");
if (inProgressAttempt) {
return "Continuar prova em andamento";
}
```
3. **Verifica Máximo de Tentativas:**
```javascript
const existingAttempts = await ExamAttempt.countDocuments({
assignmentId: assignmentId,
studentId: studentId,
});
if (existingAttempts >= assignment.maxAttempts) {
return `Maximum attempts (${assignment.maxAttempts}) reached`;
}
```
**Se todas as verificações passarem:**
- Sistema cria uma nova `ExamAttempt`
- Status: `"in_progress"`
- `attemptNumber`: `existingAttempts + 1`
- `startedAt`: Data/hora atual
- Redireciona para a página de realização da prova
---
## 5. Realização da Prova
**Local:** `/dashboard/student/assignments/[assignmentId]/take`
**O que acontece:**
1. **Carregamento das Questões:**
- Se `useCustomQuestions` for `true`: usa `assignment.customQuestions`
- Se `useCustomQuestions` for `false`: usa `template.questions`
2. **Contador de Tempo:**
- Se `timeLimit` estiver definido, inicia contador
- Atualiza a cada segundo
- Se tempo acabar, envia automaticamente
3. **Respostas do Aluno:**
- Sistema salva as respostas temporariamente
- Aluno pode mudar as respostas antes de enviar
- Não há salvamento automático (deve ser implementado)
**Estrutura das Respostas:**
```javascript
{
questionId1: { type: "mc", value: "optionId1" },
questionId2: { type: "text", value: "Resposta do aluno" },
questionId3: { type: "mc", value: null } // não respondida
}
```
---
## 6. Envio da Prova
**Ação:** Aluno clica em "Enviar" ou tempo acaba
**Validações:**
1. Confirmação do aluno (se não for auto-submit)
2. Validação das respostas
**Processo de Envio:**
```javascript
// 1. Usa as mesmas questões que foram exibidas
const examQuestions = assignment.useCustomQuestions
? assignment.customQuestions
: template.questions;
// 2. Mapeia as respostas do aluno
const answers = examQuestions.map((q) => {
const answer = currentAnswers[q._id] || { type: "mc", value: null };
return {
questionId: q._id,
selectedOptionId: answer.type === "mc" ? answer.value : null,
textAnswer: answer.type === "text" ? answer.value : null,
};
});
// 3. Envia para o servidor
const result = await submitExamAttempt(attempt._id, answers);
```
**Após Envio:**
- Status da tentativa: `"submitted"`
- Redireciona para `/dashboard/student/assignments/[assignmentId]/results`
---
## 7. Visualização de Resultados
**Local:** `/dashboard/student/assignments/[assignmentId]/results`
**O que o aluno vê:**
1. **Se `showResultsAfterGrading` for `true`:**
- Mostra todas as tentativas
- Mostra a nota de cada tentativa
- Mostra a melhor nota
- Mostra as respostas corretas
2. **Se `showResultsAfterGrading` for `false`:**
- Mostra mensagem: "Sua prova foi enviada e aguarda correção pelo professor"
- Botão para refazer (se permitido)
**Exemplo de Visualização:**
```
┌─────────────────────────────────────────┐
│ Prova de Inglês - Unidade 1 │
│ │
│ Suas Tentativas: │
│ │
│ 1. 60/100 (60%) - 01/02/2025 │
│ 2. 80/100 (80%) - 01/02/2025 │
│ 3. 90/100 (90%) - 01/02/2025 │
│ │
│ Melhor nota: 90/100 (90%) │
│ │
│ [Fazer Prova Novamente] │
└─────────────────────────────────────────┘
```
---
## 8. Correção pelo Professor
**Local:** `/dashboard/teacher/assignments/[assignmentId]/results`
**O que o professor vê:**
- Lista de todos os alunos da turma
- Tentativas de cada aluno
- Respostas de cada tentativa
- Botão para corrigir manualmente
**Processo de Correção:**
1. Professor clica em "Corrigir" em uma tentativa
2. Sistema abre modal com as questões e respostas
3. Professor atribui nota manualmente
4. Sistema salva a nota
5. Aluno pode ver os resultados
---
## Fluxo Completo com Repetição
### Cenário: Prova com 3 tentativas permitidas
```
┌─────────────────────────────────────────────────────────────────┐
│ 1. CRIAÇÃO DO TEMPLATE │
│ Professor cria "Prova de Inglês - Unidade 1" │
│ com 10 questões, 100 pontos, 30 minutos │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 2. ATRIBUIÇÃO DA PROVA │
│ Professor atribui à "Turma A" │
│ - allowRetakes: true │
│ - maxAttempts: 3 │
│ - startDate: 01/02/2025 08:00 │
│ - endDate: 01/02/2025 18:00 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 3. ALUNO VÊ A PROVA │
│ Status: "Disponível" │
│ Botão: "Fazer Prova" │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 4. 1ª TENTATIVA │
│ Aluno clica em "Fazer Prova" │
│ Sistema verifica: │
│ ✓ Dentro do período? SIM │
│ ✓ Tentativas < 3? SIM (0 < 3) │
│ Sistema cria tentativa #1 │
│ Aluno faz a prova (30 min) │
│ Aluno envia │
│ Resultado: 60/100 (60%) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 5. ALUNO VÊ RESULTADOS │
│ Melhor nota: 60/100 (60%) │
│ Botão: "Fazer Prova Novamente" │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 6. 2ª TENTATIVA │
│ Aluno clica em "Fazer Prova Novamente" │
│ Sistema verifica: │
│ ✓ Dentro do período? SIM │
│ ✓ Tentativas < 3? SIM (1 < 3) │
│ Sistema cria tentativa #2 │
│ Aluno faz a prova (30 min) │
│ Aluno envia │
│ Resultado: 80/100 (80%) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 7. ALUNO VÊ RESULTADOS │
│ Melhor nota: 80/100 (80%) │
│ Botão: "Fazer Prova Novamente" │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 8. 3ª TENTATIVA │
│ Aluno clica em "Fazer Prova Novamente" │
│ Sistema verifica: │
│ ✓ Dentro do período? SIM │
│ ✓ Tentativas < 3? SIM (2 < 3) │
│ Sistema cria tentativa #3 │
│ Aluno faz a prova (30 min) │
│ Aluno envia │
│ Resultado: 90/100 (90%) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 9. ALUNO TENTA NOVAMENTE │
│ Aluno clica em "Fazer Prova Novamente" │
│ Sistema verifica: │
│ ✓ Dentro do período? SIM │
│ ✗ Tentativas < 3? NÃO (3 = 3) │
│ Sistema bloqueia: │
│ "Maximum attempts (3) reached" │
│ Botão "Fazer Prova" desabilitado │
└─────────────────────────────────────────────────────────────────┘
```
---
## Regras Importantes
### 1. Período de Disponibilidade
- Aluno só pode fazer a prova entre `startDate` e `endDate`
- Se tentar antes: "Prova ainda não começou"
- Se tentar depois: "Prova já encerrou"
### 2. Tentativas em Andamento
- Se aluno tiver uma tentativa com status `"in_progress"`, não pode criar outra
- Sistema deve permitir continuar a tentativa existente
### 3. Máximo de Tentativas
- Aluno não pode exceder `maxAttempts`
- Se `allowRetakes` for `false`, `maxAttempts` deve ser `1`
### 4. Tempo Limite
- Se `timeLimit` estiver definido, prova é enviada automaticamente quando acabar
- Contador é atualizado a cada segundo
### 5. Resultados
- Se `showResultsAfterGrading` for `true`, aluno vê resultados imediatamente
- Se for `false`, aluno vê mensagem aguardando correção
---
## Melhorias Sugeridas
1. **Salvamento Automático:**
- Salvar respostas periodicamente (ex: a cada 30 segundos)
- Permitir continuar de onde parou em caso de desconexão
2. **Notificações:**
- Avisar aluno quando prova estiver prestes a encerrar
- Avisar professor quando aluno enviar prova
3. **Histórico de Tentativas:**
- Mostrar data/hora de cada tentativa
- Mostrar tempo gasto em cada tentativa
4. **Estatísticas:**
- Média da turma
- Comparação com outros alunos (opcional)
5. **Feedback Imediato:**
- Mostrar quais questões o aluno acertou/errou (opcional)
- Permitir revisão da prova após correção
---
## Conclusão
O sistema de provas é flexível e permite:
**Criação de templates reutilizáveis**
**Atribuição com configurações personalizadas**
**Controle de tentativas e repetições**
**Período de disponibilidade configurável**
**Tempo limite opcional**
**Resultados configuráveis (imediatos ou após correção)**
**Histórico completo de tentativas**
Para dúvidas ou sugestões de melhoria, consulte a equipe de desenvolvimento.
+91
View File
@@ -0,0 +1,91 @@
# Correção: login falha ("Credenciais inválidas") para alguns usuários com senha correta
## Contexto
Usuários relatam, de forma intermitente entre pessoas (não para todos), que digitam a senha
correta e recebem "Credenciais inválidas". A hipótese inicial ("será que está logando mas
mostra erro?") foi **descartada**: em `src/app/lib/userLoginAction.js` o `signIn` em caso de
sucesso lança `NEXT_REDIRECT`, que é corretamente relançado (linhas 32-39), redirecionando para
`/dispatcher`. Não existe caminho de sucesso que ainda exiba o erro. Trata-se de uma **falha real
de autenticação** (nenhuma sessão é criada).
### Causa raiz
O `username` é **gravado normalizado** mas **consultado cru**:
- Schema `src/app/models/User.js:9-10` define `trim: true` + `lowercase: true`. O Mongoose aplica
isso em todo `save`/`create`, então no banco todo username está minúsculo e sem espaços.
- Login `src/app/lib/utils/auth.js:31-32` busca com o valor cru:
`User.findOne({ username: credentials.username })`. O MongoDB faz match **case-sensitive**.
Resultado: quem cadastrou `Joao` (salvo como `joao`) e faz login digitando `Joao` gera busca por
`"Joao"` → nenhum match → `null` → "Credenciais inválidas", **sem nem comparar a senha**.
### Por que "alguns sim, outros não"
Quem digita o usuário todo em minúsculo entra; quem digita com maiúscula/espaço falha **sempre**
(consistente por usuário, não aleatório). Gatilho principal: teclado de **celular** auto-capitaliza
a primeira letra, e o input de login (`src/app/auth/login/LoginForm.jsx:31-37`) não tem
`autoCapitalize`/`autoCorrect`/`autoComplete`. Autofill com espaço final tem o mesmo efeito. Daí o
padrão "mobile reclama, desktop não".
**Observação sobre dados:** não é necessária migração de banco. Como o Mongoose já força lowercase
em toda gravação, não há usernames mixed-case armazenados — apenas a consulta precisa ser corrigida.
## Abordagem (escopo robusto)
### 1. Normalizar a consulta no provider de auth (correção central)
`src/app/lib/utils/auth.js`
- Derivar um username normalizado uma vez e usá-lo na consulta:
```js
const username = (credentials.username || "").trim().toLowerCase();
const password = credentials.password;
...
const user = await User.findOne({ username }).lean();
```
- Remover o uso duplicado de `credentials.username` na linha 32 (usar a variável já tratada).
### 2. Normalizar também no server action de login
`src/app/lib/userLoginAction.js`
- Após `formData.get("username")`, normalizar antes de validar/enviar e antes de preencher
`rawData` (para que o `defaultValue` re-exibido no form já volte normalizado):
```js
const username = (formData.get("username") || "").trim().toLowerCase();
```
- Mantém o tratamento de `NEXT_REDIRECT`/`CredentialsSignin` como está.
### 3. Endurecer o input de login contra auto-capitalização (mobile)
`src/app/auth/login/LoginForm.jsx` — no `UIInput` de `name="username"` adicionar:
`autoCapitalize="none"`, `autoCorrect="off"`, `spellCheck={false}`, `autoComplete="username"`.
- Confirmar que `src/components/ui/input.jsx` repassa `...props` (já repassa), então os atributos
fluem para o `<input>` nativo.
### 4. Alinhar a checagem de duplicado no cadastro
`src/app/lib/users/createUserAction.js:131` — a verificação `findOne({ username })` usa o valor cru
e é case-sensitive, enquanto o `User.create` grava lowercased. Normalizar o `username` logo na
leitura (linha 24) para `(formData.get("username") || "").trim().toLowerCase()`, de modo que a
checagem de duplicado, a mensagem de erro re-exibida e o valor gravado fiquem consistentes. A
validação de "sem espaços" (linha 55) continua válida.
- Aplicar a mesma normalização de leitura em `createWardUserAction.js` (criação de ward) para
manter consistência, caso ele leia username de formData da mesma forma.
## Arquivos críticos
- `src/app/lib/utils/auth.js` — fix central da consulta (CRÍTICO)
- `src/app/lib/userLoginAction.js` — normalização do input do login
- `src/app/auth/login/LoginForm.jsx` — atributos anti auto-capitalize
- `src/app/lib/users/createUserAction.js` — consistência na checagem/gravação
- `src/app/lib/users/createWardUserAction.js` — mesma consistência
- (referência, sem alteração) `src/app/models/User.js`, `src/components/ui/input.jsx`
## Verificação
1. **Repro do bug (antes do fix):** com um usuário existente (ex.: `joao`), tentar login digitando
`Joao` ou `joao ` (com espaço) → deve dar "Credenciais inválidas".
2. **Depois do fix:**
- Login com `Joao`, `JOAO`, ` joao `, `joao` → todos devem autenticar e redirecionar para
`/dispatcher`.
- Senha errada → continua "Credenciais inválidas" (regressão de segurança garantida).
- Usuário inexistente → "Credenciais inválidas".
3. **Mobile:** abrir `/auth/login` em viewport mobile / dispositivo e confirmar que o campo Usuário
não auto-capitaliza a primeira letra.
4. **Cadastro:** registrar `Maria`; conferir no banco que foi salvo como `maria`; tentar cadastrar
`maria` de novo → "Nome de usuario ja cadastrado".
5. Rodar a app localmente (`npm run dev`) e exercitar os fluxos acima; rodar a suíte de testes se
existir.
+213
View File
@@ -0,0 +1,213 @@
# Course Plat
Plataforma de gerenciamento de cursos, turmas, alunos e professores.
## Stack
- **Frontend**: Next.js 15, React, Tailwind CSS
- **Backend**: Next.js API Routes, Server Actions
- **Database**: MongoDB
- **Storage**: MinIO (S3-compatible) ou Local
- **Auth**: NextAuth.js
## Quick Start (Desenvolvimento)
### 1. Clone e instale dependências
```bash
git clone <repo-url>
cd course-plat
npm install
```
### 2. Configure variáveis de ambiente
```bash
cp .env.example .env.local
```
Edite `.env.local` com suas configurações:
```bash
# Mínimo para desenvolvimento:
NODE_ENV=development
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=qualquer-segredo-aqui-min-32-caracteres
MONGODB_URI=mongodb://localhost:27017/course-plat
```
### 3. Inicie os serviços com Docker
```bash
# MongoDB + MinIO
docker compose up -d
# Verifique que os containers estão rodando
docker ps
```
### 4. Inicie a aplicação
```bash
npm run dev
```
Acesse: http://localhost:3000
## Serviços Docker
| Serviço | Porta | Descrição |
|---------|-------|-----------|
| App | 3000 | Aplicação Next.js |
| MongoDB | 27017 | Banco de dados |
| Mongo Express | 8081 | UI do MongoDB (opcional) |
| MinIO API | 9000 | Storage S3-compatible |
| MinIO Console | 9001 | UI do MinIO |
## Estrutura de Projetos
```
course-plat/
├── src/app/ # App Router Next.js
│ ├── (auth)/ # Rotas de autenticação
│ ├── (protected)/ # Rotas protegidas
│ ├── api/ # API Routes
│ └── lib/ # Utilitários e helpers
├── docker/ # Configurações Docker
│ ├── minio/ # MinIO setup
│ └── production/ # Configurações de produção
├── storage/ # Armazenamento local (se usado)
└── .env.local # Variáveis de ambiente (não commitar)
```
## Deploy em Produção
Veja [DEPLOYMENT.md](./DEPLOYMENT.md) para instruções completas.
Resumo rápido:
```bash
# 1. Configurar ambiente
cp .env.example .env.production
nano .env.production # preencher variáveis
# 2. Buildar imagem
docker build -f docker/production/Dockerfile -t course-plat:latest .
# 3. Iniciar serviços
docker compose -f docker/production/docker-compose.yml up -d
# 4. Configurar backup (crontab)
0 2 * * * cd /var/www/course-plat && ./docker/production/backup-all.sh
```
## Variáveis de Ambiente
Veja `.env.example` para todas as variáveis disponíveis.
Principais:
| Variável | Descrição | Default |
|----------|-----------|---------|
| `NODE_ENV` | Ambiente | `development` |
| `NEXTAUTH_URL` | URL da app | `http://localhost:3000` |
| `NEXTAUTH_SECRET` | Segredo auth | (obrigatório) |
| `MONGODB_URI` | String conexão MongoDB | (obrigatório) |
| `STORAGE_TYPE` | `s3` ou `local` | `s3` |
| `S3_ENDPOINT` | Endpoint MinIO/S3 | `http://localhost:9000` |
| `S3_BUCKET` | Nome do bucket | `course-plat` |
## Storage de Arquivos
### Sistema Híbrido
A aplicação suporta dois tipos de armazenamento:
**1. S3/MinIO (recomendado)**
- Arquivos armazenados em bucket privado
- Acesso via proxy da aplicação (`/api/files/...`)
- Controle de permissões por turma/classe
**2. Local**
- Arquivos em disco (`./storage/uploads`)
- Útil para desenvolvimento ou servidores pequenos
### URLs de Arquivos
| Onde | Formato |
|------|---------|
| Banco de dados | `proxy://pasta/arquivo.pdf` |
| Frontend | `/api/files/pasta/arquivo.pdf` |
Use `getFileUrl(url)` de `@/app/lib/utils/storage/fileUrl` para converter.
## Comandos Úteis
```bash
# Desenvolvimento
npm run dev # Inicia dev server
npm run build # Build para produção
npm run start # Inicia produção server
# Docker
docker compose up -d # Inicia serviços
docker compose down # Para serviços
docker compose logs -f # Ver logs
# MongoDB (via Docker)
docker exec -it course-plat-mongodb mongosh
docker exec course-plat-mongodb mongodump --archive > backup.gz
# MinIO (via Docker)
docker exec -it course-plat-minio mc alias set local http://localhost:9000 minioadmin minioadmin
docker exec course-plat-minio mc ls local/course-plat
```
## Backup
### MongoDB
```bash
# Script de backup
./docker/production/backup-mongodb.sh
# Manual
docker exec course-plat-mongodb mongodump --gzip --archive > backup.gz
```
### MinIO
```bash
# Script de backup
./docker/minio/backup-minio.sh
```
## Troubleshooting
### MongoDB não conecta
```bash
# Verificar se container está rodando
docker ps | grep mongo
# Ver logs
docker logs course-plat-mongodb
```
### MinIO Access Denied
```bash
# Verificar política do bucket
docker exec course-plat-minio mc anonymous get local/course-plat
# Bucket deve ser "none" (privado)
docker exec course-plat-minio mc anonymous set none local/course-plat
```
### Arquivos antigos não funcionam
Verifique se `getFileUrl()` está sendo usado para converter URLs. Arquivos antigos com URLs diretas do MinIO precisam ser convertidas para o proxy.
## Documentação Adicional
- [DEPLOYMENT.md](./DEPLOYMENT.md) - Deploy em produção
- [docker/minio/README.md](./docker/minio/README.md) - Setup do MinIO
## Licença
[Adicionar licença]
+893
View File
@@ -0,0 +1,893 @@
# Padrões de Estilo - Course Platform
Este documento define os padrões de estilo unificados para todo o sistema, garantindo consistência visual e facilidade de manutenção.
## Índice
- [Princípios Fundamentais](#princípios-fundamentais)
- [Cores e Tema](#cores-e-tema)
- [Tipografia](#tipografia)
- [Espaçamento](#espaçamento)
- [Badges e Labels](#badges-e-labels)
- [Componentes](#componentes)
- [Transições e Animações](#transições-e-animações)
- [Regras CSS Globais](#regras-css-globais)
---
## Princípios Fundamentais
1. **Usar CSS Variables** para cores, backgrounds e bordas (definidas em `:root` e `.dark`)
2. **Padronizar em `neutral-*`** em vez de `gray-*` para consistência
3. **Usar `blue-600`** como cor primária para botões de ação principal
4. **Manter border-radius de 8px** (`rounded-lg` ou `rounded-xl`)
5. **Usar sombras sutis**: `shadow-sm` padrão, `shadow-md` no hover
6. **Evitar regras CSS globais com `!important`** que sobrescrevem classes Tailwind
---
## Cores e Tema
### CSS Variables (Definidas em `globals.css`)
```css
/* Tema Claro (:root) */
--background: #ffffff;
--foreground: #09090b;
--card: #ffffff;
--card-foreground: #09090b;
--primary: #18181b;
--primary-foreground: #fafafa;
--secondary: #f4f4f5;
--secondary-foreground: #18181b;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f4f4f5;
--accent-foreground: #18181b;
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #18181b;
--radius: 0.5rem;
/* Tema Escuro (.dark) */
--background: #09090b;
--foreground: #fafafa;
--card: #09090b;
--card-foreground: #fafafa;
--primary: #fafafa;
--primary-foreground: #18181b;
--secondary: #27272a;
--secondary-foreground: #fafafa;
--muted: #27272a;
--muted-foreground: #a1a1aa;
--accent: #27272a;
--accent-foreground: #fafafa;
--destructive: #7f1d1d;
--destructive-foreground: #fafafa;
--border: #27272a;
--input: #27272a;
--ring: #d4d4d8;
```
### Cores Semânticas (Tailwind)
| Propósito | Light Mode | Dark Mode |
|-----------|------------|-----------|
| Primary (Ação principal) | `blue-600` | `blue-500` |
| Primary Hover | `blue-700` | `blue-600` |
| Success | `green-600` | `green-500` |
| Success Hover | `green-700` | `green-600` |
| Warning | `yellow-500` | `yellow-400` |
| Warning Hover | `yellow-600` | `yellow-500` |
| Error/Destructive | `red-600` | `red-500` |
| Error Hover | `red-700` | `red-600` |
| Info | `blue-600` | `blue-500` |
### Cores Neutras (Padrão)
**Use sempre `neutral-*` em vez de `gray-*` para consistência:**
| Escala | Light Mode | Dark Mode |
|--------|------------|-----------|
| 50 | `#fafafa` | `#18181b` |
| 100 | `#f5f5f5` | `#27272a` |
| 200 | `#e5e5e5` | `#3f3f46` |
| 300 | `#d4d4d4` | `#52525b` |
| 400 | `#a3a3a3` | `#71717a` |
| 500 | `#737373` | `#a1a1aa` |
| 600 | `#525252` | `#d4d4d8` |
| 700 | `#404040` | `#e4e4e7` |
| 800 | `#262626` | `#f4f4f5` |
| 900 | `#171717` | `#fafafa` |
| 950 | `#0a0a0a` | `#fafafa` |
---
## Tipografia
### Fonte
- **Fonte Principal**: Geist Sans (Google Fonts)
- **Fonte Monospace**: Geist Mono (Google Fonts)
### Tamanhos e Pesos
| Elemento | Classe Tailwind | Uso |
|----------|-----------------|-----|
| Título Principal | `text-2xl md:text-3xl font-semibold` | Títulos de página |
| Título de Seção | `text-lg font-semibold` | Títulos de seção |
| Subtítulo | `text-base font-medium` | Subtítulos |
| Texto Normal | `text-sm` | Texto de conteúdo |
| Texto Secundário | `text-sm text-muted-foreground` | Texto de apoio |
| Label | `text-sm font-medium` | Labels de formulário |
| Caption/Small | `text-xs` | Textos pequenos |
### Exemplos
```jsx
// Título de Página
<h1 className="text-2xl md:text-3xl font-semibold text-foreground">
Título da Página
</h1>
// Título de Seção
<h2 className="text-lg font-semibold text-foreground">
Título da Seção
</h2>
// Subtítulo
<p className="text-sm text-muted-foreground">
Descrição ou subtítulo
</p>
// Label
<label className="text-sm font-medium leading-none">
Nome do Campo
</label>
```
---
## Espaçamento
### Padrões de Padding
| Elemento | Padding/Margin |
|----------|----------------|
| Card padding | `p-5` |
| Card padding (compacto) | `p-4` |
| Button padding | `px-4 py-2` |
| Input padding | `px-3 py-2` |
| Table cell padding | `px-6 py-4` |
| Section gap | `gap-3` ou `gap-4` |
| Vertical spacing entre elementos | `space-y-4` |
| Horizontal spacing entre elementos | `space-x-3` |
### Exemplos
```jsx
// Card
<div className="border rounded-xl p-5 shadow-sm bg-card">
{/* conteúdo */}
</div>
// Formulário com espaçamento vertical
<form className="space-y-4">
{/* campos */}
</form>
// Botões com espaçamento horizontal
<div className="flex gap-3">
<button>Cancelar</button>
<button>Salvar</button>
</div>
```
---
## Badges e Labels
Badges são pequenos labels coloridos usados para indicar status, categorias ou outros metadados. Devem ser usados consistentemente em toda a aplicação.
### Estrutura Base
```jsx
<span className="text-xs px-2 py-1 rounded-full border [badge-classes]">
Label Text
</span>
```
### Padrões de Cores
#### Success/Green (Disponível, Pago, Ativo, Presente)
**Tema Claro:** `bg-emerald-100 text-emerald-700 border-emerald-200`
**Tema Escuro:** `dark:bg-emerald-900/20 dark:text-emerald-300 dark:border-emerald-800`
```jsx
<span className="text-xs px-2 py-1 rounded-full border bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-300 dark:border-emerald-800">
Disponível
</span>
```
**Casos de Uso:**
- "Disponível" (Available)
- "Pago" (Paid)
- "Ativa" (Active)
- "Presente" (Present)
- Status "Verified"
---
#### Info/Blue (Em breve, Aguardando Verificação, Justificado)
**Tema Claro:** `bg-blue-100 text-blue-700 border-blue-200`
**Tema Escuro:** `dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800`
```jsx
<span className="text-xs px-2 py-1 rounded-full border bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800">
Em breve
</span>
```
**Casos de Uso:**
- "Em breve" (Upcoming)
- "Aguardando Verificação" (Pending Verification)
- "Justificado" (Excused)
- Templates "Public"
---
#### Danger/Red (Encerrada, Rejeitado, Ausente)
**Tema Claro:** `bg-red-100 text-red-700 border-red-200`
**Tema Escuro:** `dark:bg-red-900/20 dark:text-red-300 dark:border-red-800`
```jsx
<span className="text-xs px-2 py-1 rounded-full border bg-red-100 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800">
Encerrada
</span>
```
**Casos de Uso:**
- "Encerrada" (Expired)
- "Rejeitado" (Rejected)
- "Ausente" (Absent)
- Status "Rejected"
---
#### Warning/Amber (Aguardando Pagamento, Atrasado, Arquivada)
**Tema Claro:** `bg-amber-100 text-amber-700 border-amber-200`
**Tema Escuro:** `dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800`
```jsx
<span className="text-xs px-2 py-1 rounded-full border bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800">
Aguardando Pagamento
</span>
```
**Casos de Uso:**
- "Aguardando Pagamento" (Pending Payment)
- "Atrasado" (Late)
- "Arquivada" (Archived)
- Status "Pending"
---
#### Warning/Yellow (Obrigações)
**Tema Claro:** `bg-yellow-100 text-yellow-800 border-yellow-300`
**Tema Escuro:** `dark:bg-yellow-900/50 dark:text-yellow-200 dark:border-yellow-700`
```jsx
<span className="text-xs px-2 py-1 rounded-full border bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-900/50 dark:text-yellow-200 dark:border-yellow-700">
Obrigação
</span>
```
**Casos de Uso:**
- Obrigações de pagamento
- "Obrigação" (Obligation)
---
#### Purple (Leitura)
**Tema Claro:** `bg-purple-100 text-purple-700 border-purple-200`
**Tema Escuro:** `dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800`
```jsx
<span className="text-xs px-2 py-1 rounded-full border bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800">
Leitura
</span>
```
**Casos de Uso:**
- "Leitura" (Reading)
- Categoria "Indigo"
---
#### Indigo (Trabalhos)
**Tema Claro:** `bg-indigo-100 text-indigo-700 border-indigo-200`
**Tema Escuro:** `dark:bg-indigo-900/20 dark:text-indigo-300 dark:border-indigo-800`
```jsx
<span className="text-xs px-2 py-1 rounded-full border bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-900/20 dark:text-indigo-300 dark:border-indigo-800">
Trabalhos
</span>
```
**Casos de Uso:**
- "Trabalhos" (Homework)
- Itens relacionados a assignments
---
#### Neutral/Gray (Outros, Padrão)
**Tema Claro:** `bg-gray-100 text-gray-700 border-gray-200`
**Tema Escuro:** `dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700`
```jsx
<span className="text-xs px-2 py-1 rounded-full border bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700">
Outros
</span>
```
**Casos de Uso:**
- "Outros" (Other)
- Categorias padrão/desconhecidas
- Status neutro
### Badges por Categoria
#### Categorias de Arquivos
| Categoria | Cor |
|-----------|-----|
| Slides | Blue |
| Exercícios | Green |
| Leitura | Purple |
| Vídeos | Red |
| Áudio | Yellow |
| Trabalhos | Indigo |
| Outros | Gray |
#### Status de Presença
| Status | Cor |
|--------|-----|
| Presente | Green |
| Ausente | Red |
| Atrasado | Amber |
| Justificado | Blue |
#### Status de Pagamento
| Status | Cor |
|--------|-----|
| Pago | Green |
| Aguardando Verificação | Blue |
| Rejeitado | Red |
| Aguardando Pagamento | Amber |
| Obrigação | Yellow |
#### Status de Provas
| Status | Cor |
|--------|-----|
| Disponível | Green |
| Em breve | Blue |
| Encerrada | Red |
#### Status de Turma
| Status | Cor |
|--------|-----|
| Ativa | Green |
| Arquivada | Amber |
### Exemplo de Implementação
```jsx
const categoryColors = {
'Slides': 'text-xs px-2 py-1 rounded-full border bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800',
'Exercícios': 'text-xs px-2 py-1 rounded-full border bg-green-100 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800',
'Leitura': 'text-xs px-2 py-1 rounded-full border bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800',
'Vídeos': 'text-xs px-2 py-1 rounded-full border bg-red-100 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800',
'Áudio': 'text-xs px-2 py-1 rounded-full border bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-800',
'Trabalhos': 'text-xs px-2 py-1 rounded-full border bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-900/20 dark:text-indigo-300 dark:border-indigo-800',
'Outros': 'text-xs px-2 py-1 rounded-full border bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700'
};
<span className={categoryColors[category] || categoryColors['Outros']}>
{category}
</span>
```
### Notas Importantes
1. **Consistência**: Sempre use a string de classes completa incluindo variantes de tema claro e escuro
2. **Acessibilidade**: As combinações de cores fornecem contraste suficiente para ambos os temas
3. **Opacidade**: O tema escuro usa `/20` de opacidade para a maioria das cores (exceto yellow que usa `/50`)
4. **Borda**: Sempre inclua borda para melhor separação visual
5. **Arredondamento**: Use `rounded-full` para badges em formato de pílula
6. **Tamanho**: Use `text-xs` para badges pequenos, `text-sm` para badges maiores se necessário
---
## Componentes
### Cards
**Padrão Unificado:**
```jsx
<div className="border rounded-xl p-5 shadow-sm bg-card dark:bg-card dark:border-border hover:shadow-md hover:border-neutral-300 dark:hover:border-neutral-700 transition-all">
{/* conteúdo */}
</div>
```
**Card com Link:**
```jsx
<Link href="/path" className="block no-underline">
<div className="border rounded-xl p-5 shadow-sm bg-card hover:shadow-md transition-all">
{/* conteúdo */}
</div>
</Link>
```
### Botões
**Primary (Ação Principal):**
```jsx
<button
type="submit"
className="inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white h-10 px-4 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
Salvar
</button>
```
**Secondary (Ação Secundária):**
```jsx
<button
type="button"
className="inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
Cancelar
</button>
```
**Destructive (Ação de Exclusão):**
```jsx
<button
type="button"
className="inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium bg-red-600 hover:bg-red-700 text-white h-10 px-4 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
Excluir
</button>
```
**Botão com Ícone:**
```jsx
<button className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium shadow-sm hover:shadow transition-shadow">
<Icon className="w-4 h-4" />
<span>Texto do Botão</span>
</button>
```
### Inputs
**Input de Texto:**
```jsx
<input
type="text"
className="flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Digite aqui..."
/>
```
**Select:**
```jsx
<div className="relative">
<select
className="flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 appearance-none"
>
<option value="">Selecione...</option>
{/* opções */}
</select>
<svg className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground">
{/* ícone de seta */}
</svg>
</div>
```
**Textarea:**
```jsx
<textarea
className="flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Digite sua mensagem..."
rows={4}
/>
```
### Tabelas
**Tabela Padrão:**
```jsx
<div className="w-full overflow-x-auto rounded-xl border border-border bg-card shadow-sm">
<table className="w-full text-sm text-left">
<thead className="bg-muted text-muted-foreground font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-4 border-b border-border">Coluna 1</th>
<th className="px-6 py-4 border-b border-border">Coluna 2</th>
<th className="px-6 py-4 border-b border-border text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr className="hover:bg-accent transition-colors">
<td className="px-6 py-4 text-foreground">Valor 1</td>
<td className="px-6 py-4 text-foreground">Valor 2</td>
<td className="px-6 py-4 text-right">
{/* ações */}
</td>
</tr>
</tbody>
</table>
</div>
```
### Badges
**Status Badges:**
```jsx
// Success (Pago)
<span className="px-2 py-1 rounded-full text-xs font-medium border bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200 border-green-300 dark:border-green-700">
Pago
</span>
// Warning (Pendente)
<span className="px-2 py-1 rounded-full text-xs font-medium border bg-yellow-100 dark:bg-yellow-900/50 text-yellow-800 dark:text-yellow-200 border-yellow-300 dark:border-yellow-700">
Pendente
</span>
// Error (Rejeitado)
<span className="px-2 py-1 rounded-full text-xs font-medium border bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200 border-red-300 dark:border-red-700">
Rejeitado
</span>
// Info (Aguardando Verificação)
<span className="px-2 py-1 rounded-full text-xs font-medium border bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200 border-blue-300 dark:border-blue-700">
Aguardando Verificação
</span>
// Neutral (Não Pago)
<span className="px-2 py-1 rounded-full text-xs font-medium border bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 border-neutral-300 dark:border-neutral-600">
Não Pago
</span>
```
### Flash Messages
```jsx
// Success
<div className="px-4 py-3 rounded relative mb-4 text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/50 border border-green-400 dark:border-green-700">
<div className="flex items-center">
<strong className="font-bold mr-1">Sucesso:</strong>
<span className="block sm:inline">Mensagem de sucesso</span>
</div>
</div>
// Error
<div className="px-4 py-3 rounded relative mb-4 text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-900/50 border border-red-400 dark:border-red-700">
<div className="flex items-center">
<strong className="font-bold mr-1">Erro:</strong>
<span className="block sm:inline">Mensagem de erro</span>
</div>
</div>
// Warning
<div className="px-4 py-3 rounded relative mb-4 text-yellow-700 dark:text-yellow-300 bg-yellow-100 dark:bg-yellow-900/50 border border-yellow-400 dark:border-yellow-700">
<div className="flex items-center">
<strong className="font-bold mr-1">Aviso:</strong>
<span className="block sm:inline">Mensagem de aviso</span>
</div>
</div>
// Info
<div className="px-4 py-3 rounded relative mb-4 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 border border-blue-400 dark:border-blue-700">
<div className="flex items-center">
<strong className="font-bold mr-1">Info:</strong>
<span className="block sm:inline">Mensagem informativa</span>
</div>
</div>
```
### Sidebar Navigation
```jsx
<aside className="w-64 flex-shrink-0 border-r border-neutral-200 dark:border-neutral-800 p-4 bg-white dark:bg-neutral-950">
<nav className="mt-2">
<ul className="space-y-1">
{/* itens de navegação */}
</ul>
</nav>
</aside>
```
### Stats Cards
```jsx
<div className="rounded-lg border border-neutral-200 dark:border-neutral-800 p-3 bg-neutral-50 dark:bg-neutral-800/40">
<p className="text-xs text-neutral-700 dark:text-neutral-300 font-medium">Label</p>
<p className="mt-1 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Valor</p>
</div>
```
---
## Transições e Animações
### Padrões de Transição
```jsx
// Transição suave para todas as propriedades
className="transition-all duration-200"
// Transição apenas para cores
className="transition-colors"
// Transição para sombra
className="transition-shadow"
// Transição para transformações
className="transition-transform"
```
### Hover Effects
```jsx
// Card hover
<div className="hover:shadow-md hover:border-neutral-300 dark:hover:border-neutral-700 transition-all">
// Button hover
<button className="hover:bg-blue-700 transition-colors">
// Link hover
<a className="hover:text-blue-600 transition-colors">
```
---
## Regras CSS Globais
### Importante: Evitar `!important` em regras globais
**NÃO FAÇA:**
```css
button[type="submit"] {
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
}
```
Isso sobrescreve todas as classes Tailwind e causa problemas de manutenção.
**FAÇA:**
Deixe cada componente definir seu próprio estilo usando classes Tailwind ou CSS variables sem `!important`.
### Estilos Base (Definidos em `globals.css`)
```css
/* Form elements */
input, select, textarea {
border: 1px solid var(--input);
background-color: var(--background);
border-radius: var(--radius);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: var(--foreground);
transition: all 0.2s;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--ring);
box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring);
}
/* Tables */
table {
border-collapse: collapse;
width: 100%;
font-size: 0.875rem;
}
thead {
background-color: var(--muted);
}
tbody tr {
border-bottom: 1px solid var(--border);
}
tbody tr:hover {
background-color: var(--accent);
}
th {
text-align: left;
font-weight: 500;
color: var(--muted-foreground);
padding: 0.75rem 1rem;
font-size: 0.75rem;
text-transform: uppercase;
}
td {
padding: 1rem;
vertical-align: middle;
}
/* Cards */
.card {
background-color: var(--card);
color: var(--card-foreground);
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
}
/* Badges */
.badge {
display: inline-flex;
align-items: center;
border-radius: 9999px;
padding: 0.125rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
}
/* Links */
a {
color: var(--primary);
text-decoration: none;
}
a:hover,
a:focus,
a:active {
text-decoration: none !important;
}
```
---
## Checklist de Implementação
Ao criar ou modificar componentes, verifique:
- [ ] Usar CSS variables (`bg-card`, `text-foreground`, `border-border`)
- [ ] Usar `neutral-*` em vez de `gray-*`
- [ ] Usar `blue-600` para botões primary
- [ ] Usar `rounded-lg` ou `rounded-xl` para border-radius
- [ ] Adicionar `transition-colors` ou `transition-all` para elementos interativos
- [ ] Testar em tema claro e escuro
- [ ] Verificar contraste de cores
- [ ] Usar classes Tailwind em vez de CSS customizado quando possível
- [ ] Evitar `!important` em regras CSS
---
## Exemplos de Componentes Completos
### Formulário Padrão
```jsx
function StandardForm({ onSubmit, onCancel }) {
return (
<form onSubmit={onSubmit} className="bg-card text-card-foreground rounded-xl border shadow-sm p-6 max-w-lg mx-auto space-y-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium leading-none">
Nome
</label>
<input
id="name"
name="name"
className="flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="Digite o nome"
/>
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium leading-none">
Email
</label>
<input
id="email"
name="email"
type="email"
className="flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="Digite o email"
/>
</div>
<div className="flex gap-3 mt-6">
<button
type="submit"
className="inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white h-10 px-4 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 flex-1"
>
Salvar
</button>
<button
type="button"
onClick={onCancel}
className="inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex-1"
>
Cancelar
</button>
</div>
</form>
);
}
```
### Card de Dashboard
```jsx
function DashboardCard({ title, description, buttonText, link, icon: Icon }) {
return (
<Link href={link} className="block no-underline">
<div className="h-full border rounded-xl p-5 shadow-sm bg-card dark:bg-card dark:border-border hover:shadow-md hover:border-neutral-300 dark:hover:border-neutral-700 transition-all">
<div className="flex items-start gap-4">
{Icon && (
<div className="p-2.5 rounded-lg bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
<Icon className="w-5 h-5" />
</div>
)}
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
<p className="text-sm text-muted-foreground mb-4">{description}</p>
<span className="inline-flex justify-center text-white text-sm font-medium py-2 px-6 rounded-lg transition-colors bg-blue-600 hover:bg-blue-700">
{buttonText}
</span>
</div>
</div>
</div>
</Link>
);
}
```
---
## Conclusão
Seguir estes padrões garantirá:
**Consistência visual** em todo o sistema
**Manutenção mais fácil** com código previsível
**Melhor suporte** a tema claro/escuro
**Código mais limpo** e organizado
**Experiência do usuário** mais coesa
Para dúvidas ou sugestões de melhoria, consulte a equipe de desenvolvimento.
+145
View File
@@ -0,0 +1,145 @@
#!/bin/bash
# Script de Deploy para produção
# Uso: ./deploy.sh [ambiente]
# Exemplo: ./deploy.sh production
set -e # Para em caso de erro
echo "🚀 Iniciando deploy..."
# Cores para output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Função para imprimir mensagens coloridas
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}$1${NC}"
}
# Detectar o dono do diretório para rodar comandos como ele
DIR_OWNER=$(stat -c '%U' .)
PROJECT_DIR=$(pwd)
run_as_owner() {
su - "$DIR_OWNER" -c "cd $PROJECT_DIR && source ~/.bashrc 2>/dev/null; source ~/.profile 2>/dev/null; export NVM_DIR=\"\$HOME/.nvm\"; [ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"; $1"
}
# Verificar se está rodando como root
if [ "$EUID" -ne 0 ]; then
print_warning "Este script precisa de sudo para configurar o nginx"
SUDO="sudo"
else
SUDO=""
fi
# 1. Atualizar código (se estiver em git)
echo ""
echo "📦 Atualizando código..."
if [ -d ".git" ]; then
run_as_owner "git pull"
print_success "Código atualizado"
else
print_warning "Não é um repositório git, pulando atualização"
fi
# 2. Instalar dependências limpas
echo ""
echo "📥 Instalando dependências com npm ci..."
if [ -f "package-lock.json" ]; then
run_as_owner "rm -rf node_modules && npm ci"
print_success "Dependências instaladas"
else
print_warning "package-lock.json não encontrado, usando npm install"
run_as_owner "npm install"
fi
# 3. Configurar nginx para uploads maiores
echo ""
echo "🌐 Configurando nginx..."
NGINX_CONF="/etc/nginx/sites-available/inglescomideiasvivas"
NGINX_BACKUP="/tmp/nginx_inglescomideiasvivas.backup"
# Verificar se o arquivo de configuração existe
if [ -f "$NGINX_CONF" ]; then
# Fazer backup
$SUDO cp "$NGINX_CONF" "$NGINX_BACKUP"
# Verificar se client_max_body_size já existe
if $SUDO grep -q "client_max_body_size" "$NGINX_CONF"; then
print_warning "client_max_body_size já configurado, atualizando para 500M..."
$SUDO sed -i 's/client_max_body_size .*/client_max_body_size 500M;/' "$NGINX_CONF"
else
# Adicionar client_max_body_size após client_body_timeout ou no bloco server
$SUDO sed -i '/server {/a \ client_max_body_size 500M;' "$NGINX_CONF"
print_success "client_max_body_size 500M adicionado ao nginx"
fi
# Testar configuração do nginx
echo "Testando configuração do nginx..."
if $SUDO nginx -t 2>&1 | grep -q "successful"; then
print_success "Configuração do nginx válida"
$SUDO systemctl reload nginx
print_success "Nginx recarregado"
else
print_error "Configuração do nginx inválida!"
echo "Restorando backup..."
$SUDO cp "$NGINX_BACKUP" "$NGINX_CONF"
$SUDO nginx -t
exit 1
fi
else
print_warning "Arquivo de configuração do nginx não encontrado em $NGINX_CONF"
echo "Você pode precisar configurar manualmente:"
echo " sudo nano $NGINX_CONF"
echo "Adicione 'client_max_body_size 500M;' no bloco server"
fi
# 4. Limpar cache do Next.js e rebuild
echo ""
echo "🔨 Limpar cache e rebuild..."
run_as_owner "rm -rf .next .next.cache"
print_success "Cache limpo"
echo "Building..."
run_as_owner "npm run build"
print_success "Build concluído"
# 5. Reiniciar PM2
echo ""
echo "🔄 Reiniciando aplicação com PM2..."
# Verificar se o app existe no PM2
if run_as_owner "pm2 list" | grep -q "iciv"; then
run_as_owner "pm2 delete iciv"
print_success "App antigo removido"
fi
run_as_owner "pm2 start ecosystem.config.js"
run_as_owner "pm2 save"
print_success "Aplicação reiniciada"
# 6. Mostrar status
echo ""
echo "📊 Status da aplicação:"
run_as_owner "pm2 list"
echo ""
echo "🎉 Deploy concluído com sucesso!"
echo ""
echo "Logs em tempo real:"
echo " pm2 logs iciv"
echo ""
echo "Para verificar logs de erro:"
echo " pm2 logs iciv --err"
+93
View File
@@ -0,0 +1,93 @@
# Docker Compose para Desenvolvimento
# Para produção, veja docker/production/docker-compose.yml
services:
# MongoDB - Banco de dados
mongodb:
image: mongo:8
container_name: course-plat-mongodb
restart: unless-stopped
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME:-admin}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password}
MONGO_INITDB_DATABASE: ${MONGO_DB:-course-plat}
volumes:
- mongodb_data:/data/db
- mongodb_config:/data/configdb
healthcheck:
test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet
interval: 30s
timeout: 10s
retries: 3
networks:
- course-plat-network
# MongoDB Express (opcional - UI para o MongoDB)
mongo-express:
image: mongo-express:latest
container_name: course-plat-mongo-express
restart: unless-stopped
ports:
- "8081:8081"
environment:
ME_CONFIG_MONGODB_URL: mongodb://${MONGO_USERNAME:-admin}:${MONGO_PASSWORD:-password}@mongodb:27017/
ME_CONFIG_BASICAUTH_USERNAME: ${MONGO_EXPR_USER:-admin}
ME_CONFIG_BASICAUTH_PASSWORD: ${MONGO_EXPR_PASS:-admin}
depends_on:
mongodb:
condition: service_healthy
networks:
- course-plat-network
# MinIO - Armazenamento de arquivos (S3 compatible)
minio:
image: minio/minio:latest
container_name: course-plat-minio
restart: unless-stopped
ports:
- "9000:9000" # API
- "9001:9001" # Console (UI)
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
networks:
- course-plat-network
# MinIO Init - Cria bucket privado automaticamente
minio-init:
image: minio/mc:latest
container_name: course-plat-minio-init
depends_on:
- minio
entrypoint: >
sh -c "
sleep 5 &&
mc alias set myminio http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin} &&
mc mb myminio/${MINIO_BUCKET:-course-plat} --ignore-existing &&
mc anonymous set none myminio/${MINIO_BUCKET:-course-plat} &&
echo 'MinIO initialized! Bucket is private.'
"
restart: "no"
networks:
- course-plat-network
volumes:
mongodb_data:
driver: local
mongodb_config:
driver: local
minio_data:
driver: local
networks:
course-plat-network:
driver: bridge
+4
View File
@@ -0,0 +1,4 @@
# MinIO Credentials
MINIO_ROOT_USER=admin
MINIO_ROOT_PASSWORD=change-this-password-please
MINIO_BUCKET=course-plat
+157
View File
@@ -0,0 +1,157 @@
# MinIO + Backup Setup
Setup completo do MinIO com scripts de backup para VPS e download local.
## 📁 Estrutura
```
docker/minio/
├── docker-compose.yml # Serviço MinIO
├── backup-minio.sh # Script de backup (roda no servidor)
├── pull-backup.sh # Script para baixar backup (roda no seu PC)
└── .env.example # Variáveis de ambiente
```
## 🔒 Segurança
**Bucket é PRIVADO** por padrão. Arquivos são servidos via proxy da aplicação (`/api/files/...`), o que permite:
- Controle de autenticação
- Verificação de permissões
- Logs de acesso
## 🚀 Setup Inicial
### 1. Criar arquivo .env
```bash
cd docker/minio
cat > .env << 'EOF'
MINIO_ROOT_USER=admin
MINIO_ROOT_PASSWORD=sua-senha-forte-aqui
MINIO_BUCKET=course-plat
EOF
```
### 2. Iniciar MinIO
```bash
docker compose up -d
```
### 3. Acessar
- **Console (UI)**: http://seu-servidor:9001
- **API**: http://seu-servidor:9000
## 💾 Backup Automático
### No Servidor (VPS)
1. Copiar o script para o servidor:
```bash
chmod +x backup-minio.sh
sudo cp backup-minio.sh /usr/local/bin/
```
2. Testar manualmente:
```bash
/usr/local/bin/backup-minio.sh
```
3. Adicionar ao crontab (backup todo dia às 2h da manhã):
```bash
crontab -e
# Adicionar linha:
0 2 * * * /usr/local/bin/backup-minio.sh >> /var/log/minio-backup.log 2>&1
```
### No Seu PC
1. Editar `pull-backup.sh` com seus dados:
```bash
SERVER_USER="seu-usuario"
SERVER_HOST="seu-servidor.com"
```
2. Dar permissão e testar:
```bash
chmod +x pull-backup.sh
./pull-backup.sh
```
3. Agendar no crontab do SEU PC (todo dia às 3h):
```bash
crontab -e
# Adicionar linha:
0 3 * * * /caminho/para/pull-backup.sh
```
## 🔄 Como Funciona
```
┌─────────────────┐ 02:00 ┌──────────────────┐
│ Servidor VPS │ ────────────────▶│ Backup Local │
│ (MinIO) │ backup-minio.sh│ (/var/backups/) │
└─────────────────┘ └──────────────────┘
│ 03:00
│ pull-backup.sh
┌──────────────────┐
│ Seu PC │
│ (Backup final) │
└──────────────────┘
```
## 📦 Restaurar Backup
### No servidor:
```bash
# Parar MinIO
docker compose down
# Restaurar volume
docker run --rm \
-v course-plat_minio_data:/data \
-v /var/backups/minio:/backup \
alpine tar xzf /backup/minio-backup-YYYYMMDD-HHMMSS.tar.gz -C /data
# Reiniciar
docker compose up -d
```
## ⚠️ Considerações
| Aspecto | Nota |
|---------|------|
| **Segurança** | Use chaves SSH para o rsync |
| **Backup external** | Considere também mandar para S3/Backblaze |
| **Teste** | Teste restaurar pelo menos uma vez |
| **Logs** | Verifique `/var/log/minio-backup.log` regularmente |
## 🔧 Variáveis do backup-minio.sh
```bash
BACKUP_DIR="/var/backups/minio" # Onde salvar no servidor
RETENTION_DAYS=7 # Dias para manter backup
VOLUME_NAME="course-plat_minio_data" # Nome do volume
```
## 🔧 Variáveis do pull-backup.sh
```bash
SERVER_USER="usuario" # Usuário SSH
SERVER_HOST="seu-servidor.com" # Host do servidor
SERVER_BACKUP_DIR="/var/backups/minio"
LOCAL_BACKUP_DIR="$HOME/backups/minio"
KEEP_LOCAL_DAYS=30 # Dias para manter localmente
```
## 🔗 URLs de Arquivos
| Onde | Formato |
|------|---------|
| **Banco de dados** | `proxy://iciv/ClassTypes/arquivo.pdf` |
| **Front-end** | `/api/files/iciv/ClassTypes/arquivo.pdf` |
Use `getFileUrl(url)` do `@/app/lib/utils/storage/fileUrl` para converter.
+61
View File
@@ -0,0 +1,61 @@
#!/bin/bash
# backup-minio.sh - Script de backup do MinIO no servidor
# Uso: ./backup-minio.sh
# Agendar no crontab: 0 2 * * * /caminho/para/backup-minio.sh
set -e
# ============ CONFIGURAÇÃO ============
BACKUP_DIR="/var/backups/minio" # Onde salvar os backups no servidor
RETENTION_DAYS=7 # Quantos dias manter
VOLUME_NAME="course-plat_minio_data" # Nome do volume Docker
COMPRESSION="gzip" # gzip ou none
# Criar diretório de backup se não existir
mkdir -p "$BACKUP_DIR"
# Data atual para o nome do arquivo
DATE=$(date +%Y%m%d-%H%M%S)
BACKUP_FILE="$BACKUP_DIR/minio-backup-$DATE.tar.gz"
echo "=========================================="
echo "Backup MinIO iniciado em: $(date)"
echo "=========================================="
# ============ FAZER BACKUP ============
echo "Criando backup do volume $VOLUME_NAME..."
if [ "$COMPRESSION" = "gzip" ]; then
docker run --rm \
-v "$VOLUME_NAME:/data:ro" \
-v "$BACKUP_DIR:/backup" \
alpine tar czf "/backup/$(basename "$BACKUP_FILE")" -C /data .
else
docker run --rm \
-v "$VOLUME_NAME:/data:ro" \
-v "$BACKUP_DIR:/backup" \
alpine tar cf "/backup/$(basename "$BACKUP_FILE")" -C /data .
fi
# ============ VERIFICAR ============
if [ -f "$BACKUP_FILE" ]; then
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
echo "✓ Backup criado: $BACKUP_FILE ($SIZE)"
else
echo "✗ ERRO: Backup não foi criado!"
exit 1
fi
# ============ LIMPAR BACKUPS ANTIGOS ============
echo "Limpando backups com mais de $RETENTION_DAYS dias..."
find "$BACKUP_DIR" -name "minio-backup-*.tar.gz" -mtime +$RETENTION_DAYS -delete
# ============ LISTAR BACKUPS RESTANTES ============
echo ""
echo "Backups disponíveis:"
ls -lh "$BACKUP_DIR"/minio-backup-*.tar.gz 2>/dev/null || echo "Nenhum backup encontrado."
echo ""
echo "=========================================="
echo "Backup concluído em: $(date)"
echo "=========================================="
+39
View File
@@ -0,0 +1,39 @@
services:
minio:
image: minio/minio:latest
container_name: course-plat-minio
restart: unless-stopped
ports:
- "9000:9000" # API
- "9001:9001" # Console (UI)
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# Opcional: cria bucket automaticamente ao iniciar
minio-init:
image: minio/mc:latest
container_name: course-plat-minio-init
depends_on:
- minio
entrypoint: >
sh -c "
sleep 5 &&
mc alias set myminio http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin} &&
mc mb myminio/${MINIO_BUCKET:-course-plat} --ignore-existing &&
mc anonymous set none myminio/${MINIO_BUCKET:-course-plat} &&
echo 'MinIO initialized! Bucket is private.'
"
restart: "no"
volumes:
minio_data:
driver: local
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# pull-backup.sh - Script para baixar backups do servidor para seu PC
# Uso: ./pull-backup.sh
# Agendar no crontab do SEU PC: 0 3 * * * /caminho/para/pull-backup.sh
set -e
# ============ CONFIGURAÇÃO ============
# Preencha com seus dados
SERVER_USER="usuario" # Usuário SSH no servidor
SERVER_HOST="seu-servidor.com" # IP ou domínio do servidor
SERVER_BACKUP_DIR="/var/backups/minio" # Mesmo diretório do backup-minio.sh
LOCAL_BACKUP_DIR="$HOME/backups/minio" # Onde salvar no seu PC
KEEP_LOCAL_DAYS=30 # Quanto tempo manter no seu PC
# ============ CÓDIGO ============
mkdir -p "$LOCAL_BACKUP_DIR"
echo "=========================================="
echo "Baixando backups do MinIO"
echo "De: $SERVER_USER@$SERVER_HOST:$SERVER_BACKUP_DIR"
echo "Para: $LOCAL_BACKUP_DIR"
echo "Iniciado em: $(date)"
echo "=========================================="
# Baixar backups (somente os que ainda não temos)
echo "Sincronizando arquivos..."
rsync -avz --progress \
-e "ssh -o StrictHostKeyChecking=no" \
"$SERVER_USER@$SERVER_HOST:$SERVER_BACKUP_DIR/" \
"$LOCAL_BACKUP_DIR/"
# ============ LIMPAR BACKUPS ANTIGOS (LOCAL) ============
echo ""
echo "Limpando backups locais com mais de $KEEP_LOCAL_DAYS dias..."
find "$LOCAL_BACKUP_DIR" -name "minio-backup-*.tar.gz" -mtime +$KEEP_LOCAL_DAYS -delete
# ============ RESUMO ============
echo ""
echo "=========================================="
echo "Backups locais:"
ls -lh "$LOCAL_BACKUP_DIR"/minio-backup-*.tar.gz 2>/dev/null | tail -5
echo ""
echo "Espaço usado:"
du -sh "$LOCAL_BACKUP_DIR"
echo ""
echo "Concluído em: $(date)"
echo "=========================================="
# ============ ALERTAS ============
BACKUP_COUNT=$(ls -1 "$LOCAL_BACKUP_DIR"/minio-backup-*.tar.gz 2>/dev/null | wc -l)
if [ "$BACKUP_COUNT" -eq 0 ]; then
echo "⚠️ ATENÇÃO: Nenhum backup encontrado localmente!"
fi
+55
View File
@@ -0,0 +1,55 @@
# Multi-stage Dockerfile para Produção
# Uso: docker build -f docker/production/Dockerfile -t course-plat .
FROM node:20-alpine AS base
# Instalar dependências apenas para produção
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copiar package files
COPY package.json package-lock.json* ./
RUN npm ci --only=production
# Build da aplicação
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build Next.js
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Imagem final de produção
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Criar usuário não-root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copiar arquivos necessários
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Criar diretório para storage local
RUN mkdir -p /app/storage/uploads && chown -R nextjs:nodejs /app/storage
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"]
+136
View File
@@ -0,0 +1,136 @@
#!/bin/bash
# ==========================================
# BACKUP MONGODB - PRODUÇÃO
# ==========================================
# Suporta MongoDB nativo ou Docker
# Uso: ./backup-mongodb.sh
#
# Adicionar ao crontab (deployuser):
# 0 2 * * * cd /var/www/iciv && ./docker/production/backup-mongodb.sh >> ~/backup-mongodb.log 2>&1
set -e
# ==========================================
# CONFIGURAÇÕES
# ==========================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups/mongodb}"
RETENTION_DAYS="${RETENTION_DAYS:-7}"
CONTAINER_NAME="${MONGO_CONTAINER:-course-plat-mongodb}"
ENV_FILE="${ENV_FILE:-$PROJECT_DIR/.env.production}"
if [ -f "$ENV_FILE" ]; then
set -a
source <(grep -v '^\s*#' "$ENV_FILE")
set +a
fi
MONGO_USERNAME="${MONGO_USERNAME:-admin}"
MONGO_PASSWORD="${MONGO_PASSWORD:-password}"
MONGO_DB="${MONGO_DB:-course-plat}"
# ==========================================
# FUNÇÕES
# ==========================================
log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1"
}
error() {
log "ERROR: $1" >&2
exit 1
}
build_mongo_uri() {
if [ -n "$MONGO_USERNAME" ] && [ -n "$MONGO_PASSWORD" ]; then
echo "mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@127.0.0.1:27017/${MONGO_DB}?authSource=admin"
else
echo "mongodb://127.0.0.1:27017/${MONGO_DB}"
fi
}
# ==========================================
# DETECÇÃO: Docker ou nativo
# ==========================================
DOCKER_CMD="$(command -v docker 2>/dev/null || true)"
USE_DOCKER=false
if [ -n "$DOCKER_CMD" ] && "$DOCKER_CMD" ps 2>/dev/null | grep -q "$CONTAINER_NAME"; then
USE_DOCKER=true
log "Modo: Docker (container: $CONTAINER_NAME)"
else
MONGODUMP="$(command -v mongodump 2>/dev/null || true)"
if [ -z "$MONGODUMP" ]; then
error "mongodump nao encontrado. Instale: sudo apt install mongo-tools"
fi
log "Modo: MongoDB nativo"
fi
# ==========================================
# INÍCIO
# ==========================================
log "Iniciando backup do MongoDB..."
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_FILE="$BACKUP_DIR/mongodb-$TIMESTAMP.gz"
MONGO_URI=$(build_mongo_uri)
log "Executando mongodump..."
if [ "$USE_DOCKER" = true ]; then
"$DOCKER_CMD" exec "$CONTAINER_NAME" mongodump \
--uri="$MONGO_URI" \
--archive \
--gzip \
> "$BACKUP_FILE" 2>&1 || error "Falha no mongodump (docker)"
else
"$MONGODUMP" \
--uri="$MONGO_URI" \
--archive="$BACKUP_FILE" \
--gzip \
2>&1 || error "Falha no mongodump"
fi
if [ ! -f "$BACKUP_FILE" ]; then
error "Arquivo de backup nao foi criado"
fi
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
log "Backup concluido: $BACKUP_FILE ($BACKUP_SIZE)"
# ==========================================
# LIMPEZA
# ==========================================
log "Removendo backups antigos (mais de $RETENTION_DAYS dias)..."
REMOVED=$(find "$BACKUP_DIR" \
-name "mongodb-*.gz" \
-type f \
-mtime +$RETENTION_DAYS \
-print -delete 2>/dev/null | wc -l)
log "Backups removidos: $REMOVED"
# ==========================================
# RESUMO
# ==========================================
TOTAL_BACKUPS=$(find "$BACKUP_DIR" -name "mongodb-*.gz" -type f | wc -l)
TOTAL_SIZE=$(du -sh "$BACKUP_DIR" 2>/dev/null | cut -f1)
log "Resumo:"
log " - Backup atual: $BACKUP_FILE"
log " - Total de backups: $TOTAL_BACKUPS"
log " - Tamanho total: $TOTAL_SIZE"
log "Backup do MongoDB concluido com sucesso!"
+125
View File
@@ -0,0 +1,125 @@
# Docker Compose para Produção
# Uso: docker compose -f docker/production/docker-compose.yml up -d
#
# Antes de usar:
# 1. Copiar .env.example para .env.production
# 2. Preencher todas as variáveis de ambiente
# 3. Buildar a imagem da aplicação: docker build -t course-plat .
# 4. Ou usar build automático com compose (descomentar build section)
services:
# Aplicação Next.js
app:
image: course-plat:latest
# Para build automático, descomente abaixo:
# build:
# context: ../..
# dockerfile: docker/production/Dockerfile
container_name: course-plat-app
restart: unless-stopped
ports:
- "${APP_PORT:-3000}:3000"
environment:
# Node
NODE_ENV: production
# Next.js
NEXTAUTH_URL: ${NEXTAUTH_URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
# MongoDB
MONGO_URI: mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@mongodb:27017/${MONGO_DB}?authSource=admin
# Storage
STORAGE_TYPE: ${STORAGE_TYPE:-s3}
LOCAL_STORAGE_PATH: /app/storage/uploads
S3_ENDPOINT: ${S3_ENDPOINT}
S3_REGION: ${S3_REGION:-us-east-1}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_BUCKET: ${S3_BUCKET}
volumes:
- app_storage:/app/storage/uploads
depends_on:
- mongodb
- minio
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
- course-plat-network
# MongoDB - Banco de dados
mongodb:
image: mongo:8
container_name: course-plat-mongodb
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
MONGO_INITDB_DATABASE: ${MONGO_DB}
volumes:
- mongodb_data:/data/db
- mongodb_config:/data/configdb
- ./backups/mongodb:/backups # Para restore
healthcheck:
test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet
interval: 30s
timeout: 10s
retries: 5
networks:
- course-plat-network
# MinIO - Armazenamento de arquivos
minio:
image: minio/minio:latest
container_name: course-plat-minio
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- minio_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
networks:
- course-plat-network
# Nginx (opcional) - Reverse Proxy
nginx:
image: nginx:alpine
container_name: course-plat-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
- nginx_logs:/var/log/nginx
depends_on:
- app
networks:
- course-plat-network
volumes:
mongodb_data:
driver: local
mongodb_config:
driver: local
minio_data:
driver: local
app_storage:
driver: local
nginx_logs:
driver: local
networks:
course-plat-network:
driver: bridge
+131
View File
@@ -0,0 +1,131 @@
# Nginx Reverse Proxy para Course Plat
# Uso: Copiar para docker/production/nginx.conf ou /etc/nginx/sites-available/course-plat
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/rss+xml font/truetype font/opentype
application/vnd.ms-fontobject image/svg+xml;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
# Upstream da aplicação
upstream app_backend {
server app:3000;
keepalive 32;
}
# HTTP -> HTTPS redirect (descomentar em prod com SSL)
# server {
# listen 80;
# server_name seu-dominio.com.br;
# return 301 https://$server_name$request_uri;
# }
# Server principal
server {
listen 80;
# listen 443 ssl http2; # Descomentar para SSL
server_name _;
# SSL Configuration (descomentar para production)
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Client body size limit (uploads)
client_max_body_size 500M;
# Proxy settings
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Next.js static files (com cache)
location /_next/static {
alias /var/www/.next/static;
expires 365d;
add_header Cache-Control "public, immutable";
}
location /static {
alias /var/www/public;
expires 30d;
add_header Cache-Control "public";
}
# API routes (sem cache, com rate limit)
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app_backend;
}
# Auth endpoints (rate limit mais estrito)
location /api/auth/ {
limit_req zone=login burst=5 nodelay;
proxy_pass http://app_backend;
}
# File downloads (proxy)
location /api/files/ {
proxy_pass http://app_backend;
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
# Todas as outras requests
location / {
proxy_pass http://app_backend;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# Health check
location /health {
access_log off;
proxy_pass http://app_backend/api/health;
}
}
}
+162
View File
@@ -0,0 +1,162 @@
# Auditoria de Seguranca - course-plat
**Data:** 2026-05-18
**Escopo:** Analise completa de autenticacao, autorizacao e vulnerabilidades de seguranca
---
## CRITICO (Exploracao Imediata Possivel)
### 1. Middleware de Protecao de Rotas INATIVO
**Arquivo:** `src/proxy.js`
O middleware existe mas **nao ha `src/middleware.js`** no projeto. O Next.js so reconhece middleware nos caminhos padrao (`src/middleware.js` ou `middleware.js` na raiz). Toda a protecao de rotas por pagina esta desativada. Qualquer usuario pode acessar `/admin/dashboard` diretamente sem login.
### 2. `updateUserData` - Escalacao de Privilegio TOTAL (sem auth)
**Arquivo:** `src/app/lib/users/updateUserAction.js:11`
- `"use server"` com ZERO verificacao de autenticacao
- Qualquer cliente (nao autenticado) pode modificar qualquer usuario, incluindo alterar o campo `roles` para `["admin"]`
- **Impacto:** Um atacante pode se conceder acesso admin completo
### 3. `examActions.js` - 18 funcoes SEM autenticacao
**Arquivo:** `src/app/lib/actions/examActions.js` (1102 linhas)
| Funcao | Linha | Impacto |
|--------|-------|---------|
| `getExamTemplates` | 82 | Vazar todas as questoes + respostas corretas de provas |
| `getExamTemplateById` | 104 | Ver gabarito completo de qualquer prova |
| `updateExamTemplate` | 155 | Alterar respostas corretas de qualquer prova |
| `deleteExamTemplate` | 184 | Deletar qualquer modelo de prova |
| `duplicateExamTemplate` | 218 | Duplicar e ver conteudo de qualquer prova |
| `getExamAssignments` | 256 | Ver todas as atribuicoes de prova |
| `getExamAssignmentById` | ~298 | Ver detalhes completos de atribuicao |
| `createExamAssignment` | ~330 | Criar atribuicoes para qualquer turma |
| `updateExamAssignment` | ~350 | Modificar atribuicoes de prova |
| `deleteExamAssignment` | ~370 | Deletar atribuicoes |
| `archiveExamAssignment` | ~390 | Arquivar/desarquivar |
| `getExamAttempts` | ~410 | Ver todas as respostas e notas de todos os alunos |
| `getExamAttemptById` | ~430 | Ver detalhes de qualquer tentativa |
| `createExamAttempt` | ~450 | Criar tentativas impersonando qualquer aluno |
| `submitExamAttempt` | ~470 | Submeter respostas por qualquer tentativa |
| `getExamStatistics` | ~500 | Ver estatisticas gerais |
| `getStudentExamStatistics` | ~520 | Ver historico de qualquer aluno |
| `getClasses` | ~540 | Listar todas as turmas |
### 4. Server Actions administrativas sem autenticacao
15 funcoes com `"use server"` e ZERO verificacao de login:
| Funcao | Arquivo | Impacto |
|--------|---------|---------|
| `updateUserData` | `users/updateUserAction.js` | Escalacao de privilegio para admin |
| `saveUserData` | `users/createWardUserAction.js` | Criar contas de aluno sem auth |
| `saveProductAction` | `products/actions.js` | Criar/modificar produtos e precos |
| `deleteProductAction` | `products/actions.js` | Deletar qualquer produto |
| `saveClassAction` | `classes/saveClassAction.js` | Criar/modificar turmas |
| `toggleClassStatus` | `classes/toggleClassStatus.js` | Arquivar/ativar qualquer turma |
| `deleteClass` | `classes/deleteClass.js` | Deletar qualquer turma |
| `deleteClassTypeAction` | `classes/deleteClassType.js` | Deletar tipo de turma |
| `saveClassLink` | `classes/saveClassLinkAction.js` | Alterar link de aula |
| `saveClassTypeAction` | `classes/saveClassTypeAction.js` | Criar/modificar tipos de turma |
| `saveCategoryAction` | `categories/saveCategoryAction.js` | Criar/modificar categorias |
| `deleteCategory` | `categories/deleteCategory.js` | Deletar categorias |
| `deleteFile` | `generalActions/deleteFile.js` | Deletar qualquer arquivo do DB e S3 |
| `updateExamTemplate` | `examActions.js` | Alterar gabaritos de provas |
| `submitExamAttempt` | `examActions.js` | Submeter respostas por qualquer tentativa |
---
## ALTO (Risco Significativo)
### 5. IDOR em `/api/orders/[id]`
**Arquivo:** `src/app/api/orders/[id]/route.js`
Qualquer usuario autenticado pode ver qualquer pedido por ID, sem verificacao de propriedade.
### 6. Vazamento de Informacoes em `/api/health`
**Arquivo:** `src/app/api/health/route.js` - Totalmente publico
Expoe: status do MongoDB, contagem de usuarios, tipo de storage, endpoint S3, bucket name, `NODE_ENV`, `NEXTAUTH_URL`.
### 7. Endpoint de Debug em Producao
**Arquivo:** `src/app/api/debug-token/route.js`
Qualquer usuario autenticado pode ver sessao completa, roles e status admin.
### 8. Aulas sem verificacao de membros
**Arquivo:** `src/app/api/classes/[id]/lessons/route.js` (GET)
Qualquer usuario autenticado pode ver aulas de qualquer turma, sem verificar se e membro.
### 9. Headers de Debug no Middleware
**Arquivo:** `src/proxy.js:92-96`
Headers `x-debug-roles`, `x-debug-is-logged-in`, `x-debug-default-route` vazam informacoes de autenticacao em toda resposta.
---
## MEDIO
### 10. Ausencia de Headers de Seguranca
**Arquivo:** `next.config.mjs`
Nenhum header de seguranca configurado:
- Sem `Content-Security-Policy`
- Sem `X-Frame-Options`
- Sem `X-Content-Type-Options`
- Sem `Strict-Transport-Security`
- Sem `Referrer-Policy`
### 11. Vazamento de Erros Internos
Multiplas funcoes retornam erros raw ao cliente: `Erro: ${err}`, `error.message` - podem vazar stack traces, detalhes de conexao DB, etc.
### 12. `console.log` com Dados Sensiveis
`examActions.js` usa `console.log` para imprimir respostas de alunos e dados de questoes no log do servidor.
### 13. Upload de Arquivo sem Restricao de Entidade
**Arquivo:** `src/app/lib/generalActions/saveFileAction.js`
Qualquer usuario autenticado pode vincular arquivos a qualquer entidade (qualquer turma, tipo de turma, etc.).
### 14. Limite de Upload Muito Alto
**Arquivo:** `next.config.mjs:11`
`bodySizeLimit: '200mb'` permite uploads de ate 200MB, facilitando ataques de negacao de servico.
### 15. Recibos como Base64 no MongoDB
`submitOrderPaymentAction` armazena recibos como data URI base64 diretamente no documento do banco, causando growth descontrolado do DB.
---
## Resumo Quantitativo
| Severidade | Quantidade | Descricao |
|------------|------------|-----------|
| CRITICO | 15 | Server actions sem autenticacao nenhuma |
| ALTO | 12 | Autenticado mas sem verificacao de role/autorizacao |
| MEDIO | 5 | Autenticado e com role mas outros problemas |
| OK | 27 | Protegido corretamente com auth + autorizacao |
| **Total de funcoes auditadas** | **68** | |
---
## Plano de Correcao (Prioridade Decrescente)
1. **Renomear `src/proxy.js` para `src/middleware.js`** - Ativa a protecao de rotas por pagina
2. **Adicionar `auth()` + verificacao de role em TODAS as 15 server actions criticas** - Usar o padrao de `requireRole("admin")` ou `requireAuth()` do `authorization.js`
3. **Refazer `examActions.js`** - Todas as funcoes precisam de auth. Leitura de templates nao deve retornar `isCorrect`. Considerar consolidar com `examFlowActions.js` (que ja tem seguranca correta)
4. **Adicionar verificacao de propriedade em `/api/orders/[id]`**
5. **Proteger `/api/health`** com autenticacao admin ou remover dados sensiveis
6. **Remover `/api/debug-token`** em producao
7. **Remover headers de debug** do middleware
8. **Adicionar headers de seguranca** no `next.config.mjs`
9. **Sanitizar mensagens de erro** antes de enviar ao cliente
10. **Remover `console.log`** com dados sensiveis
11. **Reduzir `bodySizeLimit`** para algo mais razoavel (ex: 10MB)
12. **Adicionar verificacao de membership em GET lessons**
---
## Comparativo: examActions.js vs examFlowActions.js
`examFlowActions.js` demonstra **praticas de seguranca corretas** (auth + role + class membership checks em todas as 6 funcoes), enquanto `examActions.js` (21 funcoes) tem **quase nenhuma autenticacao ou autorizacao**. Sugere-se que `examFlowActions.js` foi escrito depois como substituicao mais segura, mas as funcoes inseguras em `examActions.js` nunca foram removidas ou protegidas.
+240
View File
@@ -0,0 +1,240 @@
# Manual do Administrador
## Visão Geral
O painel de administrador permite gerenciar todos os aspectos da plataforma de cursos, incluindo usuários, turmas, provas, pagamentos e arquivos.
## Acesso
- **URL**: `/admin/dashboard`
- **Role Necessária**: `admin`
---
## Funcionalidades
### 1. Dashboard Principal
**Rota**: `/admin/dashboard`
Página inicial com cards de navegação para todas as funcionalidades administrativas.
---
### 2. Gerenciamento de Tipos de Turma
**Rota**: `/admin/dashboard/classTypes`
#### Funcionalidades
- Criar novos tipos de turma (ex: "Starters", "Movers")
- Definir faixa etária
- Definir preço mensal
- Gerenciar arquivos por tipo de turma
#### Como Testar
1. Acesse `/admin/dashboard/classTypes`
2. Clique em "Novo Tipo de Turma"
3. Preencha nome, idade mínima, idade máxima e preço
4. Salve
5. **Esperado**: Tipo de turma aparece na lista
#### Campos
- `name`: Nome do tipo de turma
- `minAge`: Idade mínima
- `maxAge`: Idade máxima
- `price`: Preço mensal
---
### 3. Gerenciamento de Turmas
**Rota**: `/admin/dashboard/class`
#### Funcionalidades
- Criar novas turmas
- Atribuir professores
- Definir horário e dias da semana
- Ativar/desativar turmas
- Gerenciar alunos matriculados
- Upload de materiais
#### Como Testar
1. Acesse `/admin/dashboard/class`
2. Clique em "Nova Turma"
3. Selecione o tipo de turma
4. Adicione professores (multi-select)
5. Defina os dias da semana
6. Salve
7. **Esperado**: Turma criada e visível na lista
#### Campos
- `classType`: Tipo de turma (obrigatório)
- `teachers`: Professores (pode ter vários)
- `schedule`: Horário das aulas
- `days`: Dias da semana
- `active`: Status ativo/inativo
---
### 4. Gerenciamento de Usuários
**Rota**: `/admin/dashboard/users`
#### Funcionalidades
- Visualizar todos os usuários
- Editar informações de usuários
- Gerenciar roles (admin, teacher, student, parent)
- Vincular responsáveis a estudantes
- Excluir usuários
#### Como Testar
1. Acesse `/admin/dashboard/users`
2. Clique no ícone de editar em um usuário
3. Altere as roles marcando/desmarcando checkboxes
4. Para vincular responsável, selecione na lista
5. Salve
6. **Esperado**: Roles atualizadas, vínculos criados
#### Roles Disponíveis
- `admin`: Acesso completo ao painel administrativo
- `teacher`: Acesso a turmas atribuídas
- `student`: Acesso a turmas matriculadas
- `parent`: Acesso a dados dos filhos vinculados
---
### 5. Gerenciamento de Pagamentos
**Rotas**:
- `/admin/dashboard/payments` - Visão geral
- `/admin/dashboard/payments/by-class` - Por turma
#### Funcionalidades
- Criar obrigações de pagamento para alunos
- Aprovar/rejeitar pagamentos
- Visualizar comprovantes
- Estatísticas de pagamentos
#### Como Testar - Criar Obrigação
1. Acesse `/admin/dashboard/payments`
2. Clique em "Nova Obrigação"
3. Selecione o aluno
4. Digite o valor e data de vencimento
5. Salve
6. **Esperado**: Obrigação aparece na lista do aluno
#### Como Testar - Aprovar Pagamento
1. Na lista de pagamentos, encontre um pendente
2. Clique para ver detalhes
3. Visualize o comprovante
4. Clique em "Aprovar" ou "Rejeitar"
5. **Esperado**: Status atualizado
#### Status de Pagamento
- `pending`: Aguardando aprovação
- `verified`: Pagamento aprovado
- `rejected`: Pagamento rejeitado
---
### 6. Provas e Tarefas
**Rotas**:
- `/admin/dashboard/exams` - Gerenciar provas
- `/admin/dashboard/exam-templates` - Templates de prova
- `/admin/dashboard/assignments` - Atribuir provas a turmas
- `/admin/dashboard/statistics/exams` - Estatísticas
#### Funcionalidades
- Criar templates de provas reutilizáveis
- Adicionar perguntas (múltipla escolha, texto)
- Atribuir provas a turmas
- Definir data/hora de início e fim
- Configurar tentativas permitidas
- Ver resultados e estatísticas
#### Como Testar - Criar Template
1. Acesse `/admin/dashboard/exam-templates`
2. Clique em "Novo Template"
3. Digite título e descrição
4. Adicione perguntas:
- Pergunta de múltipla escolha: adicione opções e marque a correta
- Pergunta de texto: apenas o enunciado
5. Salve
6. **Esperado**: Template criado
#### Como Testar - Atribuir Prova
1. Acesse `/admin/dashboard/assignments`
2. Clique em "Nova Atribuição"
3. Selecione o template
4. Selecione a turma
5. Defina data/hora início e fim
6. Configure tentativas permitidas
7. Salve
8. **Esperado**: Prova aparece para os alunos da turma
---
### 7. Categorias de Arquivos
**Rota**: `/admin/dashboard/categories`
#### Funcionalidades
- Criar categorias para organizar arquivos
- Ex: "Material de Aula", "Lição de Casa", "Provas Antigas"
#### Como Testar
1. Acesse `/admin/dashboard/categories`
2. Clique em "Nova Categoria"
3. Digite o nome
4. Salve
5. **Esperado**: Categoria disponível ao fazer upload de arquivos
---
### 8. Gerenciamento de Arquivos
**Rotas**:
- `/admin/dashboard/files` - Lista de arquivos
- `/admin/dashboard/files/add` - Upload de arquivo
- `/admin/dashboard/files/[id]` - Editar arquivo
#### Funcionalidades
- Fazer upload de arquivos
- Associar a turmas ou tipos de turma
- Categorizar arquivos
- Gerenciar permissões
#### Como Testar
1. Acesse `/admin/dashboard/files/add`
2. Digite o nome do arquivo
3. Selecione o arquivo no computador
4. Selecione a categoria
5. Associe a uma turma ou tipo de turma (opcional)
6. Salve
7. **Esperado**: Arquivo disponível para download na turma
---
## Checklist de Testes
- [ ] Criar tipo de turma
- [ ] Criar turma com professores
- [ ] Criar usuário com role student
- [ ] Criar usuário com role teacher
- [ ] Vincular responsável a estudante
- [ ] Matricular estudante em turma
- [ ] Criar obrigação de pagamento
- [ ] Aprovar pagamento
- [ ] Criar template de prova
- [ ] Atribuir prova a turma
- [ ] Fazer upload de material para turma
---
## Possíveis Problemas
### Usuário não acessa o admin
- **Verifique**: O usuário tem a role `admin`?
- **Solução**: Edite o usuário em `/admin/dashboard/users`
### Turma não aparece para o professor
- **Verifique**: O professor está atribuído à turma?
- **Solução**: Edite a turma em `/admin/dashboard/class`
### Aluno não vê a prova
- **Verifique**: Data/hora da atribuição está correta?
- **Verifique**: Aluno está matriculado na turma?
- **Solução**: Verifique a atribuição em `/admin/dashboard/assignments`
+100
View File
@@ -0,0 +1,100 @@
# Teste Manual - Link da Aula (Professor, Estudante e Responsavel)
## Objetivo
Validar o fluxo completo de cadastro e visualizacao do campo **Link da Aula**:
- Professor registra aula com link
- Estudante visualiza e acessa o link no historico
- Responsavel visualiza e acessa o link no historico do estudante
## Pre-requisitos
- Aplicacao rodando localmente (ex: `http://localhost:3000`)
- 1 usuario com role `teacher`
- 1 usuario com role `student` matriculado na mesma turma do professor
- 1 usuario com role `guardian` vinculado ao estudante
- ID de turma valido (exemplo: `/dashboard/teacher/class/<classId>`)
- URL de teste para usar como link da aula (ex: `https://example.com/aula-01`)
## Cenário 1 - Professor registra aula com link
1. Fazer login como professor.
2. Acessar a turma: `/dashboard/teacher/class/<classId>`.
3. Clicar em **Registrar Aula**.
4. Preencher os campos obrigatorios:
- Data da aula
- Topico/Assunto
- Presenca (qualquer combinacao valida)
5. Preencher **Link da Aula** com URL valida (ex: `https://example.com/aula-01`).
6. Clicar em **Salvar**.
7. Acessar **Historico** da turma: `/dashboard/teacher/class/<classId>/history`.
### Resultado esperado
- Aula criada com sucesso.
- No card da aula no historico deve aparecer o botao/link **Abrir link da aula**.
- Ao clicar, o link abre em nova aba.
## Cenário 2 - Professor edita aula e atualiza/remove link
1. No historico da turma, abrir a aula registrada.
2. Editar a aula (fluxo de edicao ja existente).
3. Alterar o valor de **Link da Aula** para outra URL valida.
4. Salvar e validar no historico.
5. Editar novamente e limpar o campo **Link da Aula**.
6. Salvar.
### Resultado esperado
- Ao atualizar, o novo link passa a ser exibido no historico.
- Ao remover (campo vazio), o botao/link **Abrir link da aula** nao deve mais aparecer.
## Cenário 3 - Estudante visualiza link no historico
1. Fazer logout do professor e login como estudante da mesma turma.
2. Acessar: `/dashboard/student/class/<classId>/history`.
3. Localizar a aula registrada no Cenario 1.
4. Clicar em **Abrir link da aula**.
### Resultado esperado
- O link da aula aparece no card quando cadastrado pelo professor.
- O clique abre a URL em nova aba.
- Se o professor removeu o link (Cenario 2), o estudante nao deve ver o botao/link.
## Cenário 4 - Responsavel visualiza link no historico do estudante
1. Fazer logout do estudante e login como responsavel vinculado.
2. Acessar estudante e turma:
- `/dashboard/guardian/<studentId>/class/<classId>/history`
3. Localizar a aula registrada.
4. Clicar em **Abrir link da aula**.
### Resultado esperado
- O responsavel ve o mesmo link da aula registrado pelo professor.
- O clique abre a URL em nova aba.
- Se o link foi removido, o responsavel nao deve ver o botao/link.
## Cenário 5 - Validacao de URL invalida no formulario
1. Login como professor.
2. Abrir **Registrar Aula**.
3. Preencher `Link da Aula` com valor invalido (ex: `meu-link`).
4. Tentar salvar.
### Resultado esperado
- O navegador deve bloquear o envio por ser `type="url"` (ou indicar formato invalido).
- Aula nao deve ser salva ate corrigir para URL valida.
## Checklist rapido
- [ ] Professor consegue criar aula com link
- [ ] Link aparece no historico do professor
- [ ] Professor consegue atualizar link
- [ ] Professor consegue remover link
- [ ] Estudante visualiza link quando existir
- [ ] Responsavel visualiza link quando existir
- [ ] Link abre em nova aba para os 3 perfis
- [ ] URL invalida nao permite envio do formulario
+278
View File
@@ -0,0 +1,278 @@
# Manual do Responsável
## Visão Geral
O painel do responsável permite gerenciar os estudantes vinculados (filhos), acompanhar seu progresso, acessar materiais e gerenciar pagamentos.
## Acesso
- **URL**: `/dashboard/guardian`
- **Role Necessária**: `parent` ou `guardian`
---
## Funcionalidades
### 1. Dashboard Principal
**Rota**: `/dashboard/guardian`
Visão geral de todos os estudantes vinculados.
#### Informações Exibidas
- Número total de estudantes
- Turmas ativas
- Total de turmas
#### Lista de Estudantes
Cards com:
- Nome do estudante
- Nome de usuário
- Número de turmas
- Link para dashboard individual
#### Como Testar
1. Faça login como responsável
2. Acesse `/dashboard/guardian`
3. **Esperado**: Ver todos os estudantes vinculados
---
### 2. Dashboard Individual do Estudante
**Rota**: `/dashboard/guardian/[id]`
Página dedicada a um estudante específico.
#### Informações do Estudante
- Nome completo
- Nome de usuário
- Email
- Data de nascimento
#### Turmas Ativas
Cards das turmas em que o estudante está matriculado:
- Nome da turma
- Professores
- Horário
- Status
#### Turmas Arquivadas
Turmas inativas ou concluídas.
#### Como Testar
1. No dashboard principal, clique em um estudante
2. **Esperado**: Ver informações completas do estudante e suas turmas
---
### 3. Detalhes da Turma do Estudante
**Rota**: `/dashboard/guardian/[id]/class/[classId]`
Visão detalhada de uma turma específica do estudante.
#### Informações da Turma
- Nome
- Professores
- Horário e dias
- Status
#### Estatísticas do Estudante
- Taxa de presença (%)
- Total de aulas
- Presentes, atrasados, ausentes
#### Materiais Disponíveis
Arquivos organizados por categoria para download.
#### Como Testar - Acessar Turma
1. No dashboard do estudante, clique em uma turma
2. **Esperado**: Ver detalhes da turma e materiais
#### Como Testar - Baixar Material
1. Na seção "Materiais", clique em um arquivo
2. **Esperado**: Download iniciado
---
### 4. Histórico de Aulas
**Rota**: `/dashboard/guardian/[id]/class/[classId]/history`
Histórico de presença do estudante em uma turma.
#### Informações por Aula
- Data
- Conteúdo/tema
- Professor
- Status de presença do estudante
#### Status de Presença
- `Presente`: Estudante presente
- `Atrasado`: Estudante chegou atrasado
- `Ausente`: Estudante faltou
- `Justificado`: Falta justificada
#### Como Testar
1. Acesse uma turma do estudante
2. Clique em "Histórico"
3. **Esperado**: Ver todas as aulas com presença do estudante
---
### 5. Registrar Novo Estudante
**Rota**: `/dashboard/guardian/register-student`
Formulário para cadastrar um novo estudante vinculado ao responsável.
#### Campos Obrigatórios
- Nome completo
- Nome de usuário
- Data de nascimento
- Senha
#### Regras
- Estudante deve ter menos de 18 anos
- Senha deve ter no mínimo 8 caracteres
- Estudante é automaticamente vinculado ao responsável
#### Como Testar
1. Acesse `/dashboard/guardian`
2. Clique em "Registrar Novo Estudante"
3. Preencha todos os campos
4. Clique em "Registrar"
5. **Esperado**: Estudante criado e vinculado automaticamente
---
### 6. Gerenciamento de Pagamentos
**Rota**: `/dashboard/guardian/payments`
Gerenciar pagamentos de todos os estudantes vinculados.
#### Funcionalidades
- Ver obrigações de todos os estudantes
- Filtrar por estudante específico
- Registrar pagamentos
- Fazer upload de comprovantes
- Ver histórico completo
- Visualizar comprovantes
#### Como Testar - Registrar Pagamento
1. Acesse `/dashboard/guardian/payments`
2. Na seção "Obrigações", encontre uma pendente
3. Clique para registrar pagamento
4. Preencha:
- Valor (preenchido automaticamente da obrigação)
- Data do pagamento
- Método (PIX, transferência, dinheiro, cartão)
- Nome do pagador (opcional)
- Upload de comprovante (opcional)
- Observações (opcional)
5. Salve
6. **Esperado**: Pagamento registrado com status "pendente"
#### Abas de Navegação
- **Todos**: Ver pagamentos de todos os estudantes
- **Por Estudante**: Filtrar por estudante específico
#### Status de Pagamento
- `pending`: Aguardando aprovação
- `verified`: Pagamento aprovado
- `rejected`: Pagamento rejeitado
#### Métodos de Pagamento
- `pix`: PIX
- `transferência`: Transferência bancária
- `dinheiro`: Dinheiro
- `cartão_crédito`: Cartão de crédito
- `cartão_débito`: Cartão de débito
---
### 7. Registro de Pagamento Específico
**Rota**: `/dashboard/guardian/payments/register`
Formulário para registrar um pagamento a partir de uma obrigação específica.
#### Diferença para a Página Principal
- Campo de valor já vem preenchido
- Estudante e turma já estão selecionados
- Fluxo mais rápido para pagamentos individuais
#### Como Testar
1. A partir de uma obrigação, clique em registrar
2. Preencha os campos restantes
3. Salve
4. **Esperado**: Pagamento registrado
---
## Fluxo de Uso Típico
### Acompanhamento Diário
1. Acesse `/dashboard/guardian`
2. Clique em um estudante
3. Verifique as turmas e atividades recentes
### Acompanhamento de Pagamentos
1. Acesse `/dashboard/guardian/payments`
2. Verifique obrigações pendentes
3. Registre os pagamentos feitos
4. Aguarde aprovação
### Acompanhamento Acadêmico
1. Acesse um estudante
2. Entre em uma turma
3. Baixe os materiais para acompanhar
4. Verifique o histórico de presença
---
## Checklist de Testes
- [ ] Ver lista de estudantes vinculados
- [ ] Acessar dashboard de um estudante
- [ ] Ver turmas de um estudante
- [ ] Acessar detalhes de uma turma
- [ ] Baixar material da turma
- [ ] Ver histórico de presença
- [ ] Registrar novo estudante
- [ ] Registrar pagamento para um estudante
- [ ] Ver histórico de pagamentos
---
## Possíveis Problemas
### Não vejo um estudante
- **Verifique**: O estudante está vinculado à sua conta?
- **Solução**: Entre em contato com o admin
### Não consigo registrar estudante
- **Verifique**: A data de nascimento indica menor de 18 anos?
- **Solução**: Estudantes maiores de idade devem se cadastrar sozinhos
### Pagamento não é aprovado
- **Verifique**: O comprovante foi anexado corretamente?
- **Solução**: Entre em contato com o admin
### Não vejo todas as turmas do estudante
- **Possível causa**: Algumas turmas podem estar arquivadas
- **Solução**: Verifique na seção "Turmas Arquivadas"
---
## Relação Responsável-Estudante
### Como Funciona o Vínculo
- Estudantes têm um campo `guardiansAccounts` com IDs dos responsáveis
- Responsáveis têm um campo `wardAccounts` com IDs dos estudantes
- A relação é bidirecional
### Criando Vínculo (Admin)
1. Acesse `/admin/dashboard/users`
2. Edite o estudante
3. Selecione o responsável na lista
4. Salve
### Criando Vínculo (Auto-registro)
- Ao registrar um estudante pelo dashboard do responsável, o vínculo é automático
- Isso só funciona para menores de 18 anos
+248
View File
@@ -0,0 +1,248 @@
# Manual do Estudante
## Visão Geral
O painel do estudante permite visualizar turmas, acessar materiais, fazer provas, registrar presença e gerenciar pagamentos.
## Acesso
- **URL**: `/dashboard/student`
- **Role Necessária**: `student`
---
## Funcionalidades
### 1. Dashboard Principal
**Rota**: `/dashboard/student`
Lista de todas as turmas matriculadas com cards mostrando:
- Nome da turma
- Professores
- Horário
- Status
#### Como Testar
1. Faça login como estudante
2. Acesse `/dashboard/student`
3. **Esperado**: Ver todas as turmas matriculadas
---
### 2. Detalhes da Turma
**Rota**: `/dashboard/student/class/[id]`
Página principal de uma turma específica.
#### Informações Exibidas
- Nome da turma
- Professores
- Horário e dias
- Status
#### Estatísticas
- Taxa de presença (%)
- Total de aulas
- Presentes, atrasados, ausentes
#### Materiais Disponíveis
Arquivos organizados por categoria com opção de download.
#### Provas Disponíveis
Lista de provas para fazer com:
- Data limite
- Tempo disponível
- Botão para iniciar
#### Feed de Atividades Recentes
Mostra as últimas atividades da turma.
#### Como Testar - Acessar Turma
1. No dashboard, clique em uma turma
2. **Esperado**: Ver detalhes completos da turma
#### Como Testar - Baixar Material
1. Acesse uma turma
2. Na seção "Materiais", clique em um arquivo
3. **Esperado**: Download do arquivo iniciado
---
### 3. Histórico da Turma
**Rota**: `/dashboard/student/class/[id]/history`
Lista de todas as aulas com:
- Data
- Conteúdo/tema
- Professor
- Status de presença do aluno
#### Status de Presença
- `Presente`: Aluno presente
- `Atrasado`: Aluno chegou atrasado
- `Ausente`: Aluno faltou
- `Justificado`: Falta justificada
#### Como Testar
1. Acesse uma turma
2. Clique em "Histórico"
3. **Esperado**: Ver todas as aulas com sua presença
---
### 4. Fazendo Provas
**Rota**: `/dashboard/student/assignments/[id]/take`
Interface para fazer provas online.
#### Funcionalidades
- Timer com contagem regressiva
- Perguntas de múltipla escolha e texto
- Barra de progresso
- Salvar automaticamente
- Entender automaticamente ao acabar o tempo
#### Como Testar
1. Acesse uma turma
2. Na seção "Provas Disponíveis", clique em "Iniciar"
3. Responda às perguntas
4. Clique em "Entregar" ou aguarde o tempo acabar
5. **Esperado**: Prova entregue, redirecionado para resultados
#### Importante
- O timer não para se você sair da página
- Após o tempo acabar, a prova é entregue automaticamente
- Você pode ver suas respostas antes de entregar
---
### 5. Resultados de Provas
**Rota**: `/dashboard/student/assignments/[id]/results`
Ver resultados das provas feitas.
#### Informações Exibidas
- Nota final
- Tempo gasto
- Tentativas anteriores
- Feedback por pergunta (se disponível)
- Opção de tentar novamente (se permitido)
#### Como Testar
1. Após fazer uma prova, você será redirecionado para resultados
2. Ou acesse através da turma > Provas
3. **Esperado**: Ver nota, respostas e feedback
---
### 6. Pagamentos
**Rota**: `/dashboard/student/payments`
Gerenciar obrigações de pagamento e histórico.
#### Funcionalidades
- Ver obrigações pendentes
- Registrar novos pagamentos
- Ver histórico de pagamentos
- Fazer upload de comprovantes
- Ver status (pendente, aprovado, rejeitado)
#### Como Testar - Registrar Pagamento
1. Acesse `/dashboard/student/payments`
2. Na seção "Obrigações", clique em uma pendente
3. Preencha:
- Data do pagamento
- Método (PIX, transferência, dinheiro, cartão)
- Nome do pagador (opcional)
- Upload de comprovante (opcional)
4. Salve
5. **Esperado**: Pagamento registrado com status "pendente"
#### Métodos de Pagamento
- `pix`: PIX
- `transferência`: Transferência bancária
- `dinheiro`: Dinheiro
- `cartão_crédito`: Cartão de crédito
- `cartão_débito`: Cartão de débito
#### Status
- `pending`: Aguardando aprovação
- `verified`: Pagamento aprovado
- `rejected`: Pagamento rejeitado
---
### 7. Perfil
**Rota**: `/profile`
Editar informações pessoais.
#### Informações Editáveis
- Nome completo
- Nome de usuário
- Email
- Data de nascimento
#### Informações Somente Leitura
- Roles (funções)
- Responsáveis (se menor de idade)
- Estudantes sob responsabilidade (se responsável)
#### Como Testar
1. Acesse `/profile`
2. Altere qualquer campo permitido
3. Clique em "Salvar Alterações"
4. **Esperado**: Dados atualizados, mensagem de sucesso
#### Restrições para Menores de Idade
- Estudantes menores de 18 não podem remover responsáveis existentes
- Podem apenas ver quais responsáveis estão vinculados
---
## Fluxo de Uso Típico
### Dia a Dia
1. **Antes da aula**: Acesse a turma e baixe o material
2. **Durante o curso**: Faça as provas disponíveis antes da data limite
3. **Após provas**: Acompanhe seus resultados
### Mensalmente
1. Acesse `/dashboard/student/payments`
2. Verifique obrigações pendentes
3. Registre os pagamentos feitos
4. Aguarde aprovação
---
## Checklist de Testes
- [ ] Ver lista de turmas matriculadas
- [ ] Acessar detalhes de uma turma
- [ ] Baixar material da turma
- [ ] Ver histórico de presença
- [ ] Fazer uma prova
- [ ] Ver resultados de prova
- [ ] Registrar um pagamento
- [ ] Editar perfil
---
## Possíveis Problemas
### Não vejo uma turma
- **Verifique**: Você está matriculado nessa turma?
- **Solução**: Entre em contato com o admin
### Prova não aparece
- **Verifique**: A data/hora da prova está dentro do período?
- **Verifique**: Você já tentou o número máximo de vezes?
- **Solução**: Verifique com o professor
### Pagamento não é aprovado
- **Verifique**: O comprovante foi anexado corretamente?
- **Solução**: Entre em contato com o admin
### Não consigo editar responsável
- **Possível causa**: Você tem 18 anos ou mais
- **Solução**: Adultos não podem ter novos responsáveis, apenas remover existentes
+187
View File
@@ -0,0 +1,187 @@
# Manual do Professor
## Visão Geral
O painel do professor permite gerenciar turmas, fazer upload de materiais, registrar presença e criar/corrigir provas.
## Acesso
- **URL**: `/dashboard/teacher`
- **Role Necessária**: `teacher`
---
## Funcionalidades
### 1. Dashboard Principal
**Rota**: `/dashboard/teacher`
Lista de todas as turmas atribuídas ao professor com cards mostrando:
- Nome da turma
- Número de alunos
- Horário
#### Como Testar
1. Faça login como professor
2. Acesse `/dashboard/teacher`
3. **Esperado**: Ver todas as turmas atribuídas
---
### 2. Detalhes da Turma
**Rota**: `/dashboard/teacher/class/[id]`
Página principal de gerenciamento de uma turma específica.
#### Informações Exibidas
- Nome da turma
- Professores atribuídos
- Horário e dias
- Status (ativo/inativo)
- Estatísticas: número de alunos, taxa de presença, média das notas
#### Lista de Alunos
Tabela com:
- Nome do aluno
- Taxa de presença
- Média das provas
- Status
#### Ações Disponíveis
- **Upload de Materiais**: Adicionar arquivos para a turma
- **Registrar Aula**: Registrar presença e conteúdo da aula
- **Ver Histórico**: Ver todas as aulas anteriores
- **Copiar Link**: Copiar link da aula virtual
#### Como Testar - Upload de Material
1. Acesse uma turma
2. Clique em "Upload de Materiais"
3. Digite o nome do arquivo
4. Selecione o arquivo no computador
5. Selecione a categoria
6. Salve
7. **Esperado**: Arquivo aparece na seção de materiais
#### Como Testar - Registrar Aula
1. Acesse uma turma
2. Clique em "Registrar Aula"
3. Digite o tema/conteúdo da aula
4. Selecione a data
5. Marque a presença dos alunos (Presente, Atrasado, Ausente, Justificado)
6. Salve
7. **Esperado**: Aula registrada no histórico
---
### 3. Histórico da Turma
**Rota**: `/dashboard/teacher/class/[id]/history`
Lista de todas as aulas registradas com:
- Data da aula
- Conteúdo/tema
- Professor que registrou
- Resumo de presença
#### Como Testar
1. Acesse uma turma
2. Clique em "Ver Histórico"
3. **Esperado**: Ver todas as aulas com presença registrada
---
### 4. Templates de Provas
**Rota**: `/dashboard/teacher/exam-templates`
Gerenciar templates de provas reutilizáveis.
#### Funcionalidades
- Criar novo template
- Editar templates existentes
- Adicionar perguntas (múltipla escolha ou texto)
- Definir como público ou privado
#### Como Testar - Criar Template
1. Acesse `/dashboard/teacher/exam-templates`
2. Clique em "Novo Template"
3. Digite título e descrição
4. Clique em "Adicionar Pergunta"
5. Selecione o tipo:
- **Múltipla Escolha**: Digite a pergunta, adicione opções, marque a correta
- **Texto**: Digite apenas a pergunta
6. Salve
7. **Esperado**: Template criado e disponível para atribuir
---
### 5. Resultados de Provas
**Rota**: `/dashboard/teacher/assignments/[id]/results`
Ver e corrigir provas dos alunos.
#### Funcionalidades
- Ver tentativas dos alunos
- Corrigir provas manualmente
- Ver notas automáticas (para múltipla escolha)
- Acompanhar status de correção
#### Status de Correção
- `pending`: Aguardando correção
- `auto-graded`: Corrigido automaticamente
- `partially-graded`: Parcialmente corrigido
- `fully-graded`: Totalmente corrigido
#### Como Testar
1. Acesse os resultados de uma atribuição
2. Clique em uma tentativa de aluno
3. Para perguntas de texto, digite a nota e comentários
4. Salve
5. **Esperado**: Nota atribuída, status atualizado
---
## Fluxo de Trabalho Típico
### Preparar uma Turma
1. Acesse a turma
2. Faça upload dos materiais da primeira aula
3. Verifique a lista de alunos
### Durante o Curso
1. **Antes de cada aula**: Faça upload do material
2. **Após cada aula**: Registre a presença e o conteúdo
3. **Periodicamente**: Crie e atribua provas
### Criar uma Prova
1. Crie o template em `/dashboard/teacher/exam-templates`
2. Atribua à turma (isso pode precisar de intervenção do admin)
3. Acompanhe os resultados em `/dashboard/teacher/assignments/[id]/results`
---
## Checklist de Testes
- [ ] Ver lista de turmas atribuídas
- [ ] Acessar detalhes de uma turma
- [ ] Fazer upload de material
- [ ] Registrar uma aula com presença
- [ ] Ver histórico de aulas
- [ ] Criar template de prova
- [ ] Ver resultados de prova
- [ ] Corrigir prova manualmente
---
## Possíveis Problemas
### Não consigo ver uma turma
- **Verifique**: Você está atribuído a essa turma?
- **Solução**: Entre em contato com o admin
### Não consigo atribuir prova à turma
- **Possível causa**: Essa funcionalidade pode estar apenas no admin
- **Solução**: Peça ao admin para atribuir a prova
### Material não aparece para os alunos
- **Verifique**: O upload foi concluído com sucesso?
- **Verifique**: A turma está correta?
- **Solução**: Tente fazer upload novamente
+25
View File
@@ -0,0 +1,25 @@
module.exports = {
apps: [
{
name: "iciv",
script: ".next/standalone/server.js",
instances: 4,
exec_mode: "cluster",
env: {
NODE_ENV: "production",
NEXTAUTH_SECRET: "ARcy7zqFmEXBStnMf6T8uVWyvHfKcFdfCgjVzGjFCZo=",
MONGODB_URI: "mongodb://localhost:27017/course-plat",
STORAGE_TYPE: "local",
PORT: 3000,
NEXTAUTH_URL: "https://inglescomideiasvivas.com.br",
},
error_file: "/var/log/iciv/err.log",
out_file: "/var/log/iciv/out.log",
merge_logs: true,
time: true,
max_memory_restart: "700M",
kill_timeout: 5000,
listen_timeout: 5000,
},
],
};
+14
View File
@@ -0,0 +1,14 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [...compat.extends("next/core-web-vitals")];
export default eslintConfig;
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@/auth": ["./src/app/lib/utils/auth.js"]
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: '500mb',
},
},
// --- ADICIONE ESTA SEÇÃO ---
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'inglescomideiasvivas.com.br',
port: '',
pathname: '/**',
},
],
},
// ---------------------------
outputFileTracingRoot: __dirname,
output: process.env.NODE_ENV === 'production' ? 'standalone' : undefined,
};
export default nextConfig;
+8081
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"name": "course-plat",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
"start": "node .next/standalone/server.js",
"lint": "next lint",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed",
"test:debug": "playwright test --debug",
"test:report": "playwright show-report"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.665.0",
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.2.0",
"@next/env": "^16.0.0",
"bcryptjs": "^3.0.2",
"date-fns": "^4.1.0",
"dotenv": "^17.4.2",
"mongoose": "^8.16.0",
"next": "^16.0.0",
"next-auth": "^5.0.0-beta.29",
"prop-types": "^15.8.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-icons": "^5.5.0",
"react-select": "^5.10.2",
"zod": "^4.0.16"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@playwright/test": "^1.58.1",
"@tailwindcss/postcss": "^4",
"eslint": "^9",
"eslint-config-next": "^16.0.0",
"tailwindcss": "^4"
},
"overrides": {
"brace-expansion": "^5.0.9",
"postcss": "^8.5.18",
"sharp": "^0.35.3"
}
}
+217
View File
@@ -0,0 +1,217 @@
# Plano de Responsividade Mobile + Reutilizacao de Codigo
## Breakpoints alvo
| Nome | Largura | Dispositivo |
|-------|---------|--------------------------------|
| xs | < 640px | iPhone SE, pequenos |
| sm | 640px+ | Smartphones grandes |
| md | 768px+ | Tablets retrato |
| lg | 1024px+ | Tablets paisagem / laptops |
---
## Fase 0: Extrair componentes base (reutilizacao)
Antes de aplicar responsividade, consolidar codigo duplicado.
### 0.1 Componentes novos a criar
| Componente | Substitui | Arquivos afetados |
|----------------------|------------------------------------------------|-------------------|
| `Modal` | 11 copias do overlay `fixed inset-0 bg-black/50` | 11 arquivos |
| `TableWrapper` | 9 wrappers identicos de tabela admin | 9 arquivos |
| `MaterialsList` | Listagem de materiais nos 3 ClassDetail | 3 arquivos |
| `ClassHistoryList` | 3 versoes (teacher/student/guardian) | 3 arquivos |
| `PaymentForm` unificado | 2 PaymentForms 98% identicos | 2 arquivos |
| `FormField` | Padrao label+input+helper repetido em 4 forms | 4 arquivos |
| `Spinner` | SVG de loading copiado em 2+ arquivos | 2+ arquivos |
### 0.2 Componentes existentes nao utilizados
| Componente existente | Redefinido inline em | Acao |
|---------------------------------------------------|-------------------------------------|---------------------------|
| `Card` (`dashboard/components/Card.jsx`) | 20 ocorrencias em 10 arquivos | Importar em vez de inline |
| `Stat` (`dashboard/components/Stat.jsx`) | Redefinido localmente em 2 arquivos | Importar |
| `Row` (`dashboard/components/Row.jsx`) | Redefinido em `StudentClassDetail` | Importar |
| `Button` (`shared/Button.jsx`) | Redefinido em `StudentClassDetail` | Importar |
| `DashboardLayout` | Existe mas zero paginas usam | Adotar ou remover |
| `dateUtils.js` | `formatDateBR` redefinido em 12 arquivos (22 instancias) | Importar |
### 0.3 Funcoes utilitarias a centralizar
| Funcao | Onde centralizar | Redefinida em |
|--------------------|------------------------------------|---------------------|
| `formatDateBR` | `@/app/lib/utils/dateUtils.js` | 12 arquivos |
| `formatTime` | `@/app/lib/utils/dateUtils.js` | 2 ExamResults |
| `normalizeStatus` | `@/app/lib/utils/statusPatterns.js`| 5 arquivos |
---
## Fase 1: Layout responsivo global
### 1.1 Admin Sidebar -- Drawer off-canvas
**Arquivos**: `SideBarNav.jsx:7`, `admin/layout.jsx:22-28`
**Problema**: Sidebar fixa `w-64` (256px) visivel em todas as telas. Em celulares de 375px, sobram ~119px para conteudo.
**Solucao**:
- Criar `MobileSidebarToggle` (hamburger button) visivel apenas em `md:hidden`
- Sidebar: `hidden md:flex` + state para abrir/fechar no mobile com overlay
- Usar `@headlessui/react` (ja no projeto) para o Dialog/Drawer
- Drawer aberto: largura `w-72` + overlay escuro
- Animação de slide-in/slide-out
### 1.2 Page container padronizado
**Problema**: 14+ paginas com container `max-w-6xl mx-auto p-6` hardcoded de formas inconsistentes.
**Solucao**: Padronizar usando `MainSection` ou `DashboardLayout` com padding responsivo:
```
px-4 sm:px-6 lg:px-8 py-4 sm:py-6
```
| Pagina | Container atual | Correcao |
|----------------------------|----------------------------------|----------------------------|
| `student/StudentClassDetail` | `max-w-6xl p-6` (sem mx-auto) | Adicionar `mx-auto` |
| `guardian/GuardianClassDetail` | `w-full p-6` | `max-w-6xl mx-auto p-4 sm:p-6` |
| `guardian/GuardianClassHistoryList` | `w-full` sem padding | Adicionar `px-4 sm:px-6` |
| Todas as outras | Variacoes de `max-w-6xl mx-auto p-6` | Padronizar via `MainSection` |
### 1.3 `PageHeader` -- actions com wrap
**Arquivo**: `shared/PageHeader.jsx:29`
**Correcao**: Adicionar `flex-wrap` no div de actions. Resolve ~9 paginas de uma vez.
---
## Fase 2: Componentes responsivos (1 correcao aplica-se a N paginas)
### 2.1 `Modal` (novo componente)
```
max-w-[95vw] sm:max-w-2xl md:max-w-4xl
```
+ padding responsivo `p-4 sm:p-6`
**Afeta**: 11 modais
### 2.2 `TableWrapper` (novo componente)
- `overflow-x-auto` por padrao
- Borda e estilo consistentes
**Afeta**: 9 tabelas admin
### 2.3 `MainSection` -- padding responsivo
```
p-4 sm:p-6
```
**Afeta**: todas as paginas que usam `MainSection`
---
## Fase 3: Ajustes por componente
### 3.1 Grids sem breakpoint responsivo
| Arquivo | Linha | Atual | Correcao |
|------------------------------------------|-------|-------------------|---------------------------------|
| `teacher/ExamResults.jsx` | 140 | `grid-cols-3` | `grid-cols-1 sm:grid-cols-3` |
| `teacher/ExamResults.jsx` | 251 | `grid-cols-2` | `grid-cols-1 sm:grid-cols-2` |
| `exam-templates/TemplateForm.jsx` | 485 | `grid-cols-2` | `grid-cols-1 sm:grid-cols-2` |
| `exam-templates/AssignmentForm.jsx` | 485 | `grid-cols-2` | `grid-cols-1 sm:grid-cols-2` |
### 3.2 Tabela com overflow-hidden
| Arquivo | Linha | Atual | Correcao |
|----------------------------|-------|-------------------|-------------------|
| `teacher/ClassDetail.jsx` | 304 | `overflow-hidden` | `overflow-x-auto` |
### 3.3 Flex layouts que transbordam
| Arquivo | Linha | Correcao |
|----------------------------------|----------|---------------------------------------------|
| `student/TakeExam.jsx` | 211 | `flex-wrap` + `gap-2` |
| `dashboard/student/page.jsx` | 29 | `flex-wrap` nos botoes |
| `teacher/ExamResults.jsx` | 169 | `flex-wrap` nos metadados |
| `student/ExamResults.jsx` | 269 | `flex-wrap gap-4` no resumo |
| `lessons/LessonForm.jsx` | 372-376 | `flex-col sm:flex-row` |
| `teacher/AssignmentList.jsx` | 127 | `flex-wrap gap-2` |
| `exam-templates/TemplateList.jsx`| 153 | `flex-wrap` nos botoes |
| `student/AvailableExams.jsx` | 187, 231 | `flex-col sm:flex-row` |
### 3.4 Texto longo sem quebra
| Arquivo | Linha | Correcao |
|----------------------------------------|-------|---------------------|
| `guardian/GuardianClassDetail.jsx` | 40 | `break-words` |
| `teacher/class/[id]/page.jsx` | 212 | Truncar no mobile |
### 3.5 Tabelas com muitas colunas -- ocultar no mobile
| Arquivo | Colunas | Correcao |
|--------------------------------|---------|---------------------------------------|
| `teacher/ExamStatistics.jsx` | 7 | `hidden sm:table-cell` nas colunas menos importantes |
| `guardian/payments/page.jsx` | 6 | Ocultar "Comprovante" no mobile |
### 3.6 Botoes que podem empilhar
| Arquivo | Linha | Correcao |
|------------------|-------|-------------------------|
| `PaymentForm.jsx`| 193 | `flex-col sm:flex-row` |
| `RegisterForm.jsx`| 253 | `flex-col sm:flex-row` |
---
## Header mobile -- ja parcialmente implementado
O `Header.jsx` ja tem navegacao mobile (`md:hidden` com scroll horizontal). Pontos de melhoria:
- Chips de navegacao mobile (linhas 147-162) poderiam usar icones maiores e mais touch-friendly
- Considerar colapsar os chips em menu hamburger quando ha mais de 4 items (admin tem 6 links)
---
## Resumo de prioridade de execucao
1. **Fase 0** -- Extrair componentes base (maior esforco, maior ganho em manutenibilidade)
2. **Fase 1** -- Layout global responsivo (sidebar + containers + page header)
3. **Fase 2** -- Componentes responsivos reutilizaveis (Modal + TableWrapper)
4. **Fase 3** -- Ajustes pontuais por componente (grids, flex, tabelas)
---
## Metricas estimadas
| Metricica | Valor |
|------------------------------------|----------------------|
| Arquivos alterados | ~18 |
| Linhas removidas (duplicacao) | ~800-1000 |
| Componentes novos reutilizaveis | 7 |
| Componentes importados (ja existiam)| 6 |
| Correcoes de responsividade | ~26 |
| Modais afetados por 1 componente | 11 |
| Tabelas afetadas por 1 componente | 9 |
---
## Componentes que NAO precisam de correcao de responsividade
Ja estao corretos:
- `page.jsx` (landing page) -- bem estruturado com breakpoints
- `LoginForm.jsx` -- formulario centralizado simples
- `ClassCard.jsx` -- card layout responsivo
- `ClassCardsComponent.jsx` -- `grid-cols-1 sm:grid-cols-2`
- `WardClassCardComponent.jsx` -- `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`
- `DashboardCard.jsx` -- card layout
- `AdminDashboard.jsx` -- `grid-cols-1 sm:grid-cols-2`
- `RoleDispatcher.jsx` -- `grid-cols-1 sm:grid-cols-2`
- `ProfileForm.jsx` -- `grid-cols-1 md:grid-cols-2`
- `PasswordUpdateForm.jsx` -- `grid-cols-1 md:grid-cols-2`
- `AffiliateProductsSection.jsx` -- `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`
- `ToggleTheme.jsx` -- botao de tamanho fixo
- `UserMenu.jsx` -- esconde nome em telas pequenas com `hidden sm:inline`
+102
View File
@@ -0,0 +1,102 @@
# Dashboard Design System - Standardization Plan
## Problem Analysis
Current dashboards have **inconsistent layouts** causing width and spacing issues:
| Dashboard | Width Constraint | Padding | Layout Component |
|-----------|------------------|---------|------------------|
| Admin | `max-w-6xl mx-auto p-6` | 1.5rem | Page-level wrapper |
| Teacher | None (uses MainSection) | 2rem (p-8) | MainSection with no max-width |
| Student | `max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6` | Variable | Page-level wrapper |
| Guardian | `max-w-5xl mx-auto` | 2rem (p-8) | MainSection + inner wrapper |
### Additional Issues:
1. **Global CSS** (lines 451-459) adds underline to all links on hover
2. **ClassCardsComponent** has `lg:grid-cols-3` causing 3 cards per row on large screens
3. **ClassCard** links have default underline on hover
## Proposed Solution
### 1. Update MainSection Component
Add width constraint to standardize all dashboards using it:
```jsx
// Current
<main className={`flex-1 p-8 overflow-y-auto ${className}`}>
// Proposed
<main className={`flex-1 p-6 overflow-y-auto ${className}`}>
<div className="max-w-7xl mx-auto">
{children}
</div>
</main>
```
### 2. Remove Global Link Underline
Remove hover underline from globals.css (lines 457-459):
```css
/* Remove these lines */
a:hover {
text-decoration: underline;
}
```
### 3. Standardize ClassCardsComponent Grid
Change from 3 columns to max 2 columns:
```jsx
// Current
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
// Proposed
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
```
### 4. Ensure ClassCard Has No Underline
Keep the `no-underline` class on Link:
```jsx
<Link href={link} className="block no-underline">
```
### 5. Standard Dashboard Page Pattern
All dashboard pages should follow this structure:
```jsx
<MainSection>
<div className="max-w-6xl mx-auto">
<PageTitle title="..." subTitle="..."/>
{/* Content */}
</div>
</MainSection>
```
### 6. Create Shared DashboardLayout Component (Optional)
For future consistency, create a reusable component:
```jsx
// src/app/(protected)/components/DashboardLayout.jsx
export function DashboardLayout({ title, subtitle, children }) {
return (
<MainSection>
<div className="max-w-6xl mx-auto">
<PageTitle title={title} subTitle={subtitle}/>
{children}
</div>
</MainSection>
);
}
```
## Files to Modify
1. [`src/app/(protected)/components/shared/Main.jsx`](src/app/\(protected\)/components/shared/Main.jsx) - Add max-width wrapper
2. [`src/app/globals.css`](src/app/globals.css) - Remove link hover underline
3. [`src/app/(protected)/components/shared/ClassCardsComponent.jsx`](src/app/\(protected\)/components/shared/ClassCardsComponent.jsx) - Standardize grid
4. [`src/app/(protected)/components/shared/ClassCard.jsx`](src/app/\(protected\)/components/shared/ClassCard.jsx) - Ensure no-underline
5. [`src/app/(protected)/dashboard/teacher/page.jsx`](src/app/\(protected\)/dashboard/teacher/page.jsx) - Remove redundant wrapper
6. (Optional) [`src/app/(protected)/components/DashboardLayout.jsx`](src/app/\(protected\)/components/DashboardLayout.jsx) - Create reusable component
## After Changes
All dashboards will:
- Have consistent max-width (`max-w-6xl` ≈ 72rem / 1152px)
- Use consistent padding (`p-6` ≈ 1.5rem)
- Display max 2 cards per row
- No underline on link hover
+177
View File
@@ -0,0 +1,177 @@
# Payment Registration System Architecture
## Overview
A payment registration system that allows guardians/students to register payments for classes and enables admins to track payment status.
## Requirements
- **Users**: Guardians/students can register payments, admins can view payment status
- **Payment Information**: Value, date/time, optional receipt image
- **Access Control**: Only admins can view payment details; guardians/students can only register their own payments
## Database Schema Design
### Payment Model
```javascript
{
_id: ObjectId,
classId: ObjectId (ref: Class),
userId: ObjectId (ref: User),
amount: Number,
paymentDate: Date,
paymentMethod: String (e.g., "pix", "bank_transfer", "cash"),
status: String (enum: ["pending", "verified", "rejected"]),
receiptUrl: String (optional, from file upload),
notes: String (optional),
createdAt: Date,
updatedAt: Date
}
```
### Key Features
- **One-to-Many**: A class can have multiple payments (partial payments)
- **Many-to-One**: A user can make multiple payments across different classes
- **Status Tracking**: Pending → Verified/Rejected workflow
## API Routes Structure
### 1. Payment API Routes
```
/api/payments
GET - List all payments (admin only)
POST - Create new payment (guardian/student only)
GET/:id - Get single payment (admin only)
PUT/:id - Update payment status (admin only)
DELETE/:id - Delete payment (admin only)
/api/payments/class/:classId
GET - Get all payments for a specific class (admin only)
/api/payments/user/:userId
GET - Get all payments for a specific user (guardian/student only)
```
### 2. File Upload Integration
- Use existing `/api/files` routes
- Set `relatedToType` = "payment"
- Set `relatedToId` = payment ID
- Store receipt image URL in payment record
## UI Components Structure
### Admin Dashboard
```
/app/(protected)/admin/dashboard/payments/
page.jsx - Main payments dashboard
components/
PaymentsTable.jsx - Table showing all payments
PaymentDetail.jsx - Modal showing payment details
PaymentStats.jsx - Statistics cards
VerifyPaymentButton.jsx - Button to verify/reject payments
```
### Guardian Dashboard
```
/app/(protected)/dashboard/guardian/payments/
page.jsx - List of registered payments
register/
page.jsx - Payment registration form
components/
PaymentForm.jsx - Form to register payment
ReceiptUpload.jsx - File upload component
/app/(protected)/dashboard/guardian/class/[id]/payments/
page.jsx - Payments for specific class
```
### Student Dashboard
```
/app/(protected)/dashboard/student/payments/
page.jsx - List of registered payments
register/
page.jsx - Payment registration form
```
## Payment Flow Diagram
```mermaid
sequenceDiagram
participant G as Guardian/Student
participant UI as UI Component
participant API as API Route
participant DB as Database
participant FS as File Storage
G->>UI: Submit payment form (amount, date, receipt)
UI->>API: POST /api/payments
API->>DB: Create payment record (status: pending)
API->>FS: Upload receipt image
API->>DB: Update payment with receipt URL
API-->>UI: Return payment confirmation
UI-->>G: Show success message
Note over Admin: Admin views dashboard
Admin->>UI: View payments dashboard
UI->>API: GET /api/payments
API-->>UI: Return all payments
UI-->>Admin: Display payment table with status
```
## Class View Integration
### Guardian Class Detail Page
- Add payment status indicator
- Show "Register Payment" button if not paid
- Show payment history for the class
### Admin Class View
- Show payment status for all students
- Filter by payment status
- Quick actions to verify/reject payments
## Implementation Steps
### Phase 1: Database & API
1. Create Payment model
2. Create API routes for payment CRUD operations
3. Integrate with existing file upload system
### Phase 2: Admin Dashboard
1. Create payments dashboard page
2. Build payments table with filtering
3. Add payment detail modal
4. Implement verify/reject functionality
### Phase 3: Guardian/Student Interface
1. Create payment registration form
2. Build payment history view
3. Integrate receipt upload
4. Add payment status indicators
### Phase 4: Integration
1. Update class detail pages
2. Add payment status to class cards
3. Add payment reminders (optional)
## Security Considerations
- **Role-Based Access Control**: Only admins can view payment details
- **Data Validation**: Validate payment amounts and dates
- **File Upload Security**: Validate receipt images before storing
- **User Authorization**: Users can only register payments for their own classes
## Data Validation Rules
- Amount: Must be positive number
- Payment Date: Must be in the past or present
- Receipt: Max file size, allowed image formats only
- Payment Method: Must be from predefined list
## Status Workflow
```
Pending (default) → Verified (admin approves) → Rejected (admin rejects)
```
## Future Enhancements
- Payment reminders for pending payments
- Payment history export
- Bulk payment verification
- Payment analytics and reports
- Integration with payment gateways (optional)
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
# Payment Registration System - Summary
## Overview
A complete payment registration system for your course platform that allows guardians/students to register payments and enables admins to track payment status.
## What You'll Get
### 1. **Database Model**
- New [`Payment`](src/app/models/Payment.js) model with fields:
- `classId` - Reference to the class
- `userId` - Reference to the user (guardian/student)
- `amount` - Payment value
- `paymentDate` - Date and time of payment
- `paymentMethod` - Method (PIX, bank transfer, cash, etc.)
- `status` - Pending, Verified, or Rejected
- `receiptUrl` - Optional receipt image
- `notes` - Optional notes
### 2. **API Routes**
- [`/api/payments`](src/app/api/payments/route.js) - Create and list payments
- [`/api/payments/[id]`](src/app/api/payments/[id]/route.js) - Get, update, delete single payment
- [`/api/payments/class/[classId]`](src/app/api/payments/class/[classId]/route.js) - Get all payments for a class
- [`/api/payments/user/[userId]`](src/app/api/payments/user/[userId]/route.js) - Get all payments for a user
### 3. **Admin Dashboard**
- [`/admin/dashboard/payments`](src/app/(protected)/admin/dashboard/payments/page.jsx) - Main dashboard
- **Features**:
- View all payments in a table
- Filter by status (all, pending, verified, rejected)
- See payment statistics (total, verified, pending, rejected)
- Verify or reject payments
- View payment details
### 4. **Guardian/Student Interface**
- **Payment Registration**:
- Form to register payments with amount, date, method
- Optional receipt image upload
- Notes field for additional information
- **Payment History**:
- View all registered payments
- See payment status for each class
### 5. **Payment Status Indicators**
- Reusable [`PaymentStatusBadge`](src/app/(protected)/components/shared/PaymentStatusBadge.jsx) component
- Shows status: Not Paid, Pending, Paid, or Rejected
- Color-coded badges for easy identification
## How It Works
### For Guardians/Students:
1. Navigate to a class they're enrolled in or their ward is enrolled in
2. Click "Registrar Pagamento" (Register Payment)
3. Fill in payment details:
- Amount (R$)
- Payment date
- Payment method (PIX, bank transfer, cash, etc.)
- Optional: Upload receipt image
- Optional: Add notes
4. Submit the form
5. Payment status becomes "Pending" (waiting for admin verification)
### For Admins:
1. Navigate to Admin Dashboard → Payments
2. View all payments in a table
3. See payment statistics at the top
4. Filter payments by status
5. Click on a payment to see details
6. Verify or reject the payment
7. Payment status updates accordingly
## File Structure
```
src/
├── app/
│ ├── api/payments/ # API routes
│ ├── (protected)/
│ │ ├── admin/dashboard/payments/ # Admin dashboard
│ │ ├── dashboard/guardian/payments/ # Guardian interface
│ │ └── dashboard/student/payments/ # Student interface
│ └── components/shared/PaymentStatusBadge.jsx # Reusable component
├── models/Payment.js # Payment model
└── lib/utils/payments.js # Helper functions
```
## Security Features
- **Role-Based Access Control**:
- Only admins can view payment details
- Guardians can only register payments for their wards
- Students can only register payments for their enrolled classes
- **Data Validation**:
- Amount must be positive
- Payment date must be valid
- File uploads are validated
- **Data Integrity**:
- All payments are tracked with timestamps
- Payment status workflow is enforced
## Integration with Existing System
- Uses existing file upload infrastructure
- Integrates with existing User and Class models
- Follows existing API patterns
- Uses existing UI components
- Compatible with current role-based authentication
## Next Steps
1. **Review the plans**:
- [`payment-system-architecture.md`](plans/payment-system-architecture.md) - High-level architecture
- [`payment-system-implementation.md`](plans/payment-system-implementation.md) - Detailed implementation guide
2. **Implementation order**:
- Create Payment model
- Create API routes
- Create admin dashboard
- Create guardian/student interface
- Integrate with class views
- Test the complete flow
3. **Testing checklist**:
- Create payment with guardian account
- Verify payment with admin account
- Test file upload for receipts
- Verify role-based access control
- Test payment status indicators
## Questions?
The detailed implementation guide includes:
- Complete code for all components
- API route implementations
- Database schema definitions
- UI component code
- Integration points
- Testing checklist
Would you like me to proceed with implementing this system? I can start by creating the Payment model and API routes.
+283
View File
@@ -0,0 +1,283 @@
# Plano de Refatoracao por Fases
Este documento organiza a correcao do projeto em fases pequenas, verificaveis e sem ruptura grande.
Objetivo principal:
- estabilizar build e seguranca
- migrar gradualmente de API Routes para Server Actions
- eliminar conflitos de CSS global (sem `!important`)
- aproximar o frontend de um estilo tipo shadcn (componentes reutilizaveis + tokens consistentes)
---
## Visao Geral das Fases
1. Fase 0 - Baseline e congelamento
2. Fase 1 - Build, auth e seguranca minima
3. Fase 2 - Migracao API -> Server Actions
4. Fase 3 - Reset de CSS global e fim de conflitos
5. Fase 4 - Base de design system estilo shadcn
6. Fase 5 - Refino visual da plataforma de ingles
7. Fase 6 - Limpeza final e hardening
Cada fase tem: escopo, tarefas, definicao de pronto e risco.
### Status consolidado (fev/2026)
- Fase 1: concluida.
- Fase 2: concluida nos dominios planejados para fluxo interno (APIs legadas mantidas como fallback temporario).
- Fase 3: concluida no objetivo principal (globals enxuto, sem `!important`, sem overrides globais agressivos).
- Fase 4: concluida no escopo principal (base `ui` ativa + login/register + dashboards principais migrados).
- Fase 5: pendente (refino visual mais amplo ainda nao iniciado de forma sistematica).
- Fase 6: em andamento parcial (melhorias de feedback ao usuario e tratamento de erros em fluxos criticos).
---
## Fase 0 - Baseline e congelamento
### Escopo
Criar um ponto de partida confiavel antes de alterar arquitetura.
### Tarefas
- [ ] Registrar o estado atual (erros conhecidos, rotas criticas, telas criticas).
- [ ] Salvar relatorio de build/lint/testes E2E atuais.
- [ ] Definir branch de refatoracao (ex.: `refactor/phases`).
- [ ] Congelar novas features ate concluir Fase 2.
### Definicao de pronto
- Baseline documentado e reproduzivel.
- Time alinhado sobre prioridades e ordem de execucao.
### Risco
- Baixo. Sem mudanca de codigo de negocio.
---
## Fase 1 - Build, auth e seguranca minima
### Escopo
Resolver problemas que quebram producao/deploy e vazamentos de autorizacao.
### Tarefas
- [x] Corrigir middleware para nao importar auth/db de runtime Edge.
- Opcao recomendada: middleware apenas para roteamento simples.
- Verificacao de permissao real fica no server (Server Components/Server Actions).
- [x] Revisar matcher do middleware para incluir rotas protegidas reais (`/admin/*`, `/dashboard/*`).
- [x] Garantir bloqueio de acesso nas paginas sensiveis antes de carregar dados.
- [x] Corrigir verificacao de role inconsistente (`role` vs `roles`).
- [x] Revisar regra de arquivos legados: negar por padrao quando nao houver metadado de permissao.
- [x] Remover/encerrar fluxo duplicado de auth (uma unica fonte de verdade do NextAuth).
- [x] Padronizar variavel de ambiente do Mongo (`MONGODB_URI` ou `MONGO_URI`) e alinhar docs.
### Definicao de pronto
- `npm run build` passa sem erro.
- Rotas protegidas retornam 401/403 corretamente.
- Fluxo de login/logout unico e consistente.
### Risco
- Medio. Pode impactar acesso de usuarios se a regra de permissao nao for validada com cuidado.
---
## Fase 2 - Migracao API -> Server Actions
### Escopo
Migrar gradualmente endpoints de API para Server Actions, priorizando fluxo interno do app.
### Principios
- Evitar big-bang. Migrar por dominio funcional.
- Manter API somente quando houver necessidade real externa (webhook, integracao de terceiros, upload streaming, callback externo).
- Toda Action deve validar sessao e permissao no servidor.
### Ordem recomendada de migracao
1. **Pagamentos**
2. **Turmas e atribuicoes/provas**
3. **Arquivos e categorias**
4. **Usuarios/guardian/wards**
### Tarefas por dominio (template)
- [ ] Criar `actions/` por dominio com funcoes server-only.
- [ ] Mover validacoes de auth/autorizacao para as Actions.
- [ ] Substituir chamadas `fetch('/api/...')` por invocacao direta de Action.
- [ ] Padronizar retorno `{ success, data, message, fieldErrors }`.
- [ ] Revalidar cache (`revalidatePath`/`revalidateTag`) quando necessario.
- [ ] Manter fallback temporario para rotas antigas e remover ao final da fase.
### Progresso atual (dominio: Pagamentos)
- [x] Criadas Server Actions de pagamentos e dependentes.
- [x] Auth/autorizacao centralizadas nas Actions de pagamentos.
- [x] Telas de pagamentos de aluno/responsavel migradas de `fetch('/api/...')` para Actions.
- [x] Formularios de registro de pagamento migrados para Actions.
- [x] Retorno padronizado nas novas Actions (`success`, `data`, `message`, `fieldErrors`).
- [x] Revalidacao aplicada para dashboards de pagamento apos envio.
- [x] Fluxo admin de pagamentos migrado para Actions (criacao de obrigacao, aprovacao/rejeicao, consulta por obrigacao).
- [x] Fallback temporario mantido: APIs antigas continuam disponiveis por compatibilidade e remocao gradual.
### Progresso atual (dominio: Turmas e atribuicoes/provas)
- [x] Criadas Server Actions para fluxo operacional de provas (listar atribuicoes por turma, iniciar tentativa, listar tentativas, detalhar tentativa, corrigir resposta).
- [x] Componentes de aluno migrados para Actions (`AvailableExams`, `TakeExam`) sem dependencia de `/api/classes/*` e `/api/assignments/*` nesses fluxos.
- [x] Componente de resultados para professor/admin migrado para Actions (`ExamResults`) sem dependencia de `/api/attempts/*`.
- [x] Mantido fallback temporario: rotas API de provas continuam existentes para compatibilidade durante transicao.
### Progresso atual (dominio: Arquivos e categorias)
- [x] Criada Server Action para carregamento de categorias com migracao de `colorIndex` quando necessario.
- [x] Formulario de upload de arquivos do admin migrado para Action de categorias, removendo dependencia de `/api/categories` no cliente.
- [x] Ajuste de navegacao pos-upload para `router.push` em componentes cliente de upload (evitando uso indevido de `redirect`).
- [x] Eliminadas chamadas `fetch('/api/...')` no frontend protegido; fluxo interno agora opera via Server Actions.
### Progresso atual (dominio: Usuarios/guardian/wards)
- [x] Criadas Server Actions de usuarios para dependentes (`getWardsAction`) e listagem admin (`getUsersForAdminAction`).
- [x] Fluxo de pagamentos do responsavel desacoplado do dominio de pagamentos para usar Action de usuarios no carregamento de dependentes.
- [x] Painel admin de usuarios passou a consumir Action de usuarios (sem dependencia de rota API interna).
- [x] Harden de cadastro de estudante por responsavel: validacao de sessao/role, vinculo seguro de guardian e verificacao de unicidade (email/username).
### Definicao de pronto
- Fluxos internos principais sem dependencia de API Route.
- Reducao significativa de codigo duplicado entre page/API.
- Permissoes centralizadas em camada server.
### Risco
- Medio/alto. Erros de cache e invalidacao podem causar dados desatualizados.
---
## Fase 3 - Reset de CSS global e fim de conflitos
### Escopo
Reduzir `globals.css` ao minimo necessario e eliminar conflitos causados por regras globais.
### Regras obrigatorias
- Sem `!important`.
- Sem redefinir utilitarios Tailwind manualmente (`.bg-*`, `.text-*`, etc.).
- Sem estilos globais agressivos para `button`, `a`, `table`, `input` que mudem componentes inteiros.
### Tarefas
- [x] Criar backup do `globals.css` atual.
- [x] Reescrever `globals.css` em blocos minimos:
- tokens CSS (`:root`, `.dark`)
- reset basico
- utilitarios realmente necessarios
- [x] Extrair classes de componentes para componentes reais (Button, Input, Badge, Card, Alert).
- [x] Remover gradualmente classes legadas utilitarias customizadas.
- [x] Garantir fonte definida no layout e aplicada de forma unica.
- [x] Revisar contraste, foco e estados disabled/hover.
### Progresso atual (Fase 3)
- [x] `globals.css` reduzido e sem `!important`.
- [x] Removidos overrides manuais de utilitarios Tailwind (`.bg-*`, `.text-*`, `.border-*`).
- [x] `Label` e `RoleCheckbox` migrados para variantes no componente (sem classes de tema globais acopladas).
- [x] Continuar extracao para componentes de base (Button/Input/Badge/Card/Alert) e reduzir classes globais remanescentes.
### Definicao de pronto
- Arquivo global enxuto e previsivel.
- Sem conflitos visuais entre telas por efeito colateral global.
- Nenhum `!important` no codigo do app.
### Risco
- Medio. Pode quebrar aparencia de telas antigas sem componentes padronizados.
---
## Fase 4 - Base de design system estilo shadcn
### Escopo
Padronizar UI com componentes reutilizaveis, variantes e tokens.
### Tarefas
- [x] Criar base `components/ui` (Button, Input, Select, Textarea, Badge, Card, Dialog, Table).
- [x] Adotar utilitario `cn` para composicao de classes.
- [x] Adotar estrategia de variantes (ex.: cva) para reduzir classes repetidas.
- [x] Padronizar espacamento, raio, sombras, tipografia e cores semanticas.
- [x] Migrar telas mais usadas primeiro (login, register, dashboard admin/teacher/student).
### Progresso atual (Fase 4)
- [x] Estrutura inicial criada em `src/components/ui` com componentes base (Button, Input, Select, Textarea, Badge, Card, Alert).
- [x] Utilitario `cn` criado e aplicado na base dos componentes UI.
- [x] Primeira migracao pratica feita em formularios criticos (registro de estudante e pagamento aluno/responsavel).
- [x] Migracao aplicada em login/register com padrao `components/ui` (Card/Input/Button/Alert) e layout alinhado.
- [x] Dashboards principais ajustados para consistencia de componentes e estilos-base (admin/teacher/student/guardian).
### Definicao de pronto
- Componentes principais usados pela maioria das telas.
- Queda relevante de duplicacao de classes Tailwind.
- Visual consistente entre areas publicas e protegidas.
### Risco
- Medio. Requer disciplina para evitar voltar ao estilo ad-hoc por pagina.
---
## Fase 5 - Refino visual da plataforma de ingles
### Escopo
Dar identidade visual propria da escola, sem perder legibilidade e performance.
### Tarefas
- [ ] Definir direcao visual unica (paleta, tipografia, acentos, ilustracoes/fotos).
- [ ] Revisar landing page e fluxo de autenticacao com hierarquia visual clara.
- [ ] Melhorar componentes de conteudo educacional (cards de nivel, progresso, tarefas, feedback).
- [ ] Garantir responsividade real (mobile-first) nas paginas criticas.
- [ ] Revisar acessibilidade (foco visivel, contraste, labels, erros de formulario).
### Definicao de pronto
- Interface coerente com proposta de plataforma de ingles.
- Melhor legibilidade e consistencia de interacao.
### Risco
- Baixo/medio. Principal risco e retrabalho visual sem criterios definidos.
---
## Fase 6 - Limpeza final e hardening
### Escopo
Fechamento tecnico para estabilidade de longo prazo.
### Tarefas
- [ ] Remover codigo morto (rotas API legadas, helpers nao usados, estilos antigos).
- [~] Reduzir logs de debug e padronizar logger por ambiente.
- [~] Revisar tratamento de erros (mensagem para usuario vs log tecnico).
- [ ] Atualizar README e documentacao de arquitetura nova (Server Actions-first).
- [ ] Validar testes E2E por papel (admin/teacher/student/guardian).
- [ ] Revisar seguranca de upload/acesso a arquivos.
### Progresso atual (Fase 6)
- [x] Melhorado feedback de permissao no registro/edicao de aula (403 com mensagem clara no UI).
- [x] Reduzido ruido de console no cliente para erros esperados (4xx) em `LessonForm`.
- [x] Ajustados fluxos de navegacao que causavam 404 silencioso (links de upload/historico/resultados entre contextos admin/teacher).
- [ ] Consolidar padrao unico de erros para todos os formularios com Server Actions e APIs legadas restantes.
### Definicao de pronto
- Build estavel, testes principais passando e documentacao atualizada.
- Arquitetura mais simples para manutencao.
### Risco
- Baixo.
---
## Criterios tecnicos transversais (todas as fases)
- Nao quebrar fluxo de login, registro e dispatcher.
- Toda regra de permissao deve existir no servidor.
- Alteracoes pequenas por PR, com escopo fechado.
- Sempre validar com build + fluxo manual das telas afetadas.
---
## Checklist de execucao por fase
Use este mini-checklist ao iniciar cada fase:
1. Definir escopo exato da fase (o que entra e o que nao entra).
2. Criar tarefas tecnicas pequenas (max 1-2 dias cada).
3. Implementar em branch dedicada.
4. Rodar `npm run lint` e `npm run build`.
5. Validar fluxo funcional manual.
6. Atualizar este documento marcando itens concluidos.
---
## Proxima acao recomendada
Fechar a **Fase 4** (migracao de login/register/dashboards para componentes `ui` + padrao visual),
e em seguida executar um passe objetivo da **Fase 6** para remover logs legados e fallbacks de API nao utilizados.
+45
View File
@@ -0,0 +1,45 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 13'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Script para criar usuário administrador
*
* Uso:
* node scripts/create-admin.js [username] [password] [email] [fullName]
*
* Exemplo:
* node scripts/create-admin.js admin admin1234 [email protected] "Administrador"
*/
import bcrypt from "bcryptjs";
import mongoose from "mongoose";
import dotenv from "dotenv";
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function normalizeName(value) {
if (!value || typeof value !== "string") return "";
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}
// Carregar .env do diretório raiz
dotenv.config({ path: join(__dirname, '../.env') });
const MONGODB_URI = process.env.MONGODB_URI || process.env.MONGO_URI || "mongodb://localhost:27017/course-plat";
// Schema do User (replicado para não depender do Next.js)
const UserSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true,
},
passwordHash: {
type: String,
required: true,
},
email: {
type: String,
unique: true,
sparse: true,
trim: true,
lowercase: true,
},
fullName: {
type: String,
required: true,
trim: true,
},
dateOfBirth: {
type: Date,
required: true,
},
roles: {
type: [String],
enum: ["student", "parent", "guardian", "teacher", "admin"],
required: true,
default: ["student"],
},
guardiansAccounts: [{
type: mongoose.Schema.Types.ObjectId,
ref: "User",
}],
wardAccounts: [{
type: mongoose.Schema.Types.ObjectId,
ref: "User",
}],
createdAt: { type: Date, default: Date.now },
modifiedAt: { type: Date, default: Date.now },
}, {
collection: 'users' // Garantir que use a coleção correta
});
async function createAdmin(username, password, email, fullName) {
try {
console.log("Conectando ao MongoDB...");
console.log(`URI: ${MONGODB_URI.replace(/:[^:@]*@/, ':****@')}`);
await mongoose.connect(MONGODB_URI);
console.log("✓ Conectado!");
const User = mongoose.model('User', UserSchema);
const existingUser = await User.findOne({ username });
if (existingUser) {
// Adiciona role admin ao usuário existente
await User.updateOne(
{ username },
{ $addToSet: { roles: "admin" } }
);
console.log(`✓ Usuário "${username}" atualizado com role admin!`);
} else {
// Cria novo usuário admin
const passwordHash = await bcrypt.hash(password, 10);
await User.create({
username,
passwordHash,
email,
fullName,
dateOfBirth: new Date("1990-01-01"),
roles: ["admin"],
});
console.log(`✓ Usuário admin "${username}" criado com sucesso!`);
console.log(` Email: ${email}`);
console.log(` Senha: ${password}`);
}
// Verificar
const user = await User.findOne({ username });
console.log(`\nRoles atuais: ${user.roles.join(", ")}`);
} catch (error) {
console.error("Erro ao criar admin:", error);
process.exit(1);
} finally {
await mongoose.disconnect();
}
}
// Args da linha de comando ou valores padrão
const args = process.argv.slice(2);
const username = normalizeName(args[0] || "admin").replace(/\s+/g, "");
const password = args[1] || "admin1234";
const email = args[2] || "[email protected]";
const fullName = normalizeName(args[3] || "Administrador");
console.log(`\nCriando admin: ${username}`);
console.log(`Senha: ${password}`);
console.log(`Email: ${email}\n`);
createAdmin(username, password, email, fullName);
+61
View File
@@ -0,0 +1,61 @@
/**
* One-time migration script to assign colorIndex to existing categories
* Run with: node scripts/migrate-category-colors.js
*/
const { MongoClient } = require('mongodb');
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/course-plat';
const DB_NAME = 'course-plat'; // Adjust if needed
async function migrate() {
const client = new MongoClient(MONGODB_URI);
try {
await client.connect();
console.log('Connected to MongoDB');
const db = client.db(DB_NAME);
const categories = db.collection('categories');
// Find all categories without colorIndex
const withoutColor = await categories.find({ colorIndex: { $exists: false } }).toArray();
if (withoutColor.length === 0) {
console.log('All categories already have colorIndex. Nothing to migrate.');
return;
}
console.log(`Found ${withoutColor.length} categories without colorIndex.`);
// Get current max colorIndex
const withColor = await categories.find({ colorIndex: { $exists: true } })
.sort({ colorIndex: -1 })
.limit(1)
.toArray();
let startIndex = withColor.length > 0 ? (withColor[0].colorIndex + 1) : 0;
// Update each category
for (const cat of withoutColor) {
const colorIndex = startIndex % 10; // Cycle through 10 colors
await categories.updateOne(
{ _id: cat._id },
{ $set: { colorIndex: colorIndex } }
);
console.log(`Updated "${cat.name}" -> colorIndex: ${colorIndex}`);
startIndex++;
}
console.log('Migration complete!');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
await client.close();
process.exit(0);
}
}
migrate();
@@ -0,0 +1,335 @@
"use client";
import React, { useEffect, useState, useRef } from "react";
import { useActionState } from "react";
import saveAffiliateProductAction from "@/app/lib/affiliateProducts/saveAffiliateProductAction";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
import { useRouter } from "next/navigation";
import { XMarkIcon, PhotoIcon } from "@heroicons/react/24/outline";
import { getAllClassItems } from "@/app/lib/helpers/getItems";
import { MAX_FILE_SIZE } from "@/app/lib/constants";
function AffiliateProductForm({ product = {}, classTypes = [], categories = [], onCancel }) {
const router = useRouter();
const fileInputRef = useRef(null);
const [showMessage, setShowMessage] = useState(true);
const [selectedClassTypes, setSelectedClassTypes] = useState(
product.classTypes?.map((ct) => ct.toString()) || []
);
const [imageInputType, setImageInputType] = useState(
product.imageUrl?.startsWith("http") ? "url" : "upload"
);
const [imagePreview, setImagePreview] = useState(product.imageUrl || "");
const [uploadedFileName, setUploadedFileName] = useState("");
const initialState = {
success: false,
message: null,
};
const [state, action, isPending] = useActionState(
saveAffiliateProductAction,
initialState
);
useEffect(() => {
if (state.message) {
setShowMessage(true);
const timer = setTimeout(() => {
setShowMessage(false);
}, 5000);
return () => clearTimeout(timer);
}
}, [state.message]);
useEffect(() => {
if (state?.success && state.redirectTo) {
const timer = setTimeout(() => {
router.push(state.redirectTo);
}, 1500);
return () => clearTimeout(timer);
}
}, [state?.success, state?.redirectTo, router]);
const handleClassTypeToggle = (classTypeId) => {
setSelectedClassTypes((prev) => {
if (prev.includes(classTypeId)) {
return prev.filter((id) => id !== classTypeId);
} else {
return [...prev, classTypeId];
}
});
};
const handleFileChange = (e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
alert(`Arquivo "${file.name}" (${sizeMB}MB) excede o limite de 500MB.`);
e.target.value = "";
return;
}
setUploadedFileName(file.name);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result);
};
reader.readAsDataURL(file);
}
};
const clearImage = () => {
setImagePreview("");
setUploadedFileName("");
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
return (
<div className="w-full max-w-2xl mx-auto">
{state?.message && showMessage && (
<FlashMessage
message={state?.message}
type={state.success ? "success" : "error"}
/>
)}
<form
action={action}
className="bg-white dark:bg-neutral-800 p-8 rounded-lg shadow-md"
>
{isPending && <p className="text-center mb-4">Carregando...</p>}
{product._id && (
<input type="hidden" name="_id" value={product._id} />
)}
{/* Hidden inputs for selected class types */}
{selectedClassTypes.map((classTypeId) => (
<input
key={classTypeId}
type="hidden"
name="classTypeIds"
value={classTypeId}
/>
))}
<div className="space-y-6">
{/* Title */}
<div>
<label htmlFor="title" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Título <span className="text-red-500">*</span>
</label>
<input
type="text"
id="title"
name="title"
defaultValue={product.title || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
placeholder="Ex: Livro de Gramática Essential"
required
/>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Descrição
</label>
<textarea
id="description"
name="description"
defaultValue={product.description || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
rows="3"
placeholder="Breve descrição do produto..."
></textarea>
</div>
{/* Image - Upload or URL */}
<div>
<label className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Imagem do Produto
</label>
{/* Toggle between upload and URL */}
<div className="flex gap-4 mb-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="imageInputType"
value="upload"
checked={imageInputType === "upload"}
onChange={() => setImageInputType("upload")}
className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
/>
<span className="text-sm text-neutral-700 dark:text-neutral-300">Fazer upload</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="imageInputType"
value="url"
checked={imageInputType === "url"}
onChange={() => setImageInputType("url")}
className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
/>
<span className="text-sm text-neutral-700 dark:text-neutral-300">Usar URL</span>
</label>
</div>
{/* Upload option */}
{imageInputType === "upload" && (
<div>
<input
ref={fileInputRef}
type="file"
id="imageFile"
name="imageFile"
accept="image/*"
onChange={handleFileChange}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
/>
{uploadedFileName && (
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Arquivo selecionado: {uploadedFileName}
</p>
)}
</div>
)}
{/* URL option */}
{imageInputType === "url" && (
<input
type="url"
id="imageUrl"
name="imageUrl"
defaultValue={product.imageUrl || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
placeholder="https://example.com/image.jpg"
/>
)}
{/* Image Preview */}
{imagePreview && (
<div className="mt-3 relative">
<img
src={imagePreview}
alt="Preview"
className="w-32 h-32 object-cover rounded-lg border border-neutral-200 dark:border-neutral-700"
/>
<button
type="button"
onClick={clearImage}
className="absolute -top-2 -right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
title="Remover imagem"
>
<XMarkIcon className="w-4 h-4" />
</button>
</div>
)}
</div>
{/* Affiliate URL */}
<div>
<label htmlFor="affiliateUrl" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Link de Afiliado <span className="text-red-500">*</span>
</label>
<input
type="url"
id="affiliateUrl"
name="affiliateUrl"
defaultValue={product.affiliateUrl || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
placeholder="https://amazon.com.br/..."
required
/>
</div>
{/* Price and Category */}
<div>
<label htmlFor="category" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Categoria
</label>
<input
type="text"
id="category"
name="category"
defaultValue={product.category || ""}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
placeholder="Ex: Livros, Materiais, etc."
/>
</div>
{/* Active */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="active"
name="active"
value="true"
defaultChecked={product.active !== undefined ? product.active : true}
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<label htmlFor="active" className="text-neutral-700 dark:text-neutral-200 text-sm font-medium">
Produto ativo
</label>
</div>
{/* Class Types */}
{classTypes.length > 0 && (
<div>
<label className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Tipos de Turma Relacionados
</label>
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto p-3 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-neutral-50 dark:bg-neutral-700">
{classTypes.map((classType) => (
<button
key={classType._id}
type="button"
onClick={() => handleClassTypeToggle(classType._id)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
selectedClassTypes.includes(classType._id)
? "bg-indigo-600 text-white border-indigo-600"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600 hover:border-indigo-400"
}`}
>
{classType.title}
</button>
))}
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
O produto será exibido para estas turmas
</p>
</div>
)}
</div>
{/* Form Actions */}
<div className="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-neutral-700 dark:text-neutral-300 font-medium rounded-lg border border-neutral-300 dark:border-neutral-600 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg disabled:opacity-50 transition-colors"
>
{isPending ? "Salvando..." : "Salvar"}
</button>
</div>
</form>
</div>
);
}
export default AffiliateProductForm;
@@ -0,0 +1,140 @@
"use client";
import { deleteAffiliateProductAction } from "@/app/lib/affiliateProducts/deleteAffiliateProductAction";
import { useActionState, useEffect, useState } from "react";
import Link from "next/link";
import { FaEdit, FaTrash } from "react-icons/fa";
import Label from "@/app/(protected)/components/shared/Label";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
export default function AffiliateProductsTable({ products = [] }) {
const initialState = { success: false, message: null };
const [state, action, isPending] = useActionState(
deleteAffiliateProductAction,
initialState
);
const [showMessage, setShowMessage] = useState(false);
useEffect(() => {
if (state?.message) {
setShowMessage(true);
const timer = setTimeout(() => setShowMessage(false), 5000);
return () => clearTimeout(timer);
}
}, [state?.message]);
return (
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
{showMessage && state?.message && (
<div className="px-6 pt-4">
<FlashMessage
message={state.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<table className="w-full text-sm">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs align-top">
<tr>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
Produto
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell text-left">
Categoria
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center">
Status
</th>
<th className="px-4 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center whitespace-nowrap w-px">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800 align-top">
{products.length === 0 ? (
<tr>
<td colSpan="4" className="px-6 py-8 text-center text-neutral-500 dark:text-neutral-400">
Nenhum produto cadastrado ainda.
</td>
</tr>
) : (
products.map((product) => (
<tr
key={product._id}
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-3.5 text-neutral-900 dark:text-neutral-100 align-top text-left">
<div className="flex items-center gap-3">
{product.imageUrl && (
<img
src={product.imageUrl}
alt={product.title}
className="w-12 h-12 rounded-lg object-cover border border-neutral-200 dark:border-neutral-700"
/>
)}
<div>
<p className="font-medium">{product.title}</p>
{product.description && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 line-clamp-1">
{product.description}
</p>
)}
</div>
</div>
</td>
<td className="px-6 py-3.5 hidden md:table-cell text-neutral-700 dark:text-neutral-300 align-top">
{product.category || "-"}
</td>
<td className="px-6 py-3.5 text-center align-top">
{product.active ? (
<Label color="emerald" size="sm">Ativo</Label>
) : (
<Label color="red" size="sm">Inativo</Label>
)}
</td>
<td className="px-4 py-3.5 align-top whitespace-nowrap">
<div className="flex items-center gap-2">
<Link
href={`/admin/dashboard/affiliate-products/edit/${product._id.toString()}`}
title="Editar"
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
>
<FaEdit className="text-lg" />
</Link>
<form action={action} className="inline">
<input
type="hidden"
name="_id"
value={product._id || "nada"}
/>
<button
type="submit"
title="Excluir"
disabled={isPending}
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
if (!confirm('Tem certeza que deseja deletar este produto?')) {
e.preventDefault();
}
}}
>
{isPending ? (
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<FaTrash className="text-lg" />
)}
</button>
</form>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,25 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import AffiliateProductForm from "../AffiliateProductForm";
import { getAllClassItems } from "@/app/lib/helpers/getItems";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
export default async function AddAffiliateProductPage() {
const session = await auth();
if (!session?.user?.roles?.includes("admin")) {
redirect("/auth/login");
}
const classTypes = await getAllClassItems();
return (
<div className="flex flex-col w-full">
<PageHeader
title="Adicionar Produto de Afiliado"
subtitle="Cadastre um novo produto de afiliado"
/>
<AffiliateProductForm classTypes={classTypes} />
</div>
);
}
@@ -0,0 +1,47 @@
import AffiliateProductForm from "@/app/(protected)/admin/dashboard/affiliate-products/AffiliateProductForm";
import { getAffiliateProductById } from "@/app/lib/affiliateProducts/getAffiliateProductsAction";
import { getAllClassItems } from "@/app/lib/helpers/getItems";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
export default async function EditAffiliateProductPage({ params }) {
const session = await auth();
if (!session?.user?.roles?.includes("admin")) {
redirect("/auth/login");
}
const awaitedParams = await params;
const id = awaitedParams.id;
if (!isValidObjectId(id)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
const productResult = await getAffiliateProductById(id);
const product = productResult.success ? productResult.data : null;
const classTypes = await getAllClassItems();
if (!product) {
redirect("/admin/dashboard/affiliate-products");
}
product._id = product._id.toString();
return (
<div className="w-full mt-3">
<PageHeader
title="Editar Produto de Afiliado"
subtitle="Atualize as informações do produto"
/>
<div className="flex justify-center">
<AffiliateProductForm product={product} classTypes={classTypes} />
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import Link from "next/link";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import AffiliateProductsTable from "./AffiliateProductsTable";
import { getAllAffiliateProductsForAdmin } from "@/app/lib/affiliateProducts/getAffiliateProductsAction";
export default async function AffiliateProductsPage() {
const result = await getAllAffiliateProductsForAdmin();
const products = result.success ? result.data : [];
return (
<div className="w-full mt-3">
<PageHeader
title="Gerenciar Produtos de Afiliados"
subtitle="Gerencie os produtos de afiliados que serão exibidos para os estudantes"
actions={
<Link href="/admin/dashboard/affiliate-products/add">
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
+ Adicionar Produto
</button>
</Link>
}
/>
<AffiliateProductsTable products={products} />
</div>
);
}
@@ -0,0 +1,81 @@
import { getExamAssignmentById } from "@/app/lib/actions/examActions";
import MainSection from "@/app/(protected)/components/shared/Main";
import ExamResults from "@/app/(protected)/components/teacher/ExamResults";
import { auth } from "@/app/lib/utils/auth";
import { redirect } from "next/navigation";
import NotAuthorized from "@/app/auth/components/NotAuthorized";
import { getClassModel } from "@/app/models/Class";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
export default async function AdminAssignmentResultsPage({ params }) {
const { id: assignmentId } = await params || {};
if (!isValidObjectId(assignmentId)) {
return (
<MainSection>
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
</MainSection>
);
}
const session = await auth();
if (!session?.user?.id) {
redirect("/auth/login");
}
const userRoles = Array.isArray(session.user.roles) ? session.user.roles : [];
const isAdmin =
userRoles.includes("admin") ||
userRoles.includes("superadmin") ||
session.user.role === "admin" ||
session.user.role === "superadmin";
if (!isAdmin) {
return <NotAuthorized />;
}
const result = await getExamAssignmentById(assignmentId);
if (!result.success) {
return (
<MainSection>
<div className="max-w-6xl mx-auto p-6">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Resultados da prova
</h1>
<p className="text-sm text-red-600 dark:text-red-400 mt-2">
{result.error || "Não foi possível carregar os resultados desta prova."}
</p>
</div>
</MainSection>
);
}
const assignment = result.success ? result.data : null;
// Check if current user is a teacher of the class
let canGrade = false;
if (session?.user?.id && assignment?.classId) {
const Class = await getClassModel();
const classRef = assignment.classId?._id || assignment.classId;
const classData = await Class.findById(classRef).lean();
const teacherIds = classData?.teachers?.map((t) => t.toString()) || [];
canGrade = teacherIds.includes(session.user.id);
}
return (
<MainSection>
<div className="max-w-6xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{assignment?.title || "Prova"}
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
Resultados e correção das provas
</p>
</div>
<ExamResults assignmentId={assignmentId} assignmentTitle={assignment?.title} canGrade={canGrade} />
</div>
</MainSection>
);
}
@@ -0,0 +1,30 @@
import { getExamAssignments, getExamTemplates, getClasses } from '@/app/lib/actions/examActions';
import AssignmentList from '@/app/(protected)/components/teacher/AssignmentList';
import AssignmentFormWrapper from '@/app/(protected)/components/exam-templates/AssignmentFormWrapper';
export default async function AssignmentsPage() {
const [assignmentsResult, templatesResult, classesResult] = await Promise.all([
getExamAssignments(),
getExamTemplates(),
getClasses()
]);
const assignments = assignmentsResult.success ? assignmentsResult.data : [];
const templates = templatesResult.success ? templatesResult.data : [];
const classes = classesResult.success ? classesResult.data : [];
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">Exam Assignments</h1>
</div>
<AssignmentList
initialAssignments={assignments}
basePath="/admin/dashboard"
/>
<AssignmentFormWrapper templates={templates} classes={classes} />
</div>
);
}
@@ -0,0 +1,105 @@
"use client";
import { deleteCategory } from "@/app/lib/categories/deleteCategory";
import { useActionState, useEffect, useState } from "react";
import Link from "next/link";
import { FaEdit, FaTrash } from "react-icons/fa";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
export default function CategoriesTable({ categories }) {
const initialState = { success: false, message: null };
const [state, action, isPending] = useActionState(
deleteCategory,
initialState
);
const [showMessage, setShowMessage] = useState(false);
useEffect(() => {
if (state?.message) {
setShowMessage(true);
const timer = setTimeout(() => setShowMessage(false), 5000);
return () => clearTimeout(timer);
}
}, [state?.message]);
return (
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
{showMessage && state?.message && (
<div className="px-6 pt-4">
<FlashMessage
message={state.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<table className="w-full text-sm text-left">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Nome da Categoria
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell">
Descrição
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-right">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{categories.map((category) => (
<tr
key={category._id}
className="hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-4 text-neutral-900 dark:text-neutral-100 font-medium">
{category.name}
</td>
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300">
{category.description || "-"}
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end items-center gap-2">
<Link
href={`/admin/dashboard/categories/edit/${category._id}`}
title="Editar"
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
>
<FaEdit className="text-lg" />
</Link>
<form action={action} className="inline">
<input
type="hidden"
name="categoryId"
value={category._id || "nada"}
/>
<button
type="submit"
title="Excluir"
disabled={isPending}
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
if (!confirm('Tem certeza que deseja deletar esta categoria?')) {
e.preventDefault();
}
}}
>
{isPending ? (
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<FaTrash className="text-lg" />
)}
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,111 @@
"use client";
import React, { useEffect, useState } from "react";
import { useActionState } from "react";
import saveCategoryAction from "@/app/lib/categories/saveCategoryAction";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
import { useRouter } from "next/navigation";
function CategoryForm({ category = {}, onCancel }) {
const router = useRouter();
const [showMessage, setShowMessage] = useState(true);
const initialState = {
success: false,
message: null,
};
const [state, action, isPending] = useActionState(
saveCategoryAction,
initialState
);
useEffect(() => {
if (state.message) {
setShowMessage(true);
const timer = setTimeout(() => {
setShowMessage(false);
}, 5000);
return () => clearTimeout(timer);
}
}, [state.message]);
useEffect(() => {
if (state?.success) {
router.push("/admin/dashboard/categories");
}
}, [state?.success, router]);
return (
<div className="w-full">
{state?.message && showMessage && (
<div className="max-w-screen-xl mx-auto w-full">
<FlashMessage
message={state?.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<form
action={action}
className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md max-w-lg mx-auto"
>
{isPending && <p>Carregando...</p>}
{category._id && (
<input type="hidden" name="_id" value={category._id} />
)}
<div className="mb-4">
<label htmlFor="name" className="block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2">
Nome da Categoria:
</label>
<input
type="text"
id="name"
name="name"
defaultValue={category.name || ""}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-gray-700 dark:border-gray-600"
placeholder="Ex: Documentos, Imagens, Vídeos"
required
/>
</div>
<div className="mb-4">
<label htmlFor="description" className="block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2">
Descrição:
</label>
<textarea
id="description"
name="description"
defaultValue={category.description || ""}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-gray-700 dark:border-gray-600"
rows="3"
placeholder="Descrição opcional da categoria..."
></textarea>
</div>
<div className="flex items-center justify-between">
<button
type="submit"
disabled={isPending}
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg focus:outline-none focus:shadow-outline disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isPending ? "Salvando..." : "Salvar"}
</button>
<button
type="button"
onClick={onCancel}
className="bg-gray-500 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded-lg focus:outline-none focus:shadow-outline transition-colors duration-200"
>
Cancelar
</button>
</div>
</form>
</div>
);
}
export default CategoryForm;
@@ -0,0 +1,14 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import CategoryForm from "../CategoryForm";
function AddCategory() {
return (
<div className="flex flex-col w-full">
<PageHeader title="Adicionar Categoria" subtitle="Crie uma nova categoria de arquivos" />
<CategoryForm />
</div>
);
}
export default AddCategory;
@@ -0,0 +1,35 @@
import CategoryForm from "@/app/(protected)/admin/dashboard/categories/CategoryForm";
import { getCategoryById } from "@/app/lib/helpers/getItems";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
export default async function EditCategoryPage({ params }) {
const awaitedParams = await params;
const id = awaitedParams.id;
if (!isValidObjectId(id)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
const category = await getCategoryById(id);
if (!category) {
return <div>Categoria não encontrada</div>;
}
return (
<div className="w-full mt-3">
<PageHeader
title="Editar Categoria"
subtitle="Atualize as informações da categoria"
/>
<div className="flex justify-center">
<CategoryForm category={category} />
</div>
</div>
);
}
@@ -0,0 +1,25 @@
import Link from "next/link";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import CategoriesTable from "./CategoriesTable";
import { getAllCategories } from "@/app/lib/helpers/getItems";
export default async function CategoriesPage() {
const categories = await getAllCategories();
return (
<div className="w-full mt-3">
<PageHeader
title="Gerenciar Categorias de Arquivos"
subtitle="Organize e gerencie as categorias de arquivos do sistema"
actions={
<Link href={`/admin/dashboard/categories/add`}>
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
+ Adicionar Nova Categoria
</button>
</Link>
}
/>
<CategoriesTable categories={categories} />
</div>
);
}
@@ -0,0 +1,27 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import ClassForm from "@/app/(protected)/admin/dashboard/class/components/ClassForm";
import { getUsersByRole } from "@/app/lib/users/getUsersByRole";
import { getClassTypes } from "@/app/lib/classes/getClassTypes";
async function AddClass() {
const teachersResult = await getUsersByRole(["teacher"]);
const teachers = teachersResult.success ? teachersResult.data : [];
const studentsResult = await getUsersByRole(["student"]);
const students = studentsResult.success ? studentsResult.data : [];
const classTypesResult = await getClassTypes();
const classTypes = classTypesResult.success ? classTypesResult.data : [];
return (
<div className="flex flex-col w-full px-4 sm:px-6 lg:px-8">
<PageHeader title="Adicionar Turma" subtitle="Crie uma nova turma no sistema" />
<ClassForm
classTypes={classTypes}
teachers={teachers}
students={students}
/>
</div>
);
}
export default AddClass;
@@ -0,0 +1,417 @@
"use client";
import React, { useEffect, useState } from "react";
import { useActionState } from "react";
import saveClassAction from "@/app/lib/classes/saveClassAction";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
import RoleCheckbox from "@/app/(protected)/components/shared/RoleCheckbox";
import FormCheckbox from "./FormCheckbox";
import { DAYS } from "@/app/lib/utils/days";
function ClassForm({
classData = {},
onCancel,
classTypes = [],
teachers = [],
students = [],
}) {
const initialState = { success: false, message: null };
const [state, action, isPending] = useActionState(
saveClassAction,
initialState
);
const [showMessage, setShowMessage] = useState(false);
const initialData = classData
? {
...classData,
classType:
typeof classData.classType === "object"
? String(classData.classType?._id ?? "")
: String(classData.classType ?? ""),
startDate: classData.startDate?.split("T")[0],
endDate: classData.endDate?.split("T")[0],
}
: {};
const [inputs, setInputs] = useState(state?.inputs || initialData || {});
useEffect(() => {
if (state?.inputs) {
setInputs((prev) => ({
...prev,
...state.inputs,
classType: String(state.inputs.classType ?? ""),
}));
}
}, [state?.inputs]);
useEffect(() => {
if (state.message) {
setShowMessage(true);
const timer = setTimeout(() => setShowMessage(false), 15000);
return () => clearTimeout(timer);
} else {
setShowMessage(false);
}
}, [state.message]);
const onClassTypeChange = (e) => {
const value = String(e.target.value);
const ct = classTypes.find((x) => String(x._id) === e.target.value);
setInputs((prev) => {
const next = { ...prev, classType: value, price: ct?.price ?? "" };
return next;
});
};
const onCheckboxChange = (field, value) => (e) => {
const currentArray = inputs[field] || [];
if (e.target.checked) {
setInputs({ ...inputs, [field]: [...currentArray, value] });
} else {
setInputs({ ...inputs, [field]: currentArray.filter((v) => v !== value) });
}
};
return (
<div className="w-full">
{/* FlashMessage outside form for better visibility */}
{state?.message && showMessage && (
<div className="max-w-4xl mx-auto mb-4">
<FlashMessage
message={state?.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<form action={action} className="max-w-4xl mx-auto">
{isPending && (
<div className="mb-4 p-4 bg-neutral-100 dark:bg-neutral-800 rounded-lg text-center">
<p className="text-neutral-600 dark:text-neutral-300">Salvando...</p>
</div>
)}
{classData._id && <input type="hidden" name="_id" value={classData._id} />}
{/* Hidden inputs for form submission */}
{(inputs.teachers || []).map((t) => (
<input key={`teacher-${t}`} type="hidden" name="teachers" value={t} />
))}
{(inputs.students || []).map((s) => (
<input key={`student-${s}`} type="hidden" name="students" value={s} />
))}
{(inputs.schedule?.days || []).map((d) => (
<input key={`day-${d}`} type="hidden" name="days" value={d} />
))}
<input type="hidden" name="status" value="active" />
{/* Main Form - Grid Layout */}
<div className="bg-white dark:bg-neutral-800 rounded-xl border border-neutral-200 dark:border-neutral-700 shadow-sm overflow-hidden">
{/* Header Section */}
<div className="bg-neutral-50 dark:bg-neutral-900/50 px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{classData._id ? "Editar Turma" : "Nova Turma"}
</h2>
</div>
{/* Form Content */}
<div className="p-6">
{/* 2-Column Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Tipo de Classe */}
<div className="space-y-2">
<label htmlFor="classType" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Tipo de Classe <span className="text-red-500">*</span>
</label>
<select
id="classType"
name="classType"
required
value={inputs?.classType || ""}
onChange={onClassTypeChange}
className="select-contrast w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Selecione...</option>
{classTypes.map((ct) => {
const id = typeof ct._id === "string" ? ct._id : String(ct._id);
return (
<option key={id} value={id}>
{ct.name || ct.title || ct.label || id}
</option>
);
})}
</select>
</div>
{/* Título */}
<div className="space-y-2">
<label htmlFor="classTitle" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Título <span className="text-red-500">*</span>
</label>
<input
type="text"
id="classTitle"
name="classTitle"
value={inputs?.classTitle || ""}
onChange={(e) => setInputs({ ...inputs, classTitle: e.target.value })}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Ex: Starters 1 - 2025"
/>
</div>
{/* Data de Início */}
<div className="space-y-2">
<label htmlFor="startDate" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Data de Início <span className="text-red-500">*</span>
</label>
<input
type="date"
lang="pt-BR"
id="startDate"
name="startDate"
value={inputs?.startDate || ""}
onChange={(e) => setInputs({ ...inputs, startDate: e.target.value })}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* Data de Término */}
<div className="space-y-2">
<label htmlFor="endDate" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Data de Término
</label>
<input
type="date"
lang="pt-BR"
id="endDate"
name="endDate"
value={inputs?.endDate || ""}
onChange={(e) => setInputs({ ...inputs, endDate: e.target.value })}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* Horário */}
<div className="space-y-2">
<label htmlFor="time" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Horário <span className="text-red-500">*</span>
</label>
<input
type="time"
id="time"
name="time"
value={inputs?.schedule?.time || ""}
onChange={(e) => setInputs({ ...inputs, schedule: { ...inputs.schedule, time: e.target.value } })}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* Mensalidade */}
<div className="space-y-2">
<label htmlFor="price" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Mensalidade
</label>
<input
type="text"
id="price"
name="price"
value={inputs?.price || ""}
onChange={(e) => setInputs({ ...inputs, price: e.target.value })}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Ex: 50,00"
/>
</div>
{/* Link */}
<div className="space-y-2 md:col-span-2">
<label htmlFor="link" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Link (Zoom, Meet, etc.)
</label>
<input
type="text"
id="link"
name="link"
value={inputs?.link || ""}
onChange={(e) => setInputs({ ...inputs, link: e.target.value })}
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="https://..."
/>
</div>
</div>
{/* Professores - Full Width */}
<div className="mt-6 space-y-3">
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Professores <span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-3">
{teachers.length === 0 ? (
<p className="text-sm text-amber-600 dark:text-amber-400">
Nenhum professor cadastrado.
</p>
) : (
teachers.map((teacher) => {
const id = typeof teacher._id === "string" ? teacher._id : String(teacher._id);
const isChecked = (inputs.teachers || []).some((t) => (typeof t === "string" ? t : String(t)) === id);
return (
<RoleCheckbox
key={id}
value={id}
label={teacher.fullName}
color="indigo"
checked={isChecked}
onChange={onCheckboxChange("teachers", id)}
/>
);
})
)}
</div>
</div>
{/* Alunos - Select Multi with Search (for many students) */}
<div className="mt-6 space-y-3">
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Alunos
</label>
{/* Selected Students as Removable Tags */}
{(inputs.students || []).length > 0 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-700">
{inputs.students.map((studentId) => {
const idStr = typeof studentId === "string" ? studentId : String(studentId);
const student = students.find((s) => (typeof s._id === "string" ? s._id : String(s._id)) === idStr);
return (
<span
key={idStr}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium ${
student ? "student-tag" : "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-300 dark:border-red-700"
}`}
>
{student ? student.fullName : `ID: ${idStr.slice(-6)}… (removido)`}
<button
type="button"
onClick={() => {
setInputs({
...inputs,
students: (inputs.students || []).filter((s) => (typeof s === "string" ? s : String(s)) !== idStr)
});
}}
>
×
</button>
</span>
);
})}
</div>
)}
{/* Add Students */}
<div className="flex gap-2">
<select
value=""
onChange={(e) => {
if (e.target.value) {
setInputs({
...inputs,
students: [...(inputs.students || []), e.target.value]
});
}
}}
className="select-contrast flex-1 h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
<option value="">Adicionar aluno...</option>
{students
.filter((s) => !(inputs.students || []).some((selectedId) => (typeof s._id === "string" ? s._id : String(s._id)) === (typeof selectedId === "string" ? selectedId : String(selectedId))))
.map((student) => {
const id = typeof student._id === "string" ? student._id : String(student._id);
return (
<option key={id} value={id}>
{student.fullName}
</option>
);
})
}
</select>
</div>
{students.length === 0 && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Nenhum aluno cadastrado.
</p>
)}
</div>
{/* Dias da Semana - Full Width */}
<div className="mt-6 space-y-3">
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
Dias da Semana <span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-3">
{DAYS.map((day) => {
const isChecked = (inputs.schedule?.days || []).includes(day);
return (
<RoleCheckbox
key={day}
value={day}
label={day}
color="amber"
checked={isChecked}
onChange={(e) => {
const currentDays = inputs.schedule?.days || [];
const newDays = e.target.checked
? [...currentDays, day]
: currentDays.filter((d) => d !== day);
setInputs({ ...inputs, schedule: { ...inputs.schedule, days: newDays } });
}}
/>
);
})}
</div>
</div>
{/* Herdar Arquivos - Only for new classes */}
{!classData._id && (
<div className="mt-6">
<FormCheckbox
name="inheritFiles"
label="Herdar arquivos do tipo de turma"
description="Ao criar a turma, herdam-se os arquivos daquele tipo de classe."
checked={inputs?.inheritFiles ?? true}
onChange={(e) => setInputs({ ...inputs, inheritFiles: e.target.checked })}
/>
</div>
)}
</div>
{/* Action Buttons */}
<div className="bg-neutral-50 dark:bg-neutral-900/50 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 flex gap-3 justify-end">
<button
type="button"
onClick={onCancel}
className="px-6 py-2.5 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-200 text-sm font-medium hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={isPending}
className="px-6 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending ? "Salvando..." : "Salvar"}
</button>
</div>
</div>
</form>
</div>
);
}
export default ClassForm;
@@ -0,0 +1,28 @@
import { ClassRow } from "@/app/(protected)/admin/dashboard/class/components/classRow";
export default function ClassesTable({classes}) {
return (
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
<table className="w-full text-sm text-left">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">Título</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell">Professores</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden lg:table-cell">Início</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-center">Status</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{classes.map((classItem) => (
<ClassRow
key={classItem._id}
classData={classItem}
/>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,117 @@
"use client";
import { useState, useEffect } from "react";
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
ComboboxButton,
} from "@headlessui/react";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/solid";
import { DAYS } from "@/app/lib/utils/days";
export default function DaysMultiSelect({ defaultSelectedDays = [], onRemove }) {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState(
DAYS.filter((d) => defaultSelectedDays.includes(d))
);
useEffect(() => {
const newSelected = DAYS.filter((d) => defaultSelectedDays.includes(d));
setSelected(newSelected);
}, [defaultSelectedDays]);
const filtered =
query === ""
? DAYS
: DAYS.filter((day) =>
day.toLowerCase().includes(query.toLowerCase())
);
const handleRemove = (day) => {
setSelected((prev) => prev.filter((d) => d !== day));
onRemove && onRemove(day);
};
return (
<div className="mb-4">
<label className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Dias da Semana <span className="text-red-500">*</span>:
</label>
<Combobox
multiple
value={selected}
onChange={setSelected}
onClose={() => setQuery("")}
>
<div className="relative">
<div
className="relative max-w-lg cursor-default overflow-hidden rounded border
border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700
text-left focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<div className="flex flex-wrap gap-1 p-2">
{selected.map((d) => (
<span
key={d}
className="flex items-center gap-1 rounded-full bg-indigo-600 px-2 py-0.5 text-xs
text-white dark:bg-indigo-900/40 dark:text-indigo-300"
>
{d}
<button
type="button"
className="rounded p-0.5 hover:bg-indigo-200 dark:hover:bg-indigo-800"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleRemove(d)}
aria-label={`Remover ${d}`}
>
<CheckIcon className="h-3 w-3 rotate-45" />
</button>
</span>
))}
</div>
<ComboboxInput
aria-label="days"
className="w-full border-none py-2 pl-3 pr-8 text-neutral-900 dark:text-neutral-200 bg-transparent focus:outline-none"
onChange={(e) => setQuery(e.target.value)}
placeholder="Selecione os dias..."
/>
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-neutral-400" />
</ComboboxButton>
</div>
<ComboboxOptions
anchor={{ to: "bottom", gap: "0.5rem" }}
className="border mt-1 max-h-60 overflow-auto rounded
border-neutral-200 dark:border-neutral-600 bg-white dark:bg-neutral-800 shadow-lg"
>
{filtered.map((day) => (
<ComboboxOption
key={day}
value={day}
className="data-[focus]:bg-indigo-600 data-[focus]:dark:bg-indigo-600
data-[focus]:dark:text-white cursor-pointer select-none px-3 py-2"
>
{({ selected }) => (
<div className="flex items-center justify-between">
<span>{day}</span>
{selected && <CheckIcon className="ml-1 h-4 w-4" />}
</div>
)}
</ComboboxOption>
))}
</ComboboxOptions>
</div>
</Combobox>
{selected.map((day) => (
<input key={day} type="hidden" name="days" value={day} />
))}
</div>
);
}
@@ -0,0 +1,25 @@
"use client";
import { FaTrash } from "react-icons/fa";
import { deleteClass } from "@/app/lib/classes/deleteClass";
export default function DeleteClassButton({ classId, classTitle }) {
return (
<form action={deleteClass} className="inline">
<input type="hidden" name="classId" value={classId} />
<button
type="submit"
title="Excluir"
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
aria-label={`Excluir turma ${classTitle}`}
onClick={(e) => {
if (!confirm('Tem certeza que deseja deletar esta turma?')) {
e.preventDefault();
}
}}
>
<FaTrash className="text-lg" />
</button>
</form>
);
}
@@ -0,0 +1,50 @@
export default function FormCheckbox({
id,
name,
label,
description,
checked,
onChange,
defaultChecked,
required = false,
disabled = false,
className = "",
}) {
const inputId = id || name;
return (
<div className={`mb-4 ${className}`}>
<div className="flex items-start gap-3">
<input
id={inputId}
name={name}
type="checkbox"
{...(checked !== undefined
? { checked, onChange }
: { defaultChecked })}
required={required}
disabled={disabled}
aria-describedby={description ? `${inputId}-desc` : undefined}
className="mt-1 h-5 w-5 rounded border-neutral-300 dark:border-neutral-600
bg-white dark:bg-neutral-700
accent-indigo-600
focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400
disabled:opacity-50"
/>
<div className="leading-tight">
<label
htmlFor={inputId}
className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold"
>
{label}
</label>
{description && (
<p id={`${inputId}-desc`} className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
{description}
</p>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import { FaPowerOff } from "react-icons/fa";
import { toggleClassStatus } from "@/app/lib/classes/toggleClassStatus";
import { useState } from "react";
export default function ToggleClassStatusButton({ classId, classTitle, isActive }) {
const [isPending, setIsPending] = useState(false);
const handleToggle = async (e) => {
e.preventDefault();
const action = isActive ? "desativar" : "ativar";
if (!confirm(`Tem certeza que deseja ${action} a turma "${classTitle}"?`)) {
return;
}
setIsPending(true);
try {
const formData = new FormData();
formData.append("classId", classId);
const result = await toggleClassStatus(formData);
if (result.success) {
// Reload the page to show updated status
window.location.reload();
} else {
alert(result.message || "Erro ao alterar status da turma.");
}
} catch (error) {
alert("Erro ao alterar status da turma.");
} finally {
setIsPending(false);
}
};
return (
<button
type="button"
onClick={handleToggle}
disabled={isPending}
title={isActive ? "Desativar" : "Ativar"}
className={`p-2 transition-colors ${
isActive
? "text-emerald-500 hover:text-emerald-700 dark:hover:text-emerald-300"
: "text-neutral-400 hover:text-emerald-600 dark:hover:text-emerald-400"
} ${isPending ? "opacity-50 cursor-not-allowed" : ""}`}
aria-label={`${isActive ? "Desativar" : "Ativar"} turma ${classTitle}`}
>
<FaPowerOff className="text-lg" />
</button>
);
}
@@ -0,0 +1,110 @@
"use client";
import { useState, useEffect } from "react";
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
ComboboxButton,
} from "@headlessui/react";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/solid";
export default function UsersMultiSelect({
label = "Usuários",
inputName = "users[]",
users = [],
defaultSelectedIds = [],
onRemove,
onSelectionChange,
}) {
const [query, setQuery] = useState("");
const selected = defaultSelectedIds
.map((id) => users.find((us) => us._id === id))
.filter(Boolean);
const filtered =
query === ""
? users
: users.filter((u) =>
u.fullName.toLowerCase().includes(query.toLowerCase())
);
return (
<div className="mb-4">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-2 block">
{label}
</label>
<Combobox
multiple
value={selected}
onChange={(newSelected) => onSelectionChange(newSelected.map(u => u._id))}
onClose={() => setQuery("")}
>
<div className="relative">
<div
className="relative max-w-200 cursor-default overflow-hidden rounded-md border border-input bg-background text-left focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1 p-1.5">
{selected.map((u) => (
<span
key={u._id}
className="inline-flex items-center gap-1 rounded-full bg-secondary px-2.5 py-0.5 text-xs font-semibold text-secondary-foreground"
>
{u.fullName}
<button
type="button"
className="rounded-full p-0.5 hover:bg-muted"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onRemove(u._id, inputName)}
aria-label={`Remover ${u.fullName}`}
>
<CheckIcon className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<ComboboxInput
aria-label={label}
displayValue={(user) => user?.fullName}
onChange={(event) => setQuery(event.target.value)}
className="w-full border-none bg-transparent py-2 pl-3 pr-8 text-sm outline-none placeholder:text-muted-foreground"
/>
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted-foreground" />
</ComboboxButton>
</div>
<ComboboxOptions
anchor={{ to: "bottom", gap: "0.25rem" }}
className="border rounded-md shadow-lg max-h-60 overflow-auto bg-popover text-popover-foreground"
>
{filtered.map((u) => (
<ComboboxOption
key={u._id}
value={u}
className="data-[focus]:bg-accent data-[focus]:text-accent-foreground cursor-pointer select-none px-3 py-2"
>
{({ selected }) => (
<div className="flex items-center justify-between">
<span className="truncate">{u.fullName}</span>
{selected && <CheckIcon className="h-4 w-4" />}
</div>
)}
</ComboboxOption>
))}
{filtered.length === 0 && query !== "" && (
<div className="py-2 px-3 text-sm text-muted-foreground">
Nenhum resultado encontrado
</div>
)}
</ComboboxOptions>
</div>
</Combobox>
{selected.map((s) => (
<input key={s._id} type="hidden" name={inputName} value={s._id} />
))}
</div>
);
}
@@ -0,0 +1,71 @@
import { getUserModel } from "@/app/models/User";
import { getFieldItemByItem } from "@/app/lib/helpers/getItems"
import { FaEdit, FaTrash } from "react-icons/fa";
import Link from "next/link";
import { FaFileCirclePlus } from "react-icons/fa6";
import DeleteClassButton from "./DeleteClassButton";
import { deleteClass } from "@/app/lib/classes/deleteClass";
import Label from "@/app/(protected)/components/shared/Label";
import ToggleClassStatusButton from "./ToggleClassStatusButton";
export async function ClassRow({ classData }) {
const User = await getUserModel();
return (
<tr className="hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
<td className="px-6 py-4 text-neutral-900 dark:text-neutral-100 font-medium">
<Link
href={`/admin/dashboard/class/files/${classData._id}`}
className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors underline-offset-2 hover:underline"
title="Abrir dashboard da turma"
>
{classData?.classTitle}
</Link>
</td>
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300">
{(
await Promise.all(
classData.teachers.map((t) =>
getFieldItemByItem(User, t._id, "fullName")
)
)
).join(", ")}
</td>
<td className="px-6 py-4 hidden lg:table-cell text-neutral-700 dark:text-neutral-300">
{new Date(classData.startDate).toLocaleDateString('pt-BR')}
</td>
<td className="px-6 py-4 text-center">
<Label
color={classData.status === 'active' ? 'emerald' : 'red'}
size="sm"
>
{classData.status === 'active' ? 'Ativa' : 'Inativa'}
</Label>
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end items-center gap-2">
<ToggleClassStatusButton
classId={classData._id.toString()}
classTitle={classData.classTitle}
isActive={classData.status === 'active'}
/>
<Link
href={`/admin/dashboard/files/class/${classData._id}/add`}
title="Adicionar Arquivo"
className="p-2 text-neutral-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<FaFileCirclePlus className="text-lg" />
</Link>
<Link
href={`/admin/dashboard/class/edit/${classData._id}`}
title="Editar"
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
>
<FaEdit className="text-lg" />
</Link>
<DeleteClassButton classId={classData._id.toString()} classTitle={classData.classTitle} />
</div>
</td>
</tr>
);
}
@@ -0,0 +1,55 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import ClassForm from "@/app/(protected)/admin/dashboard/class/components/ClassForm";
import {getUsersByRole} from "@/app/lib/users/getUsersByRole";
import {getClassTypes} from "@/app/lib/classes/getClassTypes";
import {getClassModel} from "@/app/models/Class";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
async function EditClass({params}) {
const {id} = await params;
if (!isValidObjectId(id)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
const teachersResult = await getUsersByRole(["teacher"]);
const teachers = teachersResult.success ? teachersResult.data : [];
const studentsResult = await getUsersByRole(["student"]);
const students = studentsResult.success ? studentsResult.data : [];
const classTypesResult = await getClassTypes();
const classTypes = classTypesResult.success ? classTypesResult.data : [];
const Class = await getClassModel();
const classData = await Class.findOne({_id: id}).lean();
if (!classData) {
return (
<div className="flex flex-col w-full text-center p-8">
<PageHeader title="Turma não encontrada..." />
<p className="text-gray-600 dark:text-gray-400">
A turma que você está tentando editar não existe ou foi excluída.
</p>
</div>
);
}
//TODO usar a função toPlain
const plainClassData = JSON.parse(JSON.stringify(classData));
return (
<div className="flex flex-col w-full">
<PageHeader title="Editar Turma" subtitle="Atualize as informações da turma" />
<ClassForm
classData={plainClassData || null}
classTypes={classTypes}
teachers={teachers}
students={students}
/>
</div>
);
}
export default EditClass;
@@ -0,0 +1,164 @@
import mongoose from "mongoose";
import MainSection from "@/app/(protected)/components/shared/Main";
import ClassDetail from "@/app/(protected)/components/teacher/ClassDetail";
import { getClassModel } from "@/app/models/Class";
import { getFileModel } from "@/app/models/FilesSchema";
import { getLessonModel } from "@/app/models/Lesson";
import { getExamAttemptModel } from "@/app/models/ExamAttempt";
import { getUserModel } from "@/app/models/User";
import { toPlain } from "@/app/lib/helpers/toPlain";
import { retrieveFiles } from "@/app/lib/helpers/retriveFiles";
async function ClassFilesPage({ params }) {
const { id: classId } = await params;
if (!mongoose.Types.ObjectId.isValid(classId)) {
return (
<MainSection>
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Turma não encontrada.</p>
</div>
</MainSection>
);
}
await getFileModel();
await getUserModel();
await getLessonModel();
await getExamAttemptModel();
const ClassModel = await getClassModel();
try {
const theClass = await ClassModel
.findById(classId)
.populate([
{
path: "files",
select: "title url size description uploadedAt mimetype category",
populate: {
path: "category",
select: "name colorIndex"
}
},
{
path: "students",
select: "fullName"
},
{
path: "teachers",
select: "fullName"
}
])
.lean();
if (!theClass) {
return <div>Class not found.</div>;
}
const plainClassData = toPlain(theClass);
const rawFiles = Array.isArray(plainClassData?.files) ? plainClassData.files : [];
const filesData = retrieveFiles(rawFiles);
const totalStudents = new Set((plainClassData?.students || []).map((s) => s._id)).size;
const LessonModel = await getLessonModel();
const lessons = await LessonModel.find({ classId }).sort({ date: -1 }).lean();
const plainLessons = toPlain(lessons);
const classStudentIds = new Set((plainClassData?.students || []).map((s) => s._id));
let totalPresent = 0;
let totalLate = 0;
let totalAttendanceRecords = 0;
const studentAttendance = {};
plainLessons.forEach((lesson) => {
lesson.attendance?.forEach((a) => {
const attendanceStudentId = a?.studentId?._id || a?.studentId;
if (classStudentIds.has(attendanceStudentId)) {
totalAttendanceRecords += 1;
if (a.status === "present") totalPresent += 1;
if (a.status === "late") totalLate += 1;
if (!studentAttendance[attendanceStudentId]) {
studentAttendance[attendanceStudentId] = { present: 0, late: 0, total: 0 };
}
studentAttendance[attendanceStudentId].total += 1;
if (a.status === "present") studentAttendance[attendanceStudentId].present += 1;
if (a.status === "late") studentAttendance[attendanceStudentId].late += 1;
}
});
});
const attendanceRate = totalAttendanceRecords > 0
? Math.round(((totalPresent + totalLate) / totalAttendanceRecords) * 100)
: 0;
const ExamAttempt = await getExamAttemptModel();
const attempts = await ExamAttempt.find({ classId }).lean();
const plainAttempts = toPlain(attempts);
const studentExamStats = {};
plainAttempts.forEach((attempt) => {
const studentId = attempt.studentId?._id || attempt.studentId;
if (!studentExamStats[studentId]) {
studentExamStats[studentId] = { totalScore: 0, totalPoints: 0 };
}
if (attempt.score !== undefined && attempt.totalPoints) {
studentExamStats[studentId].totalScore += attempt.score;
studentExamStats[studentId].totalPoints += attempt.totalPoints;
}
});
const studentsWithData = (plainClassData?.students || []).map((student) => {
const att = studentAttendance[student._id] || { present: 0, late: 0, total: 0 };
const studentAttRate = att.total > 0 ? Math.round(((att.present + att.late) / att.total) * 100) : 0;
const examStats = studentExamStats[student._id] || { totalScore: 0, totalPoints: 0 };
const avgScore = examStats.totalPoints > 0
? (examStats.totalScore / examStats.totalPoints * 10).toFixed(1)
: "-";
return {
...student,
attendanceRate: studentAttRate,
avgScore,
};
});
const cls = {
id: classId,
classTitle: plainClassData?.classTitle || "",
teachers: plainClassData?.teachers?.map(t => t.fullName) || [],
status: plainClassData?.status,
schedule: { days: plainClassData?.schedule?.days || [], time: plainClassData?.schedule?.time || [] },
stats: {
students: totalStudents,
attendanceRate,
avgScore: "-",
pendingSubmissions: plainClassData?.pendingSubmissions || 0,
},
materials: filesData.files || {}, // Pass only the grouped files
};
return (
<MainSection>
<ClassDetail
clsData={cls}
filesData={filesData.files || {}}
categories={filesData.categories || {}}
students={studentsWithData}
classId={classId}
uploadAddPath={`/admin/dashboard/files/class/${classId}/add`}
historyPath={`/admin/dashboard/class/history/${classId}`}
assignmentResultsBasePath="/admin/dashboard"
/>
</MainSection>
);
} catch (err) {
console.error("Error fetching class files:", err);
return <div>Error loading files.</div>;
}
}
export default ClassFilesPage;
@@ -0,0 +1,51 @@
import MainSection from "@/app/(protected)/components/shared/Main";
import ClassHistoryList from "@/app/(protected)/components/teacher/ClassHistoryList";
import { getClassModel } from "@/app/models/Class";
import { getLessonModel } from "@/app/models/Lesson";
import { toPlain } from "@/app/lib/helpers/toPlain";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
import { getUserModel } from "@/app/models/User";
export default async function AdminClassHistoryPage({ params }) {
const { id } = await params;
if (!isValidObjectId(id)) {
return (
<MainSection>
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
</MainSection>
);
}
const ClassModel = await getClassModel();
const LessonModel = await getLessonModel();
await getUserModel();
const classData = await ClassModel.findById(id)
.populate("students", "fullName _id")
.lean();
const plainClassData = toPlain(classData);
const lessons = await LessonModel.find({ classId: id })
.sort({ date: -1 })
.populate("teacherId", "fullName")
.populate("attendance.studentId", "fullName")
.lean();
const plainLessons = toPlain(lessons);
return (
<MainSection>
<ClassHistoryList
classId={id}
classTitle={plainClassData?.classTitle || ""}
lessons={plainLessons}
totalStudents={plainClassData?.students?.length || 0}
students={plainClassData?.students || []}
backHref={`/admin/dashboard/class/files/${id}`}
/>
</MainSection>
);
}
@@ -0,0 +1,33 @@
import Link from "next/link";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { getClassModel } from "@/app/models/Class";
import ClassesTable from "./components/ClassesTable";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
export default async function ClassesPage({ searchParams }) {
const params = await searchParams;
const Classes = await getClassModel();
const classes = await Classes.find({}).lean();
return (
<div className="w-full mt-3">
<PageHeader
title="Gerenciar Turmas"
subtitle="Gerencie as turmas ativas atualmente, bem como as arquivadas"
actions={
<Link href="/admin/dashboard/class/add">
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
+ Adicionar Nova Turma
</button>
</Link>
}
/>
{params?.warn && (
<div className="mb-4">
<FlashMessage message={decodeURIComponent(params.warn)} type="warning" />
</div>
)}
<ClassesTable classes={classes} />
</div>
);
}
@@ -0,0 +1,139 @@
"use client";
import React, { useEffect, useState } from "react";
import { useActionState } from "react";
import saveClassTypeAction from "@/app/lib/classes/saveClassTypeAction";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
import { useRouter } from "next/navigation";
function ClassTypeForm({ classType = {}, onCancel }) {
const router = useRouter();
const [showMessage, setShowMessage] = useState(true);
const initialState = {
success: false,
message: null,
};
const [state, action, isPending] = useActionState(
saveClassTypeAction,
initialState
);
useEffect(() => {
if (state.message) {
setShowMessage(true);
const timer = setTimeout(() => {
setShowMessage(false);
}, 5000);
return () => clearTimeout(timer);
}
}, [state.message]);
useEffect(() => {
if (state?.success && state.redirectTo) {
router.push(state.redirectTo);
}
}, [state?.success, state?.redirectTo, router]);
return (
<div className="w-full">
{state?.message && showMessage && (
<div className="max-w-screen-xl mx-auto w-full">
<FlashMessage
message={state?.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<form
action={action}
className="bg-white dark:bg-neutral-800 p-8 rounded-lg shadow-md max-w-lg mx-auto"
>
{isPending && <p>Carregando...</p>}
{classType._id && (
<input type="hidden" name="_id" value={classType._id} />
)}
<div className="mb-4">
<label htmlFor="title" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Título:
</label>
<input
type="text"
id="title"
name="title"
defaultValue={classType.title || ""}
className="shadow appearance-none border rounded w-full py-2 px-3 text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-700 dark:border-neutral-600"
placeholder="Ex: Aula de Gramática Avançada"
/>
</div>
<div className="mb-4">
<label htmlFor="description" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Descrição:
</label>
<textarea
id="description"
name="description"
defaultValue={classType.description || ""}
className="shadow appearance-none border rounded w-full py-2 px-3 text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-700 dark:border-neutral-600"
rows="3"
placeholder="Detalhes sobre o conteúdo da aula..."
></textarea>
</div>
<div className="mb-4">
<label htmlFor="ageRange" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Faixa Etária:
</label>
<input
type="text"
id="ageRange"
name="ageRange"
defaultValue={classType.ageRange || ""}
className="shadow appearance-none border rounded w-full py-2 px-3 text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-700 dark:border-neutral-600"
placeholder="Ex: 8-12 anos, Adultos, etc."
/>
</div>
<div className="mb-6">
<label htmlFor="price" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
Preço:
</label>
<input
type="number"
id="price"
name="price"
step="0.01"
defaultValue={classType.price || ""}
className="shadow appearance-none border rounded w-full py-2 px-3 text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-700 dark:border-neutral-600"
placeholder="Ex: 50.00"
/>
</div>
<div className="flex items-center justify-between">
<button
type="submit"
disabled={isPending}
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg focus:outline-none focus:shadow-outline disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{isPending ? "Salvando..." : "Salvar"}
</button>
<button
type="button"
onClick={onCancel}
className="bg-neutral-500 hover:bg-neutral-600 text-white font-bold py-2 px-4 rounded-lg focus:outline-none focus:shadow-outline transition-colors duration-200"
>
Cancelar
</button>
</div>
</form>
</div>
);
}
export default ClassTypeForm;
@@ -0,0 +1,138 @@
"use client";
import { deleteClassTypeAction } from "@/app/lib/classes/deleteClassType";
import { useActionState, useEffect, useState } from "react";
import Link from "next/link";
import { FaEdit, FaTrash } from "react-icons/fa";
import { FaFileCirclePlus } from "react-icons/fa6";
import { LuFileStack } from "react-icons/lu";
import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils";
import Label from "@/app/(protected)/components/shared/Label";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
export default function ClassTypesTable({ classTypes }) {
const initialState = { success: false, message: null };
const [state, action, isPending] = useActionState(
deleteClassTypeAction,
initialState
);
const [showMessage, setShowMessage] = useState(false);
useEffect(() => {
if (state?.message) {
setShowMessage(true);
const timer = setTimeout(() => setShowMessage(false), 5000);
return () => clearTimeout(timer);
}
}, [state?.message]);
return (
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
{showMessage && state?.message && (
<div className="px-6 pt-4">
<FlashMessage
message={state.message}
type={state.success ? "success" : "error"}
/>
</div>
)}
<table className="w-full text-sm">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs align-top">
<tr>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
Tipo de Turma
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell text-left">
Faixa Etária
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell text-left min-w-[120px]">
Preço
</th>
<th className="px-4 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center whitespace-nowrap w-px">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800 align-top">
{classTypes.map((type) => (
<tr
key={type._id}
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-3.5 text-neutral-900 dark:text-neutral-100 font-medium align-top text-left">
{type.title}
</td>
<td className="px-6 py-3.5 hidden md:table-cell text-neutral-700 dark:text-neutral-300 align-top">
{type.ageRange || "-"}
</td>
<td className="px-6 py-3.5 hidden md:table-cell text-neutral-700 dark:text-neutral-300 align-top whitespace-nowrap">
<Label color="emerald" size="md" className="gap-1">
<span className="text-xs">R$</span>
{type.price?.toFixed(2) || "-"}
</Label>
</td>
<td className="px-4 py-3.5 align-top whitespace-nowrap">
<div className="flex items-center gap-2">
<Link
href={`/admin/dashboard/files/${relatedToTitleUrl(
"classTypes"
)}/${type._id}/add`}
title="Adicionar Arquivo"
className="p-2 text-neutral-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<FaFileCirclePlus className="text-lg" />
</Link>
<Link
href={`/admin/dashboard/${relatedToTitleUrl(
"classTypes"
)}/files/${type._id}`}
title="Ver Arquivos"
className="p-2 text-neutral-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
<LuFileStack className="text-lg" />
</Link>
<Link
href={`/admin/dashboard/${relatedToTitleUrl(
"classTypes"
)}/edit/${type._id.toString()}`}
title="Editar"
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
>
<FaEdit className="text-lg" />
</Link>
<form action={action} className="inline">
<input
type="hidden"
name="_id"
value={type._id || "nada"}
/>
<button
type="submit"
title="Excluir"
disabled={isPending}
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
if (!confirm('Tem certeza que deseja deletar este tipo de turma?')) {
e.preventDefault();
}
}}
>
{isPending ? (
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<FaTrash className="text-lg" />
)}
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,5 @@
import ClassTypeForm from "../ClassTypeForm";
export default function AddClassTypeForm() {
return <ClassTypeForm />;
}
@@ -0,0 +1,14 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import AddClassTypeForm from "./AddClassTypeForm";
function AddClassType() {
return (
<div className="flex flex-col w-full">
<PageHeader title="Adicionar Tipo de Turma" subtitle="Crie um novo tipo de turma" />
<AddClassTypeForm />
</div>
);
}
export default AddClassType;
@@ -0,0 +1,41 @@
import ClassTypeForm from "@/app/(protected)/admin/dashboard/classTypes/ClassTypeForm";
import { getTypeClassAsPlainObject } from "@/app/lib/helpers/getItems";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
export default async function EditClassTypePage({ params }) {
const awaitedParams = await params;
const id = awaitedParams.id;
if (!isValidObjectId(id)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
const classType = await getTypeClassAsPlainObject(id);
if (!classType) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
classType._id = classType._id.toString();
return (
<div className="w-full mt-3">
<PageHeader
title="Editar Tipo de Turma"
subtitle="Atualize as informações do tipo de turma"
/>
<div className="flex justify-center">
<ClassTypeForm classType={classType} />
</div>
</div>
);
}
@@ -0,0 +1,74 @@
import { getClassTypeModel } from "@/app/models/ClassType";
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import Link from "next/link";
import FilesTable from "@/app/(protected)/admin/dashboard/components/FilesTable";
import { getFileModel } from "@/app/models/FilesSchema";
import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils";
import { getFileUrl } from "@/app/lib/utils/storage/fileUrl";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
async function ClassTypeFilesPage({ params }) {
const { id: classTypeId } = await params;
if (!isValidObjectId(classTypeId)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
await getFileModel();
const classTypeModel = await getClassTypeModel();
try {
const classType = await classTypeModel
.findById(classTypeId)
.populate("files")
.lean();
if (!classType) {
return <div>Class Type not found.</div>;
}
const simplifiedFiles = classType.files.map((file) => ({
...file,
_id: file._id.toString(),
uploadedBy: file.uploadedBy.toString(),
modifiedAt: file.modifiedAt.toString(),
relatedToId: file.relatedToId ? file.relatedToId.toString() : null,
category: file.category ? file.category.toString() : null,
url: getFileUrl(file.url), // Converte URL para proxy
}));
return (
<div className="w-full mt-3">
<PageHeader
title={`Arquivos da Classe ${classType.title}`}
subtitle="Gerencie os arquivos associados a este tipo de turma"
actions={
<Link
href={`/admin/dashboard/files/${relatedToTitleUrl(
"classTypes"
)}/${classType._id}/add`}
>
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
Adicionar Arquivo
</button>
</Link>
}
/>
<div>
<FilesTable files={simplifiedFiles}></FilesTable>
</div>
</div>
);
} catch (err) {
console.error("Error fetching class type and files:", err);
return <div>Error loading files.</div>;
}
}
export default ClassTypeFilesPage;
@@ -0,0 +1,22 @@
import Link from "next/link";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils";
export default async function ClassTypesPage() {
return (
<div className="w-full mt-3">
<PageHeader
title="Arquivos de Tipos de Turmas"
subtitle="Gerencie os arquivos associados aos tipos de turmas"
actions={
<Link href={`/admin/dashboard/${relatedToTitleUrl("classTypes")}/add`}>
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
+ Adicionar Novo Tipo
</button>
</Link>
}
/>
</div>
);
}
@@ -0,0 +1,26 @@
import Link from "next/link";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import ClassTypesTable from "./ClassTypesTable";
import { getAllClassItems } from "@/app/lib/helpers/getItems";
import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils";
export default async function ClassTypesPage() {
const classTypes = await getAllClassItems();
return (
<div className="w-full mt-3">
<PageHeader
title="Gerenciar Tipos de Turmas"
subtitle="Gerencie os tipos de turmas, como 'Starters' etc."
actions={
<Link href={`/admin/dashboard/${relatedToTitleUrl("classTypes")}/add`}>
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
+ Adicionar Novo Tipo
</button>
</Link>
}
/>
<ClassTypesTable classTypes={classTypes} />
</div>
);
}
@@ -0,0 +1,177 @@
"use client";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useTransition } from "react";
import { FaEdit, FaTrash, FaDownload } from "react-icons/fa";
import { IoCalendarOutline } from "react-icons/io5";
import { usePathname } from "next/navigation";
import { deleteFile } from "@/app/lib/generalActions/deleteFile";
export default function FilesTable({ files, relatedTo, relatedToId }) {
const [clientFiles, setClientFiles] = useState(files);
const router = useRouter();
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
const [confirmationFile, setConfirmationFile] = useState(null);
const [errorMessage, setErrorMessage] = useState(null);
const onEditFile = (id) => {
router.push(`/admin/dashboard/files/edit/${id.toString()}`);
};
const handleDeleteFile = (file) => {
// Always show confirmation dialog first
setConfirmationFile(file);
};
const handleConfirmDelete = () => {
if (!confirmationFile) return;
startTransition(async () => {
const result = await deleteFile({
_id: confirmationFile._id,
redirectTo: pathname,
relatedTo,
relatedToId,
force: true,
});
if (result.success) {
setClientFiles((prev) => prev.filter((file) => file._id !== confirmationFile._id));
setConfirmationFile(null);
setErrorMessage(null);
} else {
setErrorMessage(result.message || "Erro ao deletar arquivo");
setConfirmationFile(null);
}
});
};
const handleCancelDelete = () => {
setConfirmationFile(null);
setErrorMessage(null);
};
return (
<>
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
<table className="w-full text-sm text-left">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Título do Arquivo
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell">
Tipo
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell text-center">
Tamanho
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden lg:table-cell text-center">
Enviado
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-right">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{clientFiles &&
clientFiles.map((file) => (
<tr
key={file._id}
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-4 text-neutral-900 dark:text-neutral-100 font-medium">
{file.title}
</td>
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300">
{file.mimetype || "-"}
</td>
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300 text-center">
{file.size ? `${(file.size / 1024).toFixed(2)} KB` : "-"}
</td>
<td className="px-6 py-4 hidden lg:table-cell text-neutral-700 dark:text-neutral-300 text-center">
<span className="inline-flex items-center gap-1.5">
<IoCalendarOutline className="text-neutral-400" />
{new Date(file.uploadedAt).toLocaleDateString("pt-BR")}
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end items-center gap-2">
{file.url && (
<a
href={file.url}
target="_blank"
rel="noopener noreferrer"
title="Download"
className="p-2 text-neutral-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<FaDownload className="text-lg" />
</a>
)}
<button
type="button"
title="Editar"
onClick={() => onEditFile(file._id)}
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
>
<FaEdit className="text-lg" />
</button>
<button
type="button"
title="Excluir"
disabled={isPending}
onClick={() => handleDeleteFile(file)}
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
>
<FaTrash className="text-lg" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Error Message */}
{errorMessage && (
<div className="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-800 dark:text-red-200">{errorMessage}</p>
</div>
)}
{/* Confirmation Modal */}
{confirmationFile && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-neutral-800 rounded-xl p-6 max-w-md mx-4 shadow-xl">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
Confirmar Exclusão
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
Tem certeza que deseja excluir o arquivo <span className="font-semibold">"{confirmationFile.title}"</span>? Esta ação não pode ser desfeita.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={handleCancelDelete}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-white dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-600 transition-colors disabled:opacity-50"
>
Cancelar
</button>
<button
onClick={handleConfirmDelete}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>
{isPending ? "Excluindo..." : "Confirmar Exclusão"}
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,41 @@
"use client";
import { useEffect } from "react";
export default function Error({ error, reset }) {
useEffect(() => {
console.error("Erro no painel administrativo:", error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] p-8">
<div className="max-w-md w-full text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<svg className="h-8 w-8 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100 mb-2">
Erro no painel
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-6">
Ocorreu um erro ao carregar os dados do painel administrativo. Tente novamente.
</p>
<div className="flex gap-3 justify-center">
<button
onClick={reset}
className="px-5 py-2.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
>
Tentar novamente
</button>
<button
onClick={() => window.location.href = "/admin/dashboard"}
className="px-5 py-2.5 text-sm font-medium text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
>
Painel admin
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,66 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import TemplateList from "@/app/(protected)/components/exam-templates/TemplateList";
import TemplateForm from "@/app/(protected)/components/exam-templates/TemplateForm";
export default function ExamTemplatesPage({ initialTemplates }) {
const [templates, setTemplates] = useState(initialTemplates);
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState(null);
// Callback to update templates from child components
const updateTemplates = useCallback((updater) => {
setTemplates(updater);
}, []);
useEffect(() => {
const handleOpenForm = (e) => {
setEditingTemplate(e.detail);
setIsFormOpen(true);
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingTemplate(null);
};
const handleTemplatesUpdate = (e) => {
if (e.detail) {
setTemplates(e.detail);
}
};
window.addEventListener('open-template-form', handleOpenForm);
window.addEventListener('closeTemplateModal', handleCloseForm);
window.addEventListener('templates-updated', handleTemplatesUpdate);
return () => {
window.removeEventListener('open-template-form', handleOpenForm);
window.removeEventListener('closeTemplateModal', handleCloseForm);
window.removeEventListener('templates-updated', handleTemplatesUpdate);
};
}, []);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">Templates de Provas</h1>
</div>
<div className="grid gap-6">
<div>
<TemplateList
templates={templates}
setTemplates={updateTemplates}
/>
</div>
<div>
<TemplateForm
isOpen={isFormOpen}
template={editingTemplate}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
import { getExamTemplates } from '@/app/lib/actions/examActions';
import ExamTemplatesPage from './ExamTemplatesPage';
export default async function Page() {
const result = await getExamTemplates();
const templates = result.success ? result.data : [];
return <ExamTemplatesPage initialTemplates={templates} />;
}
@@ -0,0 +1,56 @@
import Link from "next/link";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import {
AcademicCapIcon,
DocumentDuplicateIcon,
ChartBarIcon,
} from "@heroicons/react/24/outline";
export default function ExamsPage() {
return (
<div className="space-y-6">
<PageHeader title="Gerenciamento de Provas" subtitle="Gerencie as provas, modelos, atribuições e estatísticas" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Link
href="/admin/dashboard/exam-templates"
className="p-6 border border-neutral-200 dark:border-neutral-800 rounded-lg hover-card-light"
>
<div className="flex items-center gap-3 mb-3">
<AcademicCapIcon className="w-8 h-8 text-indigo-600 dark:text-indigo-400" />
<h2 className="text-lg font-semibold">Modelos de Prova</h2>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Crie e gerencie modelos de prova reutilizáveis com questões
</p>
</Link>
<Link
href="/admin/dashboard/assignments"
className="p-6 border border-neutral-200 dark:border-neutral-800 rounded-lg hover-card-light"
>
<div className="flex items-center gap-3 mb-3">
<DocumentDuplicateIcon className="w-8 h-8 text-emerald-600 dark:text-emerald-400" />
<h2 className="text-lg font-semibold">Atribuições de Provas</h2>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Atribua modelos de prova às turmas com cronogramas e configurações
</p>
</Link>
<Link
href="/admin/dashboard/statistics/exams"
className="p-6 border border-neutral-200 dark:border-neutral-800 rounded-lg hover-card-light"
>
<div className="flex items-center gap-3 mb-3">
<ChartBarIcon className="w-8 h-8 text-amber-600 dark:text-amber-400" />
<h2 className="text-lg font-semibold">Estatísticas de Provas</h2>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Visualize estatísticas agregadas e métricas de desempenho
</p>
</Link>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import FileUploadComponent from "@/app/(protected)/admin/dashboard/files/components/FileUploadForm";
async function AddFileClass({ params }) {
const { relatedToType, relatedToId } = await params;
console.log("Rel type: ", relatedToType)
return (
<div className="flex flex-col w-full">
<PageHeader title="Adicionar Arquivo" subtitle="Faça upload de um novo arquivo" />
<FileUploadComponent relType={relatedToType} relId={relatedToId} />
</div>
);
}
export default AddFileClass;
@@ -0,0 +1,14 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import FileUploadComponent from "@/app/(protected)/admin/dashboard/files/components/FileUploadForm";
function AddFileClass() {
return (
<div className="flex flex-col w-full">
<PageHeader title="Adicionar Arquivo" subtitle="Faça upload de um novo arquivo" />
<FileUploadComponent />
</div>
);
}
export default AddFileClass;
@@ -0,0 +1,298 @@
"use client";
import React, { useEffect, useState, useRef } from "react";
import { useActionState } from "react";
import { saveFileAction } from "@/app/lib/generalActions/saveFileAction";
import { relatedToTitleUrl, relatedToTitle } from "@/app/lib/helpers/generalUtils";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { getCategoriesAction } from "@/app/lib/categories/getCategoriesAction";
const initialState = {
success: false,
message: null,
inputs: {},
};
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
function formatFileSize(bytes) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
}
export default function FileUploadComponent({
relType = null,
relId = null,
file = null,
redirectUrl = null,
}) {
const router = useRouter();
const fileInputRef = useRef(null);
const [state, formAction, isPending] = useActionState(
saveFileAction,
initialState
);
const [showMessage, setShowMessage] = useState(false);
const [type] = useState(relType);
const [catId] = useState(relId);
const [editing] = useState(file ? true : false);
const [categories, setCategories] = useState([]);
const [selectedCategory, setSelectedCategory] = useState(editing ? file?.category || "" : "");
// File validation states
const [selectedFile, setSelectedFile] = useState(null);
const [fileError, setFileError] = useState("");
const [fileSize, setFileSize] = useState("");
useEffect(() => {
if (state?.message) {
setShowMessage(true);
// Only redirect/clear on success, keep error message visible
if (state.success) {
const timer = setTimeout(() => {
setShowMessage(false);
if (file) {
const relatedToType = relatedToTitleUrl(file.relatedToType);
router.push(`/admin/dashboard/${relatedToType}/files/${file.relatedToId}`);
} else {
initialState.inputs = {};
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
setSelectedFile(null);
setFileSize("");
setFileError("");
}
}
}, 1500);
return () => clearTimeout(timer);
}
// On error, keep message visible until user submits again
}
}, [state?.message, file, router]);
useEffect(() => {
const fetchCategories = async () => {
const result = await getCategoriesAction();
if (!result.success) {
return;
}
setCategories(result.data || []);
if (editing && file?.category) {
setSelectedCategory(file.category);
}
};
fetchCategories();
}, [editing, file?.category]);
const handleFileChange = (e) => {
const file = e.target.files?.[0];
setFileError("");
setSelectedFile(null);
setFileSize("");
if (!file) return;
// Validate file size
if (file.size > MAX_FILE_SIZE) {
setFileError(`Arquivo muito grande! Máximo permitido: ${formatFileSize(MAX_FILE_SIZE)}`);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
setSelectedFile(file);
setFileSize(formatFileSize(file.size));
};
return (
<form
action={formAction}
className="bg-white dark:bg-gray-800 p-6 rounded shadow-md max-w-lg mx-auto"
>
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-100">
Enviar Arquivo{" "}
{relType && relId && <span>para {relatedToTitle(relType)}</span>}
</h2>
{type && catId && (
<>
<input type="hidden" name="relatedToType" value={type} />
<input type="hidden" name="relatedToId" value={catId} />
</>
)}
{editing && (
<>
<input type="hidden" name="id" value={file.id} />
</>
)}
{redirectUrl && (
<input type="hidden" name="redirectUrl" value={redirectUrl} />
)}
{/* Server response message */}
{showMessage && state?.message && (
<div
className={`mb-4 p-3 rounded-lg text-sm font-medium ${
state.success
? "bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-700"
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-700"
}`}
>
<p className="flex items-center gap-2">
{state.success ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)}
{state.message}
</p>
</div>
)}
{editing && file && (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Arquivo atual: {file.title} (
<Link
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
Ver
</Link>
)
</p>
)}
{/* File input with validation */}
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 label-dark font-semibold mb-2">
Arquivo <span className="text-red-500">*</span>
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-2">
(Máximo: {formatFileSize(MAX_FILE_SIZE)})
</span>
</label>
<input
ref={fileInputRef}
type="file"
name="file"
required={!editing}
onChange={handleFileChange}
disabled={isPending}
className={`w-full text-sm file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold ${
fileError
? "file:bg-red-50 file:text-red-700 dark:file:bg-red-900/30 dark:file:text-red-300"
: "file:bg-indigo-50 file:text-indigo-700 dark:file:bg-indigo-900/30 dark:file:text-indigo-300 hover:file:bg-indigo-100"
} ${isPending ? "opacity-50 cursor-not-allowed" : ""}`}
/>
{/* File info / error */}
{fileError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{fileError}
</p>
)}
{selectedFile && !fileError && (
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
{selectedFile.name} ({fileSize})
</p>
)}
</div>
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 label-dark font-semibold mb-2">
Título <span className="text-red-500">*</span>:
</label>
<input
type="text"
name="title"
required
disabled={isPending}
className="w-full border rounded px-3 py-2 text-gray-800 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Título do arquivo"
defaultValue={editing ? file.title : state?.inputs?.file}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 label-dark font-semibold mb-2">
Descrição:
</label>
<input
type="text"
name="description"
disabled={isPending}
className="w-full border rounded px-3 py-2 text-gray-800 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Descrição opcional..."
defaultValue={editing ? file.description : state?.inputs?.description}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 dark:text-gray-200 label-dark font-semibold mb-2">
Categoria:
</label>
<select
name="category"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
disabled={isPending}
className="w-full border rounded px-3 py-2 text-gray-800 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 mb-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">Selecione uma categoria (opcional)</option>
{categories.map((cat) => (
<option key={cat._id} value={cat._id}>
{cat.name}
</option>
))}
</select>
<input
type="text"
name="newCategoryName"
disabled={isPending}
className="w-full border rounded px-3 py-2 text-gray-800 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Ou digite uma nova categoria (opcional)"
defaultValue={state?.inputs?.newCategoryName || ""}
/>
</div>
{/* Submit button with loading state */}
<button
type="submit"
disabled={isPending || !!fileError}
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
{isPending ? (
<>
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{editing ? "Atualizando..." : "Enviando..."}
</>
) : (
<>
{editing ? "Atualizar" : "Enviar Arquivo"}
</>
)}
</button>
</form>
);
}
@@ -0,0 +1,41 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import FileUploadComponent from "@/app/(protected)/admin/dashboard/files/components/FileUploadForm";
import { getItemById } from "@/app/lib/helpers/getItems";
import { getFileModel } from "@/app/models/FilesSchema";
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
async function EditFile({ params }) {
const {id} = await params;
if (!isValidObjectId(id)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
</div>
);
}
const fileModel = await getFileModel();
const file = await fileModel.findById(id);
const shapedFile = file
? JSON.parse(JSON.stringify({
id: file._id.toString() || id,
title: file.title,
description: file.description,
relatedToType: file.relatedToType,
relatedToId: file.relatedToId ? file.relatedToId.toString() : null,
url: file.url,
category: file.category ? file.category.toString() : null
}))
: null;
return (
<div className="flex flex-col w-full">
<PageHeader title="Editar Arquivo" subtitle="Atualize as informações do arquivo" />
<FileUploadComponent file={shapedFile}/>
</div>
);
}
export default EditFile;
@@ -0,0 +1,15 @@
import React from "react";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import FileUploadComponent from "@/app/(protected)/admin/dashboard/files/components/FileUploadForm";
function AddFileClass() {
return (
<div className="flex flex-col w-full">
<PageHeader title="Adicionar Arquivo" />
{/* <FileUploadComponent /> */}
PÁGINA
</div>
);
}
export default AddFileClass;
@@ -0,0 +1,315 @@
"use client";
import React, { useState } from "react";
import {
XMarkIcon,
CheckCircleIcon,
EyeIcon,
PencilSquareIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import { EnvelopeIcon, PhoneIcon } from "@heroicons/react/24/outline";
function WhatsAppIcon(props) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
</svg>
);
}
const statusLabels = {
new: "Nova",
read: "Lida",
replied: "Respondida",
};
const statusColors = {
new: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
read: "bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-400",
replied:
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
};
const contactLabels = {
email: "Email",
whatsapp: "WhatsApp",
phone: "Telefone",
};
export default function MessageDetailModal({ message, onClose, onStatusChange, onDelete }) {
const [adminNotes, setAdminNotes] = useState(message.adminNotes || "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
function formatDate(dateStr) {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function buildWhatsAppLink() {
let phone = (message.phone || "").replace(/\D/g, "");
if (phone && !phone.startsWith("55")) {
phone = "55" + phone;
}
const text = encodeURIComponent(
`Olá ${message.name}! Recebemos sua mensagem sobre "${message.subject}". `
);
return phone ? `https://wa.me/${phone}?text=${text}` : null;
}
async function handleSaveNotes() {
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/contact-messages/${message._id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ adminNotes }),
});
const data = await res.json();
if (data.success && onStatusChange) {
onStatusChange();
} else if (!data.success) {
setError(data.error || "Erro ao salvar notas.");
}
} catch {
setError("Erro ao salvar notas. Verifique sua conexão e tente novamente.");
}
setSaving(false);
}
async function handleUpdateStatus(newStatus) {
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/contact-messages/${message._id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
const data = await res.json();
if (data.success && onStatusChange) {
onStatusChange();
onClose();
} else if (!data.success) {
setError(data.error || "Erro ao atualizar status.");
}
} catch {
setError("Erro ao atualizar status. Verifique sua conexão e tente novamente.");
}
setSaving(false);
}
async function handleDelete() {
if (!confirm("Deseja realmente excluir esta mensagem?")) return;
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/contact-messages/${message._id}`, {
method: "DELETE",
});
const data = await res.json();
if (data.success && onDelete) {
onDelete();
onClose();
} else if (!data.success) {
setError(data.error || "Erro ao excluir mensagem.");
}
} catch {
setError("Erro ao excluir mensagem. Verifique sua conexão e tente novamente.");
}
setSaving(false);
}
const whatsappLink = buildWhatsAppLink();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-neutral-200 dark:border-neutral-700">
<div className="sticky top-0 bg-white dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-700 p-5 flex items-center justify-between rounded-t-2xl">
<div className="flex items-center gap-3">
<h2 className="text-lg font-bold text-neutral-900 dark:text-white">
Mensagem
</h2>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[message.status]}`}
>
{statusLabels[message.status]}
</span>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div className="p-5 space-y-5">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Nome
</p>
<p className="text-sm font-semibold text-neutral-900 dark:text-white">
{message.name}
</p>
</div>
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Contato preferido
</p>
<p className="text-sm font-semibold text-neutral-900 dark:text-white">
{contactLabels[message.preferredContact] || message.preferredContact}
</p>
</div>
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Email
</p>
<a
href={`mailto:${message.email}`}
className="text-sm text-indigo-600 dark:text-indigo-400 hover:underline inline-flex items-center gap-1"
>
<EnvelopeIcon className="w-3.5 h-3.5" />
{message.email}
</a>
</div>
{message.phone && (
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Telefone / WhatsApp
</p>
<a
href={`tel:${message.phone}`}
className="text-sm text-neutral-700 dark:text-neutral-300 hover:underline inline-flex items-center gap-1"
>
<PhoneIcon className="w-3.5 h-3.5" />
{message.phone}
</a>
</div>
)}
</div>
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Assunto
</p>
<p className="text-sm font-semibold text-neutral-900 dark:text-white">
{message.subject}
</p>
</div>
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Mensagem
</p>
<div className="text-sm text-neutral-700 dark:text-neutral-300 whitespace-pre-wrap bg-neutral-50 dark:bg-neutral-800 rounded-xl p-4 border border-neutral-200 dark:border-neutral-700 leading-relaxed">
{message.message}
</div>
</div>
<div>
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Recebida em
</p>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
{formatDate(message.createdAt)}
</p>
</div>
<div>
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
Notas internas
</label>
<textarea
value={adminNotes}
onChange={(e) => setAdminNotes(e.target.value)}
rows={3}
className="w-full rounded-xl border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-2.5 text-sm text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
placeholder="Adicione notas sobre o atendimento..."
/>
<button
onClick={handleSaveNotes}
disabled={saving}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors disabled:opacity-50"
>
<PencilSquareIcon className="w-3.5 h-3.5" />
Salvar notas
</button>
</div>
</div>
<div className="sticky bottom-0 bg-white dark:bg-neutral-900 border-t border-neutral-200 dark:border-neutral-700 p-5 flex flex-col gap-3 rounded-b-2xl">
{error && (
<div className="rounded-xl p-3 text-sm font-medium bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800">
{error}
</div>
)}
<div className="flex flex-wrap gap-2">
{whatsappLink && (
<a
href={whatsappLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-green-600 hover:bg-green-700 text-white transition-colors"
>
<WhatsAppIcon className="w-4 h-4" />
Abrir WhatsApp
</a>
)}
<a
href={`mailto:${message.email}`}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
>
<EnvelopeIcon className="w-4 h-4" />
Enviar Email
</a>
<div className="flex-1" />
{message.status === "new" && (
<button
onClick={() => handleUpdateStatus("read")}
disabled={saving}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors disabled:opacity-50"
>
<EyeIcon className="w-4 h-4" />
Marcar como lida
</button>
)}
{message.status !== "replied" && (
<button
onClick={() => handleUpdateStatus("replied")}
disabled={saving}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors disabled:opacity-50"
>
<CheckCircleIcon className="w-4 h-4" />
Marcar respondida
</button>
)}
<button
onClick={handleDelete}
disabled={saving}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50"
>
<TrashIcon className="w-4 h-4" />
Excluir
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,248 @@
"use client";
import React, { useState, useTransition } from "react";
import {
EyeIcon,
TrashIcon,
MagnifyingGlassIcon,
FunnelIcon,
} from "@heroicons/react/24/outline";
import MessageDetailModal from "./MessageDetailModal";
const statusLabels = {
new: "Nova",
read: "Lida",
replied: "Respondida",
};
const statusColors = {
new: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
read: "bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-400",
replied:
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
};
const contactIcons = {
email: "📧",
whatsapp: "💬",
phone: "📞",
};
export default function MessagesTable({ messages: initialMessages }) {
const [messages, setMessages] = useState(initialMessages);
const [selectedMessage, setSelectedMessage] = useState(null);
const [filterStatus, setFilterStatus] = useState("all");
const [search, setSearch] = useState("");
const [refreshError, setRefreshError] = useState(false);
const [isPending, startTransition] = useTransition();
function formatDate(dateStr) {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function refreshMessages() {
setRefreshError(false);
startTransition(async () => {
try {
const res = await fetch("/api/contact-messages");
const data = await res.json();
if (data.success) {
setMessages(
data.data.map((m) => ({
...m,
_id: m._id.toString(),
createdAt: m.createdAt,
repliedAt: m.repliedAt,
}))
);
} else {
setRefreshError(true);
}
} catch {
console.error("Erro ao atualizar lista de mensagens.");
setRefreshError(true);
}
});
}
function handleDelete() {
refreshMessages();
}
const filtered = messages.filter((m) => {
const matchesStatus =
filterStatus === "all" || m.status === filterStatus;
const matchesSearch =
!search ||
m.name.toLowerCase().includes(search.toLowerCase()) ||
m.email.toLowerCase().includes(search.toLowerCase()) ||
m.subject.toLowerCase().includes(search.toLowerCase());
return matchesStatus && matchesSearch;
});
const counts = {
all: messages.length,
new: messages.filter((m) => m.status === "new").length,
read: messages.filter((m) => m.status === "read").length,
replied: messages.filter((m) => m.status === "replied").length,
};
return (
<div className="space-y-4">
{refreshError && (
<div className="rounded-xl p-3 text-sm font-medium bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-800">
Não foi possível atualizar a lista de mensagens. Recarregue a página.
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div className="relative flex-1 max-w-xs">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
<input
type="text"
placeholder="Buscar por nome, email ou assunto..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-xl border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-sm text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
/>
</div>
<div className="flex items-center gap-1.5">
<FunnelIcon className="w-4 h-4 text-neutral-400" />
{["all", "new", "read", "replied"].map((status) => (
<button
key={status}
onClick={() => setFilterStatus(status)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
filterStatus === status
? "bg-indigo-600 text-white"
: "bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700"
}`}
>
{status === "all" ? "Todas" : statusLabels[status]}
<span
className={`inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-bold ${
filterStatus === status
? "bg-white/20 text-white"
: "bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400"
}`}
>
{counts[status]}
</span>
</button>
))}
</div>
</div>
<div className="rounded-xl border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
Data
</th>
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
Nome
</th>
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
Contato
</th>
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
Assunto
</th>
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
Status
</th>
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
{filtered.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-4 py-8 text-center text-neutral-400 dark:text-neutral-500"
>
Nenhuma mensagem encontrada.
</td>
</tr>
) : (
filtered.map((msg) => (
<tr
key={msg._id}
className={`hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${
msg.status === "new"
? "bg-blue-50/50 dark:bg-blue-900/5"
: ""
}`}
>
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-400 whitespace-nowrap">
{formatDate(msg.createdAt)}
</td>
<td className="px-4 py-3">
<span className="font-medium text-neutral-900 dark:text-white">
{msg.name}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<span className="text-sm">
{contactIcons[msg.preferredContact] || "📧"}
</span>
<span className="text-neutral-600 dark:text-neutral-400 text-xs max-w-[180px] truncate">
{msg.preferredContact === "whatsapp" || msg.preferredContact === "phone"
? msg.phone || msg.email
: msg.email}
</span>
</div>
</td>
<td className="px-4 py-3">
<span className="text-neutral-700 dark:text-neutral-300 max-w-[200px] truncate block">
{msg.subject}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[msg.status]}`}
>
{statusLabels[msg.status]}
</span>
</td>
<td className="px-4 py-3 text-right">
<div className="inline-flex items-center gap-1">
<button
onClick={() => setSelectedMessage(msg)}
className="p-1.5 rounded-lg text-neutral-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors"
title="Ver detalhes"
>
<EyeIcon className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{selectedMessage && (
<MessageDetailModal
message={selectedMessage}
onClose={() => setSelectedMessage(null)}
onStatusChange={refreshMessages}
onDelete={handleDelete}
/>
)}
</div>
);
}
@@ -0,0 +1,33 @@
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import MessagesTable from "./components/MessagesTable";
import { getContactMessageModel } from "@/app/models/ContactMessage";
import { EnvelopeIcon } from "@heroicons/react/24/outline";
export const dynamic = "force-dynamic";
async function MessagesAdminPage() {
const ContactMessage = await getContactMessageModel();
const messages = await ContactMessage.find({})
.sort({ createdAt: -1 })
.lean();
const serialized = messages.map((m) => ({
...m,
_id: m._id.toString(),
createdAt: m.createdAt?.toISOString() || null,
repliedAt: m.repliedAt?.toISOString() || null,
}));
return (
<div className="w-full mt-3">
<PageHeader
title="Mensagens de Contato"
subtitle="Gerencie as mensagens recebidas pelo formulário de contato."
icon={<EnvelopeIcon className="w-6 h-6" />}
/>
<MessagesTable messages={serialized} />
</div>
);
}
export default MessagesAdminPage;
@@ -0,0 +1,24 @@
import AdminDashboard from "@/app/(protected)/components/AdminDashboard";
import NotAuthorized from "@/app/auth/components/NotAuthorized";
import { auth } from "@/app/lib/utils/auth";
import { redirect } from "next/navigation";
async function AdminDasBoard() {
const session = await auth();
if (!session?.user?.id) redirect("/auth/login");
const roles = Array.isArray(session.user.roles) ? session.user.roles : [];
const role = session.user.role;
const isAdmin = roles.includes("admin") || roles.includes("superadmin") || role === "admin" || role === "superadmin";
if (isAdmin) {
return <AdminDashboard />;
} else {
return (
<>
<NotAuthorized />
</>
);
}
}
export default AdminDasBoard;
@@ -0,0 +1,86 @@
import MainSection from "@/app/(protected)/components/shared/Main";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { getPaymentModel } from "@/app/models/Payment";
import { getUserModel } from "@/app/models/User";
import { getClassModel } from "@/app/models/Class";
import { auth } from "@/app/lib/utils/auth";
import NotAuthorized from "@/app/auth/components/NotAuthorized";
import { redirect } from "next/navigation";
import PaymentsByClass from "../components/PaymentsByClass";
function serializePayment(payment) {
return {
_id: payment._id?.toString(),
classId: payment.classId
? {
_id: payment.classId._id?.toString(),
classTitle: payment.classId.classTitle,
}
: null,
userId: payment.userId
? {
_id: payment.userId._id?.toString(),
fullName: payment.userId.fullName,
email: payment.userId.email,
}
: null,
type: payment.type,
amount: payment.amount,
status: payment.status,
paymentDate: payment.paymentDate,
dueDate: payment.dueDate,
description: payment.description,
paymentMethod: payment.paymentMethod,
receiptUrl: payment.receiptUrl,
notes: payment.notes,
createdBy: payment.createdBy?.toString(),
createdAt: payment.createdAt?.toISOString(),
updatedAt: payment.updatedAt?.toISOString(),
relatedObligationId: payment.relatedObligationId?.toString(),
};
}
export default async function AdminPaymentsByClassPage() {
const session = await auth();
if (!session?.user?.id) redirect("/auth/login");
const UserModel = await getUserModel();
const currentUser = await UserModel.findOne({ _id: session.user.id });
// Only admins can access this page
if (!currentUser.roles.includes("admin")) {
return <NotAuthorized />;
}
const Payment = await getPaymentModel();
const Class = await getClassModel();
// Fetch all payments and obligations
const payments = await Payment.find({})
.populate("classId", "classTitle")
.populate("userId", "fullName email")
.sort({ createdAt: -1 })
.lean();
// Serialize data for client components
const serializedPayments = payments.map(serializePayment);
return (
<MainSection>
<PageHeader
title="Pagamentos por Turma"
subtitle="Visualização agrupada de obrigações e movimentações financeiras"
actions={
<a
href="/admin/dashboard/payments"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 text-sm font-medium label-dark hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
>
Voltar para Pagamentos
</a>
}
/>
<PaymentsByClass payments={serializedPayments} />
</MainSection>
);
}
@@ -0,0 +1,217 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { createObligationAction } from "@/app/lib/payments/actions";
export default function CreateObligationForm({ classes, users }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [classStudents, setClassStudents] = useState([]);
const [formData, setFormData] = useState({
classId: "",
userId: "",
amount: "",
dueDate: "",
description: "",
createForAll: false,
});
// Effect to update students when class changes
useEffect(() => {
const selectedClass = classes.find(cls => cls._id === formData.classId);
if (selectedClass && selectedClass.students) {
// Filter users that are in the selected class
const students = users.filter(user =>
selectedClass.students.includes(user._id)
);
setClassStudents(students);
} else {
setClassStudents([]);
}
// Reset userId when class changes
setFormData(prev => ({ ...prev, userId: "" }));
}, [formData.classId, classes, users]);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError("");
setSuccess("");
try {
const formDataToSend = new FormData();
formDataToSend.append("classId", formData.classId);
formDataToSend.append("amount", parseFloat(formData.amount));
if (!formData.createForAll) {
formDataToSend.append("userId", formData.userId);
}
if (formData.dueDate) {
formDataToSend.append("dueDate", formData.dueDate);
}
if (formData.description) {
formDataToSend.append("description", formData.description);
}
const result = await createObligationAction(formDataToSend);
if (!result.success) {
throw new Error(result.message || "Erro ao criar obrigação");
}
setSuccess(result.message);
setFormData({
classId: "",
userId: "",
amount: "",
dueDate: "",
description: "",
createForAll: false,
});
router.refresh();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-neutral-100 mb-4">
Criar Obrigação de Pagamento
</h2>
{error && (
<div className="bg-red-600 dark:bg-red-900/50 border border-red-600 dark:border-red-600 text-white dark:text-red-200 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{success && (
<div className="bg-emerald-600 dark:bg-emerald-900/50 border border-emerald-600 dark:border-emerald-600 text-white dark:text-emerald-200 px-4 py-3 rounded mb-4">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Classe *
</label>
<select
required
value={formData.classId}
onChange={(e) => setFormData({ ...formData, classId: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
>
<option value="">Selecione uma classe</option>
{classes.map((cls) => (
<option key={cls._id} value={cls._id}>
{cls.classTitle}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Aluno (deixe vazio para todos)
</label>
<select
value={formData.userId}
onChange={(e) => setFormData({ ...formData, userId: e.target.value })}
disabled={formData.createForAll || !formData.classId}
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 disabled:opacity-50"
>
<option value="">Selecione um aluno (opcional)</option>
{classStudents.map((user) => (
<option key={user._id} value={user._id}>
{user.fullName}
</option>
))}
{formData.classId && classStudents.length === 0 && (
<option value="" disabled>
Nenhum aluno encontrado nesta turma
</option>
)}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Valor (R$) *
</label>
<input
type="number"
step="0.01"
min="0"
required
value={formData.amount}
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
placeholder="0,00"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Data de Vencimento
</label>
<input
type="date"
lang="pt-BR"
value={formData.dueDate}
onChange={(e) => setFormData({ ...formData, dueDate: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Descrição (ex: "Mensalidade Janeiro 2025")
</label>
<input
type="text"
maxLength="200"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
placeholder="Descrição da obrigação"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="createForAll"
checked={formData.createForAll}
onChange={(e) => setFormData({ ...formData, createForAll: e.target.checked, userId: "" })}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="createForAll" className="ml-2 block text-sm text-gray-700 dark:text-gray-200 label-dark">
Criar para todos os alunos da turma
</label>
</div>
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{loading ? "Criando..." : "Criar Obrigação"}
</button>
</form>
</div>
);
}
@@ -0,0 +1,214 @@
"use client";
import { useState, useEffect } from "react";
import { updateObligation, deleteObligation } from "@/app/lib/actions/paymentActions";
import { FaEdit, FaTrash, FaSave, FaTimes } from "react-icons/fa";
export default function EditObligationModal({ obligation, isOpen, onClose, onSuccess }) {
const [formData, setFormData] = useState({
amount: "",
description: "",
dueDate: "",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Update form data when obligation changes
useEffect(() => {
if (obligation) {
setFormData({
amount: obligation.amount?.toString() || "",
description: obligation.description || "",
dueDate: obligation.dueDate
? new Date(obligation.dueDate).toISOString().split("T")[0]
: "",
});
}
}, [obligation]);
if (!isOpen || !obligation) return null;
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const result = await updateObligation(obligation._id, {
amount: parseFloat(formData.amount),
description: formData.description,
dueDate: formData.dueDate || null,
});
if (result.success) {
onSuccess?.();
onClose();
} else {
setError(result.error || "Erro ao atualizar obrigação");
}
} catch (err) {
setError(err.message || "Erro ao atualizar obrigação");
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
setLoading(true);
setError("");
try {
const result = await deleteObligation(obligation._id);
if (result.success) {
onSuccess?.();
onClose();
} else {
setError(result.error || "Erro ao excluir obrigação");
}
} catch (err) {
setError(err.message || "Erro ao excluir obrigação");
} finally {
setLoading(false);
setShowDeleteConfirm(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-800 rounded-xl shadow-xl w-full max-w-md border border-gray-200 dark:border-neutral-700">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-neutral-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-neutral-100">
Editar Obrigação
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<FaTimes className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="bg-red-600 dark:bg-red-900/50 border border-red-600 dark:border-red-600 text-white dark:text-red-200 px-4 py-3 rounded">
{error}
</div>
)}
{/* Amount */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Valor (R$)
</label>
<input
type="number"
step="0.01"
min="0"
value={formData.amount}
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
className="w-full border border-gray-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
{/* Due Date */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Data de Vencimento
</label>
<input
type="date"
lang="pt-BR"
value={formData.dueDate}
onChange={(e) => setFormData({ ...formData, dueDate: e.target.value })}
className="w-full border border-gray-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
Descrição
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full border border-gray-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
rows={3}
maxLength={200}
placeholder="Descrição da obrigação..."
/>
<p className="text-xs text-gray-500 dark:text-gray-200 label-dark mt-1">
{formData.description.length}/200 caracteres
</p>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-white bg-red-600 border border-red-600 rounded-lg hover:bg-red-700 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800 dark:hover:bg-red-900/30 disabled:opacity-50"
>
<FaTrash className="w-4 h-4" />
Excluir
</button>
<div className="flex-1"></div>
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 text-gray-700 dark:text-gray-200 label-dark bg-white dark:bg-neutral-700 border border-gray-300 dark:border-neutral-600 rounded-lg hover:bg-gray-50 dark:hover:bg-neutral-600 disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
<FaSave className="w-4 h-4" />
{loading ? "Salvando..." : "Salvar"}
</button>
</div>
</form>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center z-10 rounded-xl">
<div className="bg-white dark:bg-neutral-800 rounded-lg p-6 m-4 max-w-sm border border-gray-200 dark:border-neutral-700 shadow-xl">
<h3 className="text-lg font-semibold text-gray-900 dark:text-neutral-100 mb-2">
Confirmar Exclusão
</h3>
<p className="text-gray-600 dark:text-gray-200 label-dark mb-4">
Tem certeza que deseja excluir esta obrigação? Esta ação não pode ser desfeita.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={loading}
className="px-4 py-2 text-gray-700 dark:text-gray-200 label-dark bg-white dark:bg-neutral-700 border border-gray-300 dark:border-neutral-600 rounded-lg hover:bg-gray-50 dark:hover:bg-neutral-600 disabled:opacity-50"
>
Cancelar
</button>
<button
onClick={handleDelete}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
>
<FaTrash className="w-4 h-4" />
{loading ? "Excluindo..." : "Excluir"}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,164 @@
"use client";
import { useState } from "react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import Label from "@/app/(protected)/components/shared/Label";
import { getPaymentsByObligationAction } from "@/app/lib/payments/actions";
export default function ObligationDetails({ obligations }) {
const [expandedObligation, setExpandedObligation] = useState(null);
const [obligationPayments, setObligationPayments] = useState({});
const [loading, setLoading] = useState(false);
// Group obligations by their reference (description + dueDate)
const groupedObligations = obligations.reduce((acc, obligation) => {
const key = `${obligation.description || "Pagamento"}_${obligation.dueDate || obligation.createdAt}`;
if (!acc[key]) {
acc[key] = {
...obligation,
users: [],
};
}
acc[key].users.push(obligation);
return acc;
}, {});
const groupedList = Object.values(groupedObligations);
const fetchPaymentsForObligation = async (obligation) => {
setLoading(true);
try {
const result = await getPaymentsByObligationAction(obligation._id);
if (result.success) {
setObligationPayments(prev => ({
...prev,
[obligation._id]: result.data || [],
}));
}
} catch (error) {
console.error("Error fetching payments:", error);
} finally {
setLoading(false);
}
};
const handleToggleExpand = (obligation) => {
if (expandedObligation === obligation._id) {
setExpandedObligation(null);
} else {
setExpandedObligation(obligation._id);
if (!obligationPayments[obligation._id]) {
fetchPaymentsForObligation(obligation);
}
}
};
const getStatusBadge = (userObligation) => {
const payments = obligationPayments[userObligation._id] || [];
const hasVerified = payments.some(p => p.status === "verified");
const hasPending = payments.some(p => p.status === "pending_verification");
if (hasVerified) {
return <Label color="emerald" size="sm"> Pago</Label>;
}
if (hasPending) {
return <Label color="blue" size="sm">Aguardando Verificação</Label>;
}
return <Label color="amber" size="sm">Aguardando Pagamento</Label>;
};
if (obligations.length === 0) {
return (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-6 text-center text-gray-500 dark:text-gray-200 label-dark">
Nenhuma obrigação de pagamento criada ainda.
</div>
);
}
return (
<div className="space-y-4">
{groupedList.map((group) => (
<div
key={group._id}
className="bg-white dark:bg-neutral-800 rounded-lg shadow overflow-hidden"
>
{/* Group Header */}
<div
className="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-neutral-700/50 transition-colors"
onClick={() => handleToggleExpand(group)}
>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold text-gray-900 dark:text-neutral-100">
{group.description || "Pagamento"}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">
Vencimento: {format(new Date(group.dueDate || group.createdAt), "dd/MM/yyyy", { locale: ptBR })}
</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">
{group.users.length} {group.users.length === 1 ? "aluno" : "alunos"}
</p>
<p className="text-lg font-bold text-gray-900 dark:text-neutral-100">
R$ {group.amount?.toFixed(2)}
</p>
</div>
<span className="text-gray-400 dark:text-neutral-500">
{expandedObligation === group._id ? "▼" : "▶"}
</span>
</div>
</div>
</div>
{/* Expanded Details */}
{expandedObligation === group._id && (
<div className="border-t border-gray-200 dark:border-neutral-700">
<table className="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
<thead className="bg-gray-50 dark:bg-neutral-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase tracking-wider">
Aluno
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase tracking-wider">
Valor
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-gray-200 dark:divide-neutral-700">
{group.users.map((userObligation) => (
<tr
key={userObligation._id}
className="hover:bg-gray-50 dark:hover:bg-neutral-700/50"
>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
{userObligation.userId?.fullName || "N/A"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-200 label-dark">
{userObligation.userId?.email || "N/A"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100 font-medium">
R$ {userObligation.amount?.toFixed(2) || "0.00"}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(userObligation)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,60 @@
export default function PaymentStats({ stats }) {
const statsCards = [
{
title: "Total de Obrigações",
value: stats.totalObligations || 0,
color: "yellow",
icon: "📋",
},
{
title: "Aguardando Pagamento",
value: stats.pendingObligations || 0,
color: "yellow",
icon: "⏳",
},
{
title: "Pagamentos Enviados",
value: stats.pendingPayments || 0,
color: "blue",
icon: "📤",
},
{
title: "Pagamentos Verificados",
value: stats.verifiedPayments || 0,
color: "green",
icon: "✅",
},
{
title: "Pagamentos Rejeitados",
value: stats.rejectedPayments || 0,
color: "red",
icon: "❌",
},
];
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
{statsCards.map((card) => (
<div
key={card.title}
className={`bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 ${
card.color === "yellow" ? "border-amber-500 dark:border-amber-400" :
card.color === "blue" ? "border-blue-500 dark:border-blue-400" :
card.color === "green" ? "border-emerald-500 dark:border-emerald-400" :
"border-red-500 dark:border-red-400"
}`}
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-500 dark:text-gray-200 label-dark">{card.title}</p>
<p className="text-xl font-bold text-gray-900 dark:text-neutral-100 mt-1">
{card.value}
</p>
</div>
<span className="text-2xl">{card.icon}</span>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,767 @@
"use client";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { FaCheck, FaTimes, FaEye, FaEdit, FaList, FaFileInvoiceDollar } from "react-icons/fa";
import EditObligationModal from "./EditObligationModal";
import Label from "@/app/(protected)/components/shared/Label";
import Button from "@/app/(protected)/components/shared/Button";
import { updatePaymentStatusAction } from "@/app/lib/payments/actions";
function PaymentStatusBadge({ status, type }) {
const getStatusConfig = (status, type) => {
if (type === "obligation") {
switch (status) {
case "paid":
return { color: "emerald", label: "Paga" };
case "partially_paid":
return { color: "blue", label: "Parcialmente Paga" };
default:
return { color: "amber", label: "Pendente" };
}
}
switch (status) {
case "verified":
return { color: "emerald", label: "Confirmado" };
case "pending_verification":
return { color: "blue", label: "Aguardando Verificação" };
case "rejected":
return { color: "red", label: "Rejeitado" };
default:
return { color: "gray", label: "Pendente" };
}
};
const config = getStatusConfig(status, type);
return (
<Label color={config.color} size="sm">
{config.label}
</Label>
);
}
// ============ OBRIGAÇÕES VIEW ============
function ObligationsCard({ classData, isExpanded, onToggle, obligations, statusFilter, onEditObligation }) {
// Filter obligations based on status
const filteredObligations = useMemo(() => {
if (statusFilter === "all") return obligations;
return obligations.filter(o => {
if (statusFilter === "pending") return o.status === "pending";
if (statusFilter === "partially_paid") return o.status === "partially_paid";
if (statusFilter === "paid") return o.status === "paid";
return true;
});
}, [obligations, statusFilter]);
const pendingCount = obligations.filter(o => o.status === "pending").length;
const partiallyPaidCount = obligations.filter(o => o.status === "partially_paid").length;
const paidCount = obligations.filter(o => o.status === "paid").length;
const totalPendingAmount = obligations
.filter(o => o.status === "pending")
.reduce((sum, o) => sum + (o.remainingAmount || o.amount || 0), 0);
return (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-neutral-700">
<div className="p-4 cursor-pointer hover-card-light" onClick={onToggle}>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold text-gray-900 dark:text-neutral-100">
{classData.classTitle}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">
{filteredObligations.length} obrigação(ões)
</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right text-sm">
<span className="block text-amber-600 dark:text-amber-400">
{pendingCount} pendente{totalPendingAmount > 0 && ` (R$ ${totalPendingAmount.toFixed(2)})`}
</span>
<span className="block text-blue-600 dark:text-blue-400">
{partiallyPaidCount} parcial
</span>
<span className="block text-emerald-600 dark:text-emerald-400">
{paidCount} paga
</span>
</div>
<span className="text-gray-400 dark:text-neutral-500 text-lg">
{isExpanded ? "▼" : "▶"}
</span>
</div>
</div>
</div>
{isExpanded && (
<div className="border-t border-gray-200 dark:border-neutral-700">
{filteredObligations.length === 0 ? (
<div className="p-4 text-center text-gray-500 dark:text-gray-200 label-dark">
Nenhuma obrigação encontrada com o filtro aplicado.
</div>
) : (
<div className="divide-y divide-gray-200 dark:divide-neutral-700">
{filteredObligations.map((obligation) => (
<ObligationRow
key={obligation._id}
obligation={obligation}
onEdit={onEditObligation}
/>
))}
</div>
)}
</div>
)}
</div>
);
}
function ObligationRow({ obligation, onEdit }) {
const [showPayments, setShowPayments] = useState(false);
const paidAmount = obligation.payments
?.filter(p => p.status === "verified")
.reduce((sum, p) => sum + (p.amount || 0), 0) || 0;
const remainingAmount = (obligation.amount || 0) - paidAmount;
const percentage = obligation.amount > 0 ? (paidAmount / obligation.amount) * 100 : 0;
return (
<div className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="font-medium text-gray-900 dark:text-neutral-100">
{obligation.userId?.fullName || "N/A"}
</h4>
<PaymentStatusBadge status={obligation.status} type="obligation" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark mb-2">
{obligation.description || "Mensalidade"} Vencimento: {obligation.dueDate ? format(new Date(obligation.dueDate), "dd/MM/yyyy", { locale: ptBR }) : "N/A"}
</p>
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-900 dark:text-neutral-100">
Total: <strong>R$ {obligation.amount?.toFixed(2) || "0.00"}</strong>
</span>
<span className="text-emerald-600 dark:text-emerald-400">
Pago: R$ {paidAmount.toFixed(2)}
</span>
{remainingAmount > 0 && (
<span className="text-amber-600 dark:text-amber-400">
Restante: R$ {remainingAmount.toFixed(2)}
</span>
)}
</div>
{/* Progress bar */}
<div className="mt-2 w-full bg-gray-200 dark:bg-neutral-700 rounded-full h-2">
<div
className="bg-emerald-500 h-2 rounded-full transition-all"
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
</div>
<div className="flex items-center gap-2">
{obligation.payments && obligation.payments.length > 0 && (
<button
onClick={() => setShowPayments(!showPayments)}
className="px-3 py-1 text-sm border border-gray-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
>
{showPayments ? "Ocultar" : "Ver"} {obligation.payments.length} { obligation.payments.length > 1 ? "movimentações " : "movimentação"}
</button>
)}
<button
onClick={() => onEdit(obligation)}
title="Editar obrigação"
className="p-2 text-neutral-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
<FaEdit className="text-lg" />
</button>
</div>
</div>
{/* Payment History */}
{showPayments && obligation.payments && obligation.payments.length > 0 && (
<div className="mt-4 pl-4 border-l-4 border-emerald-500">
<p className="text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase mb-2">Histórico do Pagamento</p>
<div className="space-y-2">
{obligation.payments.map((payment) => (
<div key={payment._id} className="flex items-center justify-between text-sm p-2 bg-gray-50 dark:bg-neutral-700 rounded">
<div className="flex items-center gap-3">
<PaymentStatusBadge status={payment.status} type="payment" />
<span className="text-gray-900 dark:text-neutral-100">
{payment.paymentDate ? format(new Date(payment.paymentDate), "dd/MM/yyyy", { locale: ptBR }) : "N/A"}
</span>
<span className="text-gray-500 dark:text-gray-200 label-dark">
{payment.paymentMethod === "pix" ? "PIX" :
payment.paymentMethod === "bank_transfer" ? "Transferência" :
payment.paymentMethod === "cash" ? "Dinheiro" :
payment.paymentMethod}
</span>
</div>
<span className="font-medium text-gray-900 dark:text-neutral-100">
R$ {payment.amount?.toFixed(2) || "0.00"}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
// ============ HISTÓRICO VIEW ============
function TransactionsCard({ classData, isExpanded, onToggle, transactions, statusFilter, onVerify, onRejectClick, processingId, onViewReceipt }) {
// Filter transactions based on status
const filteredTransactions = useMemo(() => {
if (statusFilter === "all") return transactions;
return transactions.filter(t => {
if (statusFilter === "pending_verification") return t.status === "pending_verification";
if (statusFilter === "verified") return t.status === "verified";
if (statusFilter === "rejected") return t.status === "rejected";
return true;
});
}, [transactions, statusFilter]);
const pendingCount = transactions.filter(t => t.status === "pending_verification").length;
const verifiedCount = transactions.filter(t => t.status === "verified").length;
const rejectedCount = transactions.filter(t => t.status === "rejected").length;
const totalVerified = transactions
.filter(t => t.status === "verified")
.reduce((sum, t) => sum + (t.amount || 0), 0);
return (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-neutral-700">
<div className="p-4 cursor-pointer hover-card-light" onClick={onToggle}>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold text-gray-900 dark:text-neutral-100">
{classData.classTitle}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">
{filteredTransactions.length} movimentação(ões)
</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right text-sm">
<span className="block text-blue-600 dark:text-blue-400">
{pendingCount} em verificação
</span>
<span className="block text-emerald-600 dark:text-emerald-400">
{verifiedCount} confirmado
</span>
<span className="block text-red-600 dark:text-red-400">
{rejectedCount} rejeitado
</span>
</div>
<span className="text-gray-400 dark:text-neutral-500 text-lg">
{isExpanded ? "▼" : "▶"}
</span>
</div>
</div>
</div>
{isExpanded && (
<div className="border-t border-gray-200 dark:border-neutral-700">
{filteredTransactions.length === 0 ? (
<div className="p-4 text-center text-gray-500 dark:text-gray-200 label-dark">
Nenhuma movimentação encontrada com o filtro aplicado.
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
<thead className="bg-gray-50 dark:bg-neutral-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Data</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Aluno</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Método</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Valor</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Status</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Ações</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-gray-200 dark:divide-neutral-700">
{filteredTransactions.map((transaction) => (
<tr key={transaction._id} className="hover-card-light">
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-200 label-dark">
{transaction.paymentDate ? format(new Date(transaction.paymentDate), "dd/MM/yyyy", { locale: ptBR }) : "N/A"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
{transaction.userId?.fullName || "N/A"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-200 label-dark">
{transaction.paymentMethod === "pix" ? "PIX" :
transaction.paymentMethod === "bank_transfer" ? "Transferência" :
transaction.paymentMethod === "cash" ? "Dinheiro" :
transaction.paymentMethod === "credit_card" ? "Cartão Crédito" :
transaction.paymentMethod === "debit_card" ? "Cartão Débito" :
transaction.paymentMethod || "-"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100 font-medium">
R$ {transaction.amount?.toFixed(2) || "0.00"}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<PaymentStatusBadge status={transaction.status} type="payment" />
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-right">
<div className="flex justify-end gap-2 items-center">
{transaction.receiptUrl ? (
<button
onClick={() => onViewReceipt(transaction.receiptUrl)}
title="Ver comprovante"
className="p-2 text-blue-500 hover:text-blue-700 dark:hover:text-blue-400 transition-colors"
>
<FaEye className="text-lg" />
</button>
) : (
<span className="text-neutral-400 dark:text-neutral-500 text-xs italic">Sem comp.</span>
)}
{transaction.status === "pending_verification" && (
<>
<button
onClick={() => onVerify(transaction._id, "verified")}
disabled={processingId === transaction._id}
title="Aprovar"
className="p-2 text-neutral-500 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors disabled:opacity-50"
>
<FaCheck className="text-lg" />
</button>
<button
onClick={() => onRejectClick(transaction._id)}
disabled={processingId === transaction._id}
title="Rejeitar"
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
>
<FaTimes className="text-lg" />
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
export default function PaymentsByClass({ payments, classes }) {
const router = useRouter();
const [viewMode, setViewMode] = useState("obligations"); // "obligations" or "transactions"
const [expandedClasses, setExpandedClasses] = useState({});
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [processingId, setProcessingId] = useState(null);
const [rejectModal, setRejectModal] = useState({ open: false, paymentId: null, notes: "" });
const [receiptModal, setReceiptModal] = useState({ open: false, url: null });
const [editModal, setEditModal] = useState({ open: false, obligation: null });
// Separate obligations and payments, and link them
const { obligationsByClass, transactionsByClass } = useMemo(() => {
const obligations = {};
const transactions = {};
payments.forEach((payment) => {
const classId = payment.classId?._id || "unknown";
if (payment.type === "obligation") {
// Initialize obligation entry if needed
if (!obligations[classId]) {
obligations[classId] = {
classId: payment.classId,
classTitle: payment.classId?.classTitle || "Turma Desconhecida",
obligations: [],
};
}
// Calculate paid amount and status
const relatedPayments = payments.filter(
p => p.type === "payment" && p.relatedObligationId?.toString() === payment._id?.toString()
);
const paidAmount = relatedPayments
.filter(p => p.status === "verified")
.reduce((sum, p) => sum + (p.amount || 0), 0);
const remainingAmount = (payment.amount || 0) - paidAmount;
let status = "pending";
if (paidAmount >= payment.amount) {
status = "paid";
} else if (paidAmount > 0) {
status = "partially_paid";
}
obligations[classId].obligations.push({
...payment,
payments: relatedPayments,
paidAmount,
remainingAmount,
status,
});
} else if (payment.type === "payment") {
// Group transactions by class
if (!transactions[classId]) {
transactions[classId] = {
classId: payment.classId,
classTitle: payment.classId?.classTitle || "Turma Desconhecida",
transactions: [],
};
}
transactions[classId].transactions.push(payment);
}
});
return { obligationsByClass: obligations, transactionsByClass: transactions };
}, [payments]);
// Get current data based on view mode
const currentData = viewMode === "obligations" ? obligationsByClass : transactionsByClass;
// Filter classes based on search and status
const filteredClasses = useMemo(() => {
const classesList = Object.values(currentData);
if (statusFilter === "all") {
return classesList.filter(cls =>
cls.classTitle.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return classesList.filter(cls => {
const items = viewMode === "obligations" ? cls.obligations : cls.transactions;
const hasMatchingStatus = items.some(item => {
if (viewMode === "obligations") {
if (statusFilter === "pending") return item.status === "pending";
if (statusFilter === "partially_paid") return item.status === "partially_paid";
if (statusFilter === "paid") return item.status === "paid";
} else {
if (statusFilter === "pending_verification") return item.status === "pending_verification";
if (statusFilter === "verified") return item.status === "verified";
if (statusFilter === "rejected") return item.status === "rejected";
}
return true;
});
return hasMatchingStatus && cls.classTitle.toLowerCase().includes(searchTerm.toLowerCase());
});
}, [currentData, searchTerm, statusFilter, viewMode]);
// Get filter options based on view mode
const filterOptions = viewMode === "obligations"
? [
{ value: "all", label: "Todos os status" },
{ value: "pending", label: "Pendentes" },
{ value: "partially_paid", label: "Parcialmente Pagas" },
{ value: "paid", label: "Pagas" },
]
: [
{ value: "all", label: "Todos os status" },
{ value: "pending_verification", label: "Aguardando Verificação" },
{ value: "verified", label: "Confirmados" },
{ value: "rejected", label: "Rejeitados" },
];
const toggleClass = (classId) => {
setExpandedClasses(prev => ({
...prev,
[classId]: !prev[classId],
}));
};
const expandAll = () => {
const allExpanded = {};
filteredClasses.forEach(cls => {
allExpanded[cls.classId?._id || "unknown"] = true;
});
setExpandedClasses(allExpanded);
};
const collapseAll = () => {
setExpandedClasses({});
};
const handleVerify = async (id, status, notes = "") => {
setProcessingId(id);
try {
const result = await updatePaymentStatusAction(id, { status, notes });
if (result.success) {
router.refresh();
}
} catch (error) {
console.error("Error updating payment:", error);
} finally {
setProcessingId(null);
setRejectModal({ open: false, paymentId: null, notes: "" });
}
};
const handleRejectClick = (paymentId) => {
setRejectModal({ open: true, paymentId, notes: "" });
};
const confirmReject = () => {
if (rejectModal.paymentId) {
handleVerify(rejectModal.paymentId, "rejected", rejectModal.notes);
}
};
const handleEditObligation = (obligation) => {
setEditModal({ open: true, obligation });
};
const handleCloseEditModal = () => {
setEditModal({ open: false, obligation: null });
};
const handleEditSuccess = () => {
router.refresh();
};
if (payments.length === 0) {
return (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-6 text-center text-gray-500 dark:text-gray-200 label-dark">
Nenhum pagamento encontrado.
</div>
);
}
// Calculate summary stats
const obligationStats = {
pending: Object.values(obligationsByClass).flatMap(c => c.obligations).filter(o => o.status === "pending").length,
partiallyPaid: Object.values(obligationsByClass).flatMap(c => c.obligations).filter(o => o.status === "partially_paid").length,
paid: Object.values(obligationsByClass).flatMap(c => c.obligations).filter(o => o.status === "paid").length,
};
const transactionStats = {
pendingVerification: Object.values(transactionsByClass).flatMap(c => c.transactions).filter(t => t.status === "pending_verification").length,
verified: Object.values(transactionsByClass).flatMap(c => c.transactions).filter(t => t.status === "verified").length,
rejected: Object.values(transactionsByClass).flatMap(c => c.transactions).filter(t => t.status === "rejected").length,
};
return (
<div>
{/* View Mode Toggle */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="Buscar turma..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
/>
</div>
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-neutral-600">
<button
onClick={() => { setViewMode("obligations"); setStatusFilter("all"); }}
className={`px-4 py-2 flex items-center gap-2 text-sm font-medium transition-colors ${
viewMode === "obligations"
? "bg-indigo-600 dark:bg-indigo-900/50 text-white dark:text-indigo-200"
: "bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-neutral-600"
}`}
>
<FaFileInvoiceDollar />
Obrigações
</button>
<button
onClick={() => { setViewMode("transactions"); setStatusFilter("all"); }}
className={`px-4 py-2 flex items-center gap-2 text-sm font-medium transition-colors ${
viewMode === "transactions"
? "bg-indigo-600 dark:bg-indigo-900/50 text-white dark:text-indigo-200"
: "bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-neutral-600"
}`}
>
<FaList />
Movimentações
</button>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
>
{filterOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<div className="flex gap-2">
<button
onClick={expandAll}
className="px-4 py-2 rounded-lg bg-blue-600 dark:bg-blue-900/50 text-white dark:text-blue-200 text-sm font-medium hover:bg-blue-700 dark:hover:bg-blue-900/70 transition-colors"
>
Expandir Todos
</button>
<button
onClick={collapseAll}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 text-sm font-medium hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
>
Recolher Todos
</button>
</div>
</div>
{/* Summary Stats */}
{viewMode === "obligations" ? (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-amber-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Pendentes</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{obligationStats.pending}</p>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-blue-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Parcialmente Pagas</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{obligationStats.partiallyPaid}</p>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-emerald-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Pagas</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{obligationStats.paid}</p>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-gray-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Total de Obrigações</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">
{obligationStats.pending + obligationStats.partiallyPaid + obligationStats.paid}
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-blue-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Aguardando Verificação</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{transactionStats.pendingVerification}</p>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-emerald-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Confirmadas</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{transactionStats.verified}</p>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-red-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Rejeitadas</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{transactionStats.rejected}</p>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-gray-500">
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Total de Movimentações</p>
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">
{transactionStats.pendingVerification + transactionStats.verified + transactionStats.rejected}
</p>
</div>
</div>
)}
{/* Classes List */}
<div className="space-y-4">
{filteredClasses.length === 0 ? (
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-6 text-center text-gray-500 dark:text-gray-200 label-dark">
Nenhuma turma encontrada com os filtros aplicados.
</div>
) : (
filteredClasses.map((classData) => (
viewMode === "obligations" ? (
<ObligationsCard
key={classData.classId?._id || "unknown"}
classData={classData}
isExpanded={!!expandedClasses[classData.classId?._id || "unknown"]}
onToggle={() => toggleClass(classData.classId?._id || "unknown")}
obligations={classData.obligations}
statusFilter={statusFilter}
onEditObligation={handleEditObligation}
/>
) : (
<TransactionsCard
key={classData.classId?._id || "unknown"}
classData={classData}
isExpanded={!!expandedClasses[classData.classId?._id || "unknown"]}
onToggle={() => toggleClass(classData.classId?._id || "unknown")}
transactions={classData.transactions}
statusFilter={statusFilter}
onVerify={handleVerify}
onRejectClick={handleRejectClick}
processingId={processingId}
onViewReceipt={(url) => setReceiptModal({ open: true, url })}
/>
)
))
)}
</div>
{/* Reject Modal */}
{rejectModal.open && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-neutral-800 rounded-lg p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-neutral-100">
Motivo da Rejeição
</h3>
<textarea
value={rejectModal.notes}
onChange={(e) => setRejectModal({ ...rejectModal, notes: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 text-sm mb-4"
rows={3}
placeholder="Informe o motivo da rejeição (opcional)"
/>
<div className="flex gap-2 justify-end">
<Button
variant="secondary"
onClick={() => setRejectModal({ open: false, paymentId: null, notes: "" })}
>
Cancelar
</Button>
<Button
variant="danger"
onClick={confirmReject}
disabled={processingId !== null}
>
Confirmar Rejeição
</Button>
</div>
</div>
</div>
)}
{/* Receipt Modal */}
{receiptModal.open && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={() => setReceiptModal({ open: false, url: null })}>
<div className="relative max-w-4xl max-h-[90vh] w-full" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setReceiptModal({ open: false, url: null })}
className="absolute -top-10 right-0 text-white hover:text-gray-300 transition-colors text-sm"
>
Fechar
</button>
{receiptModal.url?.includes("application/pdf") || receiptModal.url?.endsWith(".pdf") || receiptModal.url?.includes("data:application/pdf") ? (
<iframe
src={receiptModal.url}
className="w-full h-[85vh] rounded-lg shadow-xl"
title="Comprovante de pagamento"
/>
) : (
<img
src={receiptModal.url}
alt="Comprovante de pagamento"
className="w-full h-auto max-h-[85vh] object-contain rounded-lg shadow-xl"
/>
)}
<div className="mt-4 flex justify-center gap-3">
<a
href={receiptModal.url}
download="comprovante"
className="px-4 py-2 rounded-lg bg-blue-600 dark:bg-blue-900/50 text-white dark:text-blue-200 text-sm font-medium hover:bg-blue-700 dark:hover:bg-blue-900/70 transition-colors"
>
Download
</a>
<Button
variant="secondary"
onClick={() => setReceiptModal({ open: false, url: null })}
>
Fechar
</Button>
</div>
</div>
</div>
)}
{/* Edit Obligation Modal */}
<EditObligationModal
obligation={editModal.obligation}
isOpen={editModal.open}
onClose={handleCloseEditModal}
onSuccess={handleEditSuccess}
/>
</div>
);
}
@@ -0,0 +1,382 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { FaEye } from "react-icons/fa";
import Label from "@/app/(protected)/components/shared/Label";
import { updatePaymentStatusAction } from "@/app/lib/payments/actions";
export default function PaymentsTable({ payments }) {
const router = useRouter();
// Debug: log payments with receipt info
payments.forEach(p => {
if (p.type === "payment") {
console.log(`Payment ${p._id}:`, {
type: p.type,
status: p.status,
receiptUrl: p.receiptUrl,
hasReceipt: !!p.receiptUrl,
});
}
});
const [filter, setFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [processingId, setProcessingId] = useState(null);
const [rejectModal, setRejectModal] = useState({ open: false, paymentId: null, notes: "" });
const [receiptModal, setReceiptModal] = useState({ open: false, url: null });
const filteredPayments = payments.filter((payment) => {
if (filter !== "all" && payment.type !== filter) return false;
if (statusFilter !== "all" && payment.status !== statusFilter) return false;
return true;
});
const getStatusBadge = (payment) => {
if (payment.type === "obligation") {
return <Label color="amber" size="sm">Aguardando Pagamento</Label>;
}
const statusConfig = {
pending_verification: { color: "blue", label: "Aguardando Verificação" },
verified: { color: "emerald", label: "Pago" },
rejected: { color: "red", label: "Rejeitado" },
};
const config = statusConfig[payment.status];
if (!config) return <Label color="gray" size="sm">{payment.status}</Label>;
return <Label color={config.color} size="sm">{config.label}</Label>;
};
const handleVerify = async (id, status, notes = "") => {
setProcessingId(id);
try {
const result = await updatePaymentStatusAction(id, { status, notes });
if (result.success) {
router.refresh();
}
} catch (error) {
console.error("Error updating payment:", error);
} finally {
setProcessingId(null);
setRejectModal({ open: false, paymentId: null, notes: "" });
}
};
const handleRejectClick = (paymentId) => {
setRejectModal({ open: true, paymentId, notes: "" });
};
const confirmReject = () => {
if (rejectModal.paymentId) {
handleVerify(rejectModal.paymentId, "rejected", rejectModal.notes);
}
};
const getTypeLabel = (type) => {
return type === "obligation" ? "Obrigação" : "Pagamento";
};
return (
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 flex gap-2 flex-wrap bg-neutral-50 dark:bg-neutral-800/50">
<button
onClick={() => {
setFilter("all");
setStatusFilter("all");
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === "all" && statusFilter === "all"
? "bg-blue-600 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Todos
</button>
<button
onClick={() => {
setFilter("obligation");
setStatusFilter("all");
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === "obligation"
? "bg-amber-500 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Obrigações
</button>
<button
onClick={() => {
setFilter("payment");
setStatusFilter("all");
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === "payment"
? "bg-emerald-600 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Pagamentos
</button>
{filter === "payment" && (
<div className="flex gap-2 ml-auto">
<button
onClick={() => setStatusFilter("all")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
statusFilter === "all"
? "bg-blue-600 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Todos Status
</button>
<button
onClick={() => setStatusFilter("pending_verification")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
statusFilter === "pending_verification"
? "bg-blue-500 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Pendentes
</button>
<button
onClick={() => setStatusFilter("verified")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
statusFilter === "verified"
? "bg-emerald-600 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Verificados
</button>
<button
onClick={() => setStatusFilter("rejected")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
statusFilter === "rejected"
? "bg-red-600 text-white"
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
}`}
>
Rejeitados
</button>
</div>
)}
</div>
<table className="w-full text-sm text-left">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-gray-200 label-dark font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Tipo
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Aluno
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Grupo
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Pagador
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Valor
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Data
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
Status
</th>
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-right">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{filteredPayments.map((payment) => (
<tr
key={payment._id}
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<Label
color={
payment.type === "obligation"
? "amber"
: payment.status === "rejected"
? "red"
: "emerald"
}
size="sm"
>
{payment.type === "obligation" ? "Obrigação" : payment.status === "rejected" ? "Pagamento Rejeitado" : "Pagamento"}
</Label>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
{payment.userId?.fullName || "N/A"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
{payment.classId?.classTitle || "N/A"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
{payment.payerName || "-"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100 font-medium">
R$ {payment.amount?.toFixed(2) || "0.00"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-200 label-dark">
{payment.paymentDate
? format(new Date(payment.paymentDate), "dd/MM/yyyy", { locale: ptBR })
: payment.dueDate
? `Venc: ${format(new Date(payment.dueDate), "dd/MM/yyyy", { locale: ptBR })}`
: "N/A"}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(payment)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-right">
{payment.type === "payment" ? (
<div className="flex justify-end gap-2 items-center">
{payment.receiptUrl ? (
<button
onClick={() => setReceiptModal({ open: true, url: payment.receiptUrl })}
title="Ver comprovante"
className="p-2 text-blue-500 hover:text-blue-700 dark:hover:text-blue-400 transition-colors"
>
<FaEye className="text-lg" />
</button>
) : (
<span
title="Sem comprovante anexado"
className="text-neutral-400 dark:text-neutral-500 text-xs italic"
>
Sem comp.
</span>
)}
{payment.status === "pending_verification" && (
<>
<button
onClick={() => handleVerify(payment._id, "verified")}
disabled={processingId === payment._id}
title="Aprovar"
className="p-2 text-neutral-500 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors disabled:opacity-50"
>
<span className="text-lg font-bold"></span>
</button>
<button
onClick={() => handleRejectClick(payment._id)}
disabled={processingId === payment._id}
title="Rejeitar"
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
>
<span className="text-lg font-bold"></span>
</button>
</>
)}
{payment.status === "verified" && (
<Label color="emerald" size="sm"> Verificado</Label>
)}
{payment.status === "rejected" && (
<Label color="red" size="sm"> Rejeitado</Label>
)}
</div>
) : (
<span className="text-neutral-700 dark:text-gray-200 label-dark text-xs italic">
Aguardando pagamento
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{filteredPayments.length === 0 && (
<div className="text-center py-8 text-gray-700 dark:text-gray-200 label-dark">
Nenhum registro encontrado
</div>
)}
{rejectModal.open && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-neutral-800 rounded-lg p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-neutral-100">
Motivo da Rejeição
</h3>
<textarea
value={rejectModal.notes}
onChange={(e) => setRejectModal({ ...rejectModal, notes: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 text-sm mb-4"
rows={3}
placeholder="Informe o motivo da rejeição (opcional)"
/>
<div className="flex gap-2 justify-end">
<button
onClick={() => setRejectModal({ open: false, paymentId: null, notes: "" })}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 text-sm font-medium hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
>
Cancelar
</button>
<button
onClick={confirmReject}
disabled={processingId !== null}
className="px-4 py-2 rounded-lg bg-red-600 dark:bg-red-900/50 text-white dark:text-red-200 text-sm font-medium hover:bg-red-700 dark:hover:bg-red-900/70 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Confirmar Rejeição
</button>
</div>
</div>
</div>
)}
{/* Receipt Modal */}
{receiptModal.open && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={() => setReceiptModal({ open: false, url: null })}>
<div className="relative max-w-4xl max-h-[90vh] w-full" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setReceiptModal({ open: false, url: null })}
className="absolute -top-10 right-0 text-white hover:text-gray-300 transition-colors text-sm"
>
Fechar
</button>
{receiptModal.url?.includes("application/pdf") || receiptModal.url?.endsWith(".pdf") || receiptModal.url?.includes("data:application/pdf") ? (
<iframe
src={receiptModal.url}
className="w-full h-[85vh] rounded-lg shadow-xl"
title="Comprovante de pagamento"
/>
) : (
<img
src={receiptModal.url}
alt="Comprovante de pagamento"
className="w-full h-auto max-h-[85vh] object-contain rounded-lg shadow-xl"
/>
)}
<div className="mt-4 flex justify-center gap-3">
<a
href={receiptModal.url}
download="comprovante"
className="px-4 py-2 rounded-lg bg-blue-600 dark:bg-blue-900/50 text-white dark:text-blue-200 text-sm font-medium hover:bg-blue-700 dark:hover:bg-blue-900/70 transition-colors"
>
Download
</a>
<button
onClick={() => setReceiptModal({ open: false, url: null })}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 text-sm font-medium hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
>
Fechar
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,148 @@
import MainSection from "@/app/(protected)/components/shared/Main";
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
import { getPaymentModel } from "@/app/models/Payment";
import { getUserModel } from "@/app/models/User";
import { getClassModel } from "@/app/models/Class";
import { auth } from "@/app/lib/utils/auth";
import NotAuthorized from "@/app/auth/components/NotAuthorized";
import { redirect } from "next/navigation";
import PaymentStats from "./components/PaymentStats";
import CreateObligationForm from "./components/CreateObligationForm";
function serializePayment(payment) {
return {
_id: payment._id?.toString(),
classId: payment.classId
? {
_id: payment.classId._id?.toString(),
classTitle: payment.classId.classTitle,
}
: null,
userId: payment.userId
? {
_id: payment.userId._id?.toString(),
fullName: payment.userId.fullName,
email: payment.userId.email,
}
: null,
type: payment.type,
amount: payment.amount,
status: payment.status,
paymentDate: payment.paymentDate,
dueDate: payment.dueDate,
description: payment.description,
paymentMethod: payment.paymentMethod,
receiptUrl: payment.receiptUrl,
notes: payment.notes,
createdBy: payment.createdBy?.toString(),
createdAt: payment.createdAt?.toISOString(),
updatedAt: payment.updatedAt?.toISOString(),
relatedObligationId: payment.relatedObligationId?.toString(),
};
}
function serializeClass(cls) {
return {
_id: cls._id?.toString(),
classTitle: cls.classTitle,
students: cls.students?.map(student => student._id?.toString()) || [],
};
}
function serializeUser(user) {
return {
_id: user._id?.toString(),
fullName: user.fullName,
email: user.email,
};
}
export default async function AdminPaymentsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/auth/login");
const UserModel = await getUserModel();
const currentUser = await UserModel.findOne({ _id: session.user.id });
// Only admins can access this page
if (!currentUser.roles.includes("admin")) {
return <NotAuthorized />;
}
const Payment = await getPaymentModel();
const Class = await getClassModel();
// Fetch all payments and obligations
const payments = await Payment.find({})
.populate("classId", "classTitle")
.populate("userId", "fullName email")
.sort({ createdAt: -1 })
.lean();
// Fetch classes with their students for the obligation creation form
const classes = await Class.find({})
.populate("students", "fullName email _id")
.lean();
// Get all students for the user selection dropdown
const allUsers = await UserModel.find({
$or: [
{ roles: "student" },
{ roles: "guardian" }
]
}).select("fullName email _id").lean();
// Serialize data for client components
const serializedPayments = payments.map(serializePayment);
const serializedClasses = classes.map(serializeClass);
const serializedUsers = allUsers.map(serializeUser);
// Separate obligations and payments
const obligations = serializedPayments.filter(p => p.type === "obligation");
const userPayments = serializedPayments.filter(p => p.type === "payment");
// Calculate stats
const totalObligations = obligations.length;
const pendingObligations = obligations.filter(o => o.status === "pending").length;
const pendingPayments = userPayments.filter(p => p.status === "pending_verification").length;
const verifiedPayments = userPayments.filter(p => p.status === "verified").length;
const rejectedPayments = userPayments.filter(p => p.status === "rejected").length;
const stats = {
totalObligations,
pendingObligations,
pendingPayments,
verifiedPayments,
rejectedPayments,
};
return (
<MainSection>
<PageHeader
title="Pagamentos"
subtitle="Gerenciamento de obrigações e pagamentos"
actions={
<a
href="/admin/dashboard/payments/by-class"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3v18h18"/>
<path d="m19 9-5 5-4-4-3 3"/>
</svg>
Ver Pagamentos por Turma
</a>
}
/>
<PaymentStats stats={stats} />
<div className="mb-8">
<CreateObligationForm
classes={serializedClasses}
users={serializedUsers}
/>
</div>
</MainSection>
);
}
@@ -0,0 +1,396 @@
"use client";
import { useState, useEffect } from "react";
import { updateOrderStatusAction } from "@/app/lib/orders/actions";
import Label from "@/app/(protected)/components/shared/Label";
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
import { getFileUrl } from "@/app/lib/utils/storage/fileUrl";
import ProductImage from "@/app/(protected)/components/shared/ProductImage";
const STATUS_CONFIG = {
pending: { label: "Pendente", color: "gray" },
pending_verification: { label: "Aguardando Verificação", color: "amber" },
approved: { label: "Aprovado", color: "emerald" },
rejected: { label: "Rejeitado", color: "red" },
cancelled: { label: "Cancelado", color: "red" },
};
const formatPrice = (price) =>
new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(price);
const formatDate = (date) => {
if (!date) return "-";
return new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
};
export default function OrdersTable({ orders = [], stats = {} }) {
const [statusFilter, setStatusFilter] = useState("all");
const [productFilter, setProductFilter] = useState("all");
const [actionState, setActionState] = useState({ loading: false, message: null, type: null });
const [selectedOrder, setSelectedOrder] = useState(null);
const [rejectionReason, setRejectionReason] = useState("");
const productIds = [...new Set(orders.map((o) => o.productId?._id).filter(Boolean))];
const productNames = {};
orders.forEach((o) => {
if (o.productId?._id && o.productId?.title) {
productNames[o.productId._id] = o.productId.title;
}
});
let filteredOrders = orders;
if (statusFilter !== "all") {
filteredOrders = filteredOrders.filter((o) => o.status === statusFilter);
}
if (productFilter !== "all") {
filteredOrders = filteredOrders.filter((o) => o.productId?._id === productFilter);
}
const handleAction = async (orderId, status) => {
setActionState({ loading: true, message: null, type: null });
const result = await updateOrderStatusAction(orderId, {
status,
rejectionReason: status === "rejected" ? rejectionReason : undefined,
});
setActionState({
loading: false,
message: result.message,
type: result.success ? "success" : "error",
});
if (result.success) {
setSelectedOrder(null);
setRejectionReason("");
}
};
useEffect(() => {
if (actionState.message) {
const timer = setTimeout(() => setActionState((s) => ({ ...s, message: null })), 5000);
return () => clearTimeout(timer);
}
}, [actionState.message]);
return (
<div className="mt-6 space-y-4">
{actionState.message && (
<FlashMessage message={actionState.message} type={actionState.type} />
)}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<button
onClick={() => setStatusFilter(statusFilter === "all" ? null : "all")}
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
statusFilter === "all"
? "bg-indigo-50 dark:bg-indigo-900/20 border-2 border-indigo-400"
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
}`}
>
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{stats.total || 0}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Total</p>
</button>
<button
onClick={() => setStatusFilter(statusFilter === "pending" ? "all" : "pending")}
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
statusFilter === "pending"
? "bg-gray-50 dark:bg-gray-900/20 border-2 border-gray-400"
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
}`}
>
<p className="text-2xl font-bold text-gray-600 dark:text-gray-400">{stats.pendingNoProof || 0}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Sem Comprovante</p>
</button>
<button
onClick={() => setStatusFilter(statusFilter === "pending_verification" ? "all" : "pending_verification")}
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
statusFilter === "pending_verification"
? "bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400"
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
}`}
>
<p className="text-2xl font-bold text-amber-600 dark:text-amber-400">{stats.pending || 0}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Pendentes</p>
</button>
<button
onClick={() => setStatusFilter(statusFilter === "approved" ? "all" : "approved")}
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
statusFilter === "approved"
? "bg-emerald-50 dark:bg-emerald-900/20 border-2 border-emerald-400"
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
}`}
>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">{stats.approved || 0}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Aprovados</p>
</button>
<button
onClick={() => setStatusFilter(statusFilter === "rejected" ? "all" : "rejected")}
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
statusFilter === "rejected"
? "bg-red-50 dark:bg-red-900/20 border-2 border-red-400"
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
}`}
>
<p className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.rejected || 0}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Rejeitados</p>
</button>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-neutral-500 dark:text-neutral-400">Filtrar por produto:</label>
<select
value={productFilter}
onChange={(e) => setProductFilter(e.target.value)}
className="px-3 py-1.5 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-700 dark:text-neutral-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-800"
>
<option value="all">Todos os produtos</option>
{productIds.map((id) => (
<option key={id} value={id}>
{productNames[id] || "Produto removido"}
</option>
))}
</select>
{(statusFilter !== "all" || productFilter !== "all") && (
<button
onClick={() => { setStatusFilter("all"); setProductFilter("all"); }}
className="text-xs text-indigo-600 dark:text-indigo-400 hover:underline cursor-pointer"
>
Limpar filtros
</button>
)}
</div>
<div className="overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
<table className="w-full text-sm">
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs">
<tr>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
Produto
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
Aluno
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-right">
Valor
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center">
Status
</th>
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center">
Data
</th>
<th className="px-4 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center w-px">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{filteredOrders.length === 0 ? (
<tr>
<td colSpan="6" className="px-6 py-8 text-center text-neutral-500 dark:text-neutral-400">
Nenhum pedido encontrado.
</td>
</tr>
) : (
filteredOrders.map((order) => {
const statusCfg = STATUS_CONFIG[order.status] || { label: order.status, color: "gray" };
return (
<tr
key={order._id}
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
>
<td className="px-6 py-3.5 text-left">
<div className="flex items-center gap-3">
<ProductImage
src={getFileUrl(order.productId.imageUrl)}
alt={order.productId?.title}
className="w-10 h-10 rounded-lg object-cover border border-neutral-200 dark:border-neutral-700"
size="sm"
/>
<div>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{order.productId?.title || "Produto removido"}
</p>
</div>
</div>
</td>
<td className="px-6 py-3.5 text-neutral-700 dark:text-neutral-300 text-left">
<p className="font-medium">{order.userId?.fullName || "-"}</p>
<p className="text-xs text-neutral-500">{order.userId?.email || ""}</p>
</td>
<td className="px-6 py-3.5 text-right font-semibold text-neutral-900 dark:text-neutral-100">
{formatPrice(order.amount)}
</td>
<td className="px-6 py-3.5 text-center">
<Label color={statusCfg.color} size="sm">
{statusCfg.label}
</Label>
</td>
<td className="px-6 py-3.5 text-center text-neutral-500 dark:text-neutral-400 text-xs">
{formatDate(order.createdAt)}
</td>
<td className="px-4 py-3.5 text-center whitespace-nowrap">
<button
onClick={() => setSelectedOrder(order)}
className="text-xs px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors cursor-pointer"
>
Ver
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{selectedOrder && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Detalhes do Pedido
</h3>
<button
onClick={() => { setSelectedOrder(null); setRejectionReason(""); }}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200 transition-colors cursor-pointer"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
<div className="flex items-center gap-3 pb-4 border-b border-neutral-200 dark:border-neutral-700">
<ProductImage
src={getFileUrl(selectedOrder.productId.imageUrl)}
alt={selectedOrder.productId.title}
className="w-16 h-16 rounded-lg object-cover border border-neutral-200 dark:border-neutral-700"
/>
<div>
<p className="font-semibold text-neutral-900 dark:text-neutral-100">
{selectedOrder.productId?.title || "Produto removido"}
</p>
<p className="text-lg font-bold text-indigo-600 dark:text-indigo-400">
{formatPrice(selectedOrder.amount)}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-neutral-500 dark:text-neutral-400">Aluno</p>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{selectedOrder.userId?.fullName}
</p>
</div>
<div>
<p className="text-neutral-500 dark:text-neutral-400">Status</p>
<Label color={(STATUS_CONFIG[selectedOrder.status] || { color: "gray" }).color} size="sm">
{(STATUS_CONFIG[selectedOrder.status] || { label: selectedOrder.status }).label}
</Label>
</div>
<div>
<p className="text-neutral-500 dark:text-neutral-400">Método</p>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{selectedOrder.paymentMethod || "-"}
</p>
</div>
<div>
<p className="text-neutral-500 dark:text-neutral-400">Data do Pagamento</p>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{formatDate(selectedOrder.paymentDate)}
</p>
</div>
<div>
<p className="text-neutral-500 dark:text-neutral-400">Data do Pedido</p>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{formatDate(selectedOrder.createdAt)}
</p>
</div>
{selectedOrder.payerName && (
<div>
<p className="text-neutral-500 dark:text-neutral-400">Quem pagou</p>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{selectedOrder.payerName}
</p>
</div>
)}
</div>
{selectedOrder.receiptUrl && (
<div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-2">Comprovante</p>
<img
src={selectedOrder.receiptUrl}
alt="Comprovante"
className="max-w-full rounded-lg border border-neutral-200 dark:border-neutral-700"
/>
</div>
)}
{selectedOrder.rejectionReason && (
<div className="p-3 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-700 dark:text-red-300">
<strong>Motivo da rejeição:</strong> {selectedOrder.rejectionReason}
</p>
</div>
)}
{selectedOrder.status === "pending_verification" && (
<>
<div>
<label className="block text-sm text-neutral-500 dark:text-neutral-400 mb-1">
Motivo da rejeição (se aplicável)
</label>
<textarea
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-800"
rows="2"
placeholder="Informe o motivo caso vá rejeitar..."
></textarea>
</div>
<div className="flex gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<button
onClick={() => handleAction(selectedOrder._id, "approved")}
disabled={actionState.loading}
className="flex-1 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg disabled:opacity-50 transition-colors cursor-pointer"
>
Aprovar
</button>
<button
onClick={() => handleAction(selectedOrder._id, "rejected")}
disabled={actionState.loading}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg disabled:opacity-50 transition-colors cursor-pointer"
>
Rejeitar
</button>
</div>
</>
)}
</div>
<div className="flex justify-end mt-4">
<button
onClick={() => { setSelectedOrder(null); setRejectionReason(""); }}
className="px-4 py-2 border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 font-medium rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
>
Fechar
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More