commit 8fcb5aac66a6cfb82d952a78075d024221bbb2ca Author: Rafael Dias Martins Date: Mon Aug 31 14:10:20 2026 -0300 Initial commit diff --git a/BADGE_PATTERNS.md b/BADGE_PATTERNS.md new file mode 100644 index 0000000..e10ffe8 --- /dev/null +++ b/BADGE_PATTERNS.md @@ -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 +Active + +// Blue badge +Info + +// Red badge +Error + +// Amber badge +Warning + +// Yellow badge +Caution + +// Purple badge +Featured + +// Indigo badge +Secondary + +// Gray badge +Inactive +``` + +## Full Tailwind Classes (Reference) + +If you need to use the full Tailwind classes directly: + +```jsx + + Status + +``` + +## 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 + + Active + +``` + +### After: +```jsx +Active +``` + +## Related Files + +- `src/app/globals.css` - Contains all badge CSS classes with @apply +- `STYLE_GUIDE.md` - Overall style guide for the application diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..92841ff --- /dev/null +++ b/DEPLOYMENT.md @@ -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 | diff --git a/EXAM_FLOW.md b/EXAM_FLOW.md new file mode 100644 index 0000000..6e1e41a --- /dev/null +++ b/EXAM_FLOW.md @@ -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. diff --git a/PLANO-FIX-LOGIN-CREDENCIAIS.md b/PLANO-FIX-LOGIN-CREDENCIAIS.md new file mode 100644 index 0000000..34e8838 --- /dev/null +++ b/PLANO-FIX-LOGIN-CREDENCIAIS.md @@ -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 `` 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9aa18fe --- /dev/null +++ b/README.md @@ -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 +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] diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md new file mode 100644 index 0000000..ba7a084 --- /dev/null +++ b/STYLE_GUIDE.md @@ -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 +

+ Título da Página +

+ +// Título de Seção +

+ Título da Seção +

+ +// Subtítulo +

+ Descrição ou subtítulo +

+ +// 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 +
+ {/* conteúdo */} +
+ +// Formulário com espaçamento vertical +
+ {/* campos */} +
+ +// Botões com espaçamento horizontal +
+ + +
+``` + +--- + +## 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 + + Label Text + +``` + +### 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 + + Disponível + +``` + +**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 + + Em breve + +``` + +**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 + + Encerrada + +``` + +**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 + + Aguardando Pagamento + +``` + +**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 + + Obrigação + +``` + +**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 + + Leitura + +``` + +**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 + + Trabalhos + +``` + +**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 + + Outros + +``` + +**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' +}; + + + {category} + +``` + +### 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 +
+ {/* conteúdo */} +
+``` + +**Card com Link:** + +```jsx + +
+ {/* conteúdo */} +
+ +``` + +### Botões + +**Primary (Ação Principal):** + +```jsx + +``` + +**Secondary (Ação Secundária):** + +```jsx + +``` + +**Destructive (Ação de Exclusão):** + +```jsx + +``` + +**Botão com Ícone:** + +```jsx + +``` + +### Inputs + +**Input de Texto:** + +```jsx + +``` + +**Select:** + +```jsx +
+ + + {/* ícone de seta */} + +
+``` + +**Textarea:** + +```jsx + + + + {/* Image - Upload or URL */} +
+ + + {/* Toggle between upload and URL */} +
+ + +
+ + {/* Upload option */} + {imageInputType === "upload" && ( +
+ + {uploadedFileName && ( +

+ Arquivo selecionado: {uploadedFileName} +

+ )} +
+ )} + + {/* URL option */} + {imageInputType === "url" && ( + + )} + + {/* Image Preview */} + {imagePreview && ( +
+ Preview + +
+ )} +
+ + {/* Affiliate URL */} +
+ + +
+ + {/* Price and Category */} +
+ + +
+ + {/* Active */} +
+ + +
+ + {/* Class Types */} + {classTypes.length > 0 && ( +
+ +
+ {classTypes.map((classType) => ( + + ))} +
+

+ O produto será exibido para estas turmas +

+
+ )} + + + {/* Form Actions */} +
+ + +
+ + + ); +} + +export default AffiliateProductForm; diff --git a/src/app/(protected)/admin/dashboard/affiliate-products/AffiliateProductsTable.jsx b/src/app/(protected)/admin/dashboard/affiliate-products/AffiliateProductsTable.jsx new file mode 100644 index 0000000..30b499d --- /dev/null +++ b/src/app/(protected)/admin/dashboard/affiliate-products/AffiliateProductsTable.jsx @@ -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 ( +
+ {showMessage && state?.message && ( +
+ +
+ )} + + + + + + + + + + + {products.length === 0 ? ( + + + + ) : ( + products.map((product) => ( + + + + + + + )) + )} + +
+ Produto + + Categoria + + Status + + Ações +
+ Nenhum produto cadastrado ainda. +
+
+ {product.imageUrl && ( + {product.title} + )} +
+

{product.title}

+ {product.description && ( +

+ {product.description} +

+ )} +
+
+
+ {product.category || "-"} + + {product.active ? ( + + ) : ( + + )} + +
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/affiliate-products/add/page.jsx b/src/app/(protected)/admin/dashboard/affiliate-products/add/page.jsx new file mode 100644 index 0000000..ef31d13 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/affiliate-products/add/page.jsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/affiliate-products/edit/[id]/page.jsx b/src/app/(protected)/admin/dashboard/affiliate-products/edit/[id]/page.jsx new file mode 100644 index 0000000..ccc137c --- /dev/null +++ b/src/app/(protected)/admin/dashboard/affiliate-products/edit/[id]/page.jsx @@ -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 ( +
+

Registro não encontrado.

+
+ ); + } + + 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 ( +
+ +
+ +
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/affiliate-products/page.jsx b/src/app/(protected)/admin/dashboard/affiliate-products/page.jsx new file mode 100644 index 0000000..e6aa7a5 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/affiliate-products/page.jsx @@ -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 ( +
+ + + + } + /> + +
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/assignments/[id]/results/page.jsx b/src/app/(protected)/admin/dashboard/assignments/[id]/results/page.jsx new file mode 100644 index 0000000..04dd5e0 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/assignments/[id]/results/page.jsx @@ -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 ( + +
+

Registro não encontrado.

+
+
+ ); + } + + 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 ; + } + + const result = await getExamAssignmentById(assignmentId); + if (!result.success) { + return ( + +
+

+ Resultados da prova +

+

+ {result.error || "Não foi possível carregar os resultados desta prova."} +

+
+
+ ); + } + 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 ( + +
+
+

+ {assignment?.title || "Prova"} +

+

+ Resultados e correção das provas +

+
+ +
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/assignments/page.jsx b/src/app/(protected)/admin/dashboard/assignments/page.jsx new file mode 100644 index 0000000..05ec017 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/assignments/page.jsx @@ -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 ( +
+
+

Exam Assignments

+
+ + + + +
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/categories/CategoriesTable.jsx b/src/app/(protected)/admin/dashboard/categories/CategoriesTable.jsx new file mode 100644 index 0000000..87e1074 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/categories/CategoriesTable.jsx @@ -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 ( +
+ {showMessage && state?.message && ( +
+ +
+ )} + + + + + + + + + + {categories.map((category) => ( + + + + + + ))} + +
+ Nome da Categoria + + Descrição + + Ações +
+ {category.name} + + {category.description || "-"} + +
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/categories/CategoryForm.jsx b/src/app/(protected)/admin/dashboard/categories/CategoryForm.jsx new file mode 100644 index 0000000..acbb2aa --- /dev/null +++ b/src/app/(protected)/admin/dashboard/categories/CategoryForm.jsx @@ -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 ( +
+ {state?.message && showMessage && ( +
+ +
+ )} + +
+ {isPending &&

Carregando...

} + + {category._id && ( + + )} + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ ); +} + +export default CategoryForm; \ No newline at end of file diff --git a/src/app/(protected)/admin/dashboard/categories/add/page.jsx b/src/app/(protected)/admin/dashboard/categories/add/page.jsx new file mode 100644 index 0000000..e6d5d17 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/categories/add/page.jsx @@ -0,0 +1,14 @@ +import React from "react"; +import PageHeader from "@/app/(protected)/components/shared/PageHeader"; +import CategoryForm from "../CategoryForm"; + +function AddCategory() { + return ( +
+ + +
+ ); +} + +export default AddCategory; \ No newline at end of file diff --git a/src/app/(protected)/admin/dashboard/categories/edit/[id]/page.jsx b/src/app/(protected)/admin/dashboard/categories/edit/[id]/page.jsx new file mode 100644 index 0000000..b524666 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/categories/edit/[id]/page.jsx @@ -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 ( +
+

Registro não encontrado.

+
+ ); + } + + const category = await getCategoryById(id); + + if (!category) { + return
Categoria não encontrada
; + } + + return ( +
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/app/(protected)/admin/dashboard/categories/page.jsx b/src/app/(protected)/admin/dashboard/categories/page.jsx new file mode 100644 index 0000000..7889655 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/categories/page.jsx @@ -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 ( +
+ + + + } + /> + +
+ ); +} \ No newline at end of file diff --git a/src/app/(protected)/admin/dashboard/class/add/page.jsx b/src/app/(protected)/admin/dashboard/class/add/page.jsx new file mode 100644 index 0000000..afc9fd3 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/class/add/page.jsx @@ -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 ( +
+ + +
+ ); +} + +export default AddClass; diff --git a/src/app/(protected)/admin/dashboard/class/components/ClassForm.jsx b/src/app/(protected)/admin/dashboard/class/components/ClassForm.jsx new file mode 100644 index 0000000..72e1776 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/class/components/ClassForm.jsx @@ -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 ( +
+ {/* FlashMessage outside form for better visibility */} + {state?.message && showMessage && ( +
+ +
+ )} + +
+ {isPending && ( +
+

Salvando...

+
+ )} + + {classData._id && } + + {/* Hidden inputs for form submission */} + {(inputs.teachers || []).map((t) => ( + + ))} + {(inputs.students || []).map((s) => ( + + ))} + {(inputs.schedule?.days || []).map((d) => ( + + ))} + + + {/* Main Form - Grid Layout */} +
+ + {/* Header Section */} +
+

+ {classData._id ? "Editar Turma" : "Nova Turma"} +

+
+ + {/* Form Content */} +
+ {/* 2-Column Grid */} +
+ + {/* Tipo de Classe */} +
+ + +
+ + {/* Título */} +
+ + 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" + /> +
+ + {/* Data de Início */} +
+ + 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" + /> +
+ + {/* Data de Término */} +
+ + 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" + /> +
+ + {/* Horário */} +
+ + 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" + /> +
+ + {/* Mensalidade */} +
+ + 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" + /> +
+ + {/* Link */} +
+ + 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://..." + /> +
+ +
+ + {/* Professores - Full Width */} +
+ +
+ {teachers.length === 0 ? ( +

+ Nenhum professor cadastrado. +

+ ) : ( + 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 ( + + ); + }) + )} +
+
+ + {/* Alunos - Select Multi with Search (for many students) */} +
+ + + {/* Selected Students as Removable Tags */} + {(inputs.students || []).length > 0 && ( +
+ {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 ( + + {student ? student.fullName : `ID: ${idStr.slice(-6)}… (removido)`} + + + ); + })} +
+ )} + + {/* Add Students */} +
+ +
+ + {students.length === 0 && ( +

+ Nenhum aluno cadastrado. +

+ )} +
+ + {/* Dias da Semana - Full Width */} +
+ +
+ {DAYS.map((day) => { + const isChecked = (inputs.schedule?.days || []).includes(day); + return ( + { + const currentDays = inputs.schedule?.days || []; + const newDays = e.target.checked + ? [...currentDays, day] + : currentDays.filter((d) => d !== day); + setInputs({ ...inputs, schedule: { ...inputs.schedule, days: newDays } }); + }} + /> + ); + })} +
+
+ + {/* Herdar Arquivos - Only for new classes */} + {!classData._id && ( +
+ setInputs({ ...inputs, inheritFiles: e.target.checked })} + /> +
+ )} + +
+ + {/* Action Buttons */} +
+ + +
+ +
+
+
+ ); +} + +export default ClassForm; diff --git a/src/app/(protected)/admin/dashboard/class/components/ClassesTable.jsx b/src/app/(protected)/admin/dashboard/class/components/ClassesTable.jsx new file mode 100644 index 0000000..2642374 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/class/components/ClassesTable.jsx @@ -0,0 +1,28 @@ +import { ClassRow } from "@/app/(protected)/admin/dashboard/class/components/classRow"; + +export default function ClassesTable({classes}) { + + return ( +
+ + + + + + + + + + + + {classes.map((classItem) => ( + + ))} + +
TítuloProfessoresInícioStatusAções
+
+ ); +} \ No newline at end of file diff --git a/src/app/(protected)/admin/dashboard/class/components/DaysMultiSelect.jsx b/src/app/(protected)/admin/dashboard/class/components/DaysMultiSelect.jsx new file mode 100644 index 0000000..9b1f751 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/class/components/DaysMultiSelect.jsx @@ -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 ( +
+ + + setQuery("")} + > +
+
+
+ {selected.map((d) => ( + + {d} + + + ))} +
+ + setQuery(e.target.value)} + placeholder="Selecione os dias..." + /> + + + + +
+ + + {filtered.map((day) => ( + + {({ selected }) => ( +
+ {day} + {selected && } +
+ )} +
+ ))} +
+
+
+ + {selected.map((day) => ( + + ))} +
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/class/components/DeleteClassButton.jsx b/src/app/(protected)/admin/dashboard/class/components/DeleteClassButton.jsx new file mode 100644 index 0000000..c5b617f --- /dev/null +++ b/src/app/(protected)/admin/dashboard/class/components/DeleteClassButton.jsx @@ -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 ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/app/(protected)/admin/dashboard/class/components/FormCheckbox.jsx b/src/app/(protected)/admin/dashboard/class/components/FormCheckbox.jsx new file mode 100644 index 0000000..3b1431e --- /dev/null +++ b/src/app/(protected)/admin/dashboard/class/components/FormCheckbox.jsx @@ -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 ( +
+
+ +
+ + {description && ( +

+ {description} +

+ )} +
+
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/class/components/ToggleClassStatusButton.jsx b/src/app/(protected)/admin/dashboard/class/components/ToggleClassStatusButton.jsx new file mode 100644 index 0000000..f0f29c0 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/class/components/ToggleClassStatusButton.jsx @@ -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 ( + + ); +} diff --git a/src/app/(protected)/admin/dashboard/class/components/UsersMultiSelect.jsx b/src/app/(protected)/admin/dashboard/class/components/UsersMultiSelect.jsx new file mode 100644 index 0000000..03e8d2f --- /dev/null +++ b/src/app/(protected)/admin/dashboard/class/components/UsersMultiSelect.jsx @@ -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 ( +
+ + onSelectionChange(newSelected.map(u => u._id))} + onClose={() => setQuery("")} + > +
+
+ {selected.length > 0 && ( +
+ {selected.map((u) => ( + + {u.fullName} + + + ))} +
+ )} + 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" + /> + + + +
+ + {filtered.map((u) => ( + + {({ selected }) => ( +
+ {u.fullName} + {selected && } +
+ )} +
+ ))} + {filtered.length === 0 && query !== "" && ( +
+ Nenhum resultado encontrado +
+ )} +
+
+
+ {selected.map((s) => ( + + ))} +
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/class/components/classRow.js b/src/app/(protected)/admin/dashboard/class/components/classRow.js new file mode 100644 index 0000000..66dd0dc --- /dev/null +++ b/src/app/(protected)/admin/dashboard/class/components/classRow.js @@ -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 ( + + + + {classData?.classTitle} + + + + {( + await Promise.all( + classData.teachers.map((t) => + getFieldItemByItem(User, t._id, "fullName") + ) + ) + ).join(", ")} + + + {new Date(classData.startDate).toLocaleDateString('pt-BR')} + + + + + +
+ + + + + + + + +
+ + + ); +} diff --git a/src/app/(protected)/admin/dashboard/class/edit/[id]/page.jsx b/src/app/(protected)/admin/dashboard/class/edit/[id]/page.jsx new file mode 100644 index 0000000..e353aaa --- /dev/null +++ b/src/app/(protected)/admin/dashboard/class/edit/[id]/page.jsx @@ -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 ( +
+

Registro não encontrado.

+
+ ); + } + + 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 ( +
+ +

+ A turma que você está tentando editar não existe ou foi excluída. +

+
+ ); + } + //TODO usar a função toPlain + const plainClassData = JSON.parse(JSON.stringify(classData)); + + return ( +
+ + +
+ ); +} + +export default EditClass; diff --git a/src/app/(protected)/admin/dashboard/class/files/[id]/page.jsx b/src/app/(protected)/admin/dashboard/class/files/[id]/page.jsx new file mode 100644 index 0000000..09ddada --- /dev/null +++ b/src/app/(protected)/admin/dashboard/class/files/[id]/page.jsx @@ -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 ( + +
+

Turma não encontrada.

+
+
+ ); + } + + 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
Class not found.
; + } + + 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 ( + + + + ); + } catch (err) { + console.error("Error fetching class files:", err); + return
Error loading files.
; + } +} + +export default ClassFilesPage; diff --git a/src/app/(protected)/admin/dashboard/class/history/[id]/page.jsx b/src/app/(protected)/admin/dashboard/class/history/[id]/page.jsx new file mode 100644 index 0000000..f77b0dd --- /dev/null +++ b/src/app/(protected)/admin/dashboard/class/history/[id]/page.jsx @@ -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 ( + +
+

Registro não encontrado.

+
+
+ ); + } + + 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 ( + + + + ); +} diff --git a/src/app/(protected)/admin/dashboard/class/page.jsx b/src/app/(protected)/admin/dashboard/class/page.jsx new file mode 100644 index 0000000..3600b9a --- /dev/null +++ b/src/app/(protected)/admin/dashboard/class/page.jsx @@ -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 ( +
+ + + + } + /> + {params?.warn && ( +
+ +
+ )} + +
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/classTypes/ClassTypeForm.jsx b/src/app/(protected)/admin/dashboard/classTypes/ClassTypeForm.jsx new file mode 100644 index 0000000..faa8b7f --- /dev/null +++ b/src/app/(protected)/admin/dashboard/classTypes/ClassTypeForm.jsx @@ -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 ( +
+ {state?.message && showMessage && ( +
+ +
+ )} + +
+ {isPending &&

Carregando...

} + + {classType._id && ( + + )} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ ); +} + +export default ClassTypeForm; diff --git a/src/app/(protected)/admin/dashboard/classTypes/ClassTypesTable.jsx b/src/app/(protected)/admin/dashboard/classTypes/ClassTypesTable.jsx new file mode 100644 index 0000000..9e8e22e --- /dev/null +++ b/src/app/(protected)/admin/dashboard/classTypes/ClassTypesTable.jsx @@ -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 ( +
+ {showMessage && state?.message && ( +
+ +
+ )} + + + + + + + + + + + {classTypes.map((type) => ( + + + + + + + ))} + +
+ Tipo de Turma + + Faixa Etária + + Preço + + Ações +
+ {type.title} + + {type.ageRange || "-"} + + + +
+ + + + + + + + + +
+ + +
+
+
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/classTypes/add/AddClassTypeForm.jsx b/src/app/(protected)/admin/dashboard/classTypes/add/AddClassTypeForm.jsx new file mode 100644 index 0000000..c7cd8da --- /dev/null +++ b/src/app/(protected)/admin/dashboard/classTypes/add/AddClassTypeForm.jsx @@ -0,0 +1,5 @@ +import ClassTypeForm from "../ClassTypeForm"; + +export default function AddClassTypeForm() { + return ; +} \ No newline at end of file diff --git a/src/app/(protected)/admin/dashboard/classTypes/add/page.jsx b/src/app/(protected)/admin/dashboard/classTypes/add/page.jsx new file mode 100644 index 0000000..fe6403e --- /dev/null +++ b/src/app/(protected)/admin/dashboard/classTypes/add/page.jsx @@ -0,0 +1,14 @@ +import React from "react"; +import PageHeader from "@/app/(protected)/components/shared/PageHeader"; +import AddClassTypeForm from "./AddClassTypeForm"; + +function AddClassType() { + return ( +
+ + +
+ ); +} + +export default AddClassType; diff --git a/src/app/(protected)/admin/dashboard/classTypes/edit/[id]/page.jsx b/src/app/(protected)/admin/dashboard/classTypes/edit/[id]/page.jsx new file mode 100644 index 0000000..b96560d --- /dev/null +++ b/src/app/(protected)/admin/dashboard/classTypes/edit/[id]/page.jsx @@ -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 ( +
+

Registro não encontrado.

+
+ ); + } + + const classType = await getTypeClassAsPlainObject(id); + + if (!classType) { + return ( +
+

Registro não encontrado.

+
+ ); + } + + classType._id = classType._id.toString(); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/classTypes/files/[id]/page.jsx b/src/app/(protected)/admin/dashboard/classTypes/files/[id]/page.jsx new file mode 100644 index 0000000..4b6ed66 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/classTypes/files/[id]/page.jsx @@ -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 ( +
+

Registro não encontrado.

+
+ ); + } + + await getFileModel(); + + const classTypeModel = await getClassTypeModel(); + + try { + const classType = await classTypeModel + .findById(classTypeId) + .populate("files") + .lean(); + + if (!classType) { + return
Class Type not found.
; + } + + 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 ( +
+ + + + } + /> +
+ +
+
+ ); + } catch (err) { + console.error("Error fetching class type and files:", err); + return
Error loading files.
; + } +} + +export default ClassTypeFilesPage; diff --git a/src/app/(protected)/admin/dashboard/classTypes/files/page.jsx b/src/app/(protected)/admin/dashboard/classTypes/files/page.jsx new file mode 100644 index 0000000..5f01907 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/classTypes/files/page.jsx @@ -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 ( +
+ + + + } + /> +
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/classTypes/page.jsx b/src/app/(protected)/admin/dashboard/classTypes/page.jsx new file mode 100644 index 0000000..0142408 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/classTypes/page.jsx @@ -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 ( +
+ + + + } + /> + +
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/components/FilesTable.jsx b/src/app/(protected)/admin/dashboard/components/FilesTable.jsx new file mode 100644 index 0000000..8ba81ce --- /dev/null +++ b/src/app/(protected)/admin/dashboard/components/FilesTable.jsx @@ -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 ( + <> +
+ + + + + + + + + + + + {clientFiles && + clientFiles.map((file) => ( + + + + + + + + ))} + +
+ Título do Arquivo + + Tipo + + Tamanho + + Enviado + + Ações +
+ {file.title} + + {file.mimetype || "-"} + + {file.size ? `${(file.size / 1024).toFixed(2)} KB` : "-"} + + + + {new Date(file.uploadedAt).toLocaleDateString("pt-BR")} + + +
+ {file.url && ( + + + + )} + + +
+
+
+ + {/* Error Message */} + {errorMessage && ( +
+

{errorMessage}

+
+ )} + + {/* Confirmation Modal */} + {confirmationFile && ( +
+
+

+ Confirmar Exclusão +

+

+ Tem certeza que deseja excluir o arquivo "{confirmationFile.title}"? Esta ação não pode ser desfeita. +

+
+ + +
+
+
+ )} + + ); +} diff --git a/src/app/(protected)/admin/dashboard/error.jsx b/src/app/(protected)/admin/dashboard/error.jsx new file mode 100644 index 0000000..1cb5908 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/error.jsx @@ -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 ( +
+
+
+ + + +
+

+ Erro no painel +

+

+ Ocorreu um erro ao carregar os dados do painel administrativo. Tente novamente. +

+
+ + +
+
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/exam-templates/ExamTemplatesPage.jsx b/src/app/(protected)/admin/dashboard/exam-templates/ExamTemplatesPage.jsx new file mode 100644 index 0000000..899cd3d --- /dev/null +++ b/src/app/(protected)/admin/dashboard/exam-templates/ExamTemplatesPage.jsx @@ -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 ( +
+
+

Templates de Provas

+
+ +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/exam-templates/page.jsx b/src/app/(protected)/admin/dashboard/exam-templates/page.jsx new file mode 100644 index 0000000..7f8884e --- /dev/null +++ b/src/app/(protected)/admin/dashboard/exam-templates/page.jsx @@ -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 ; +} diff --git a/src/app/(protected)/admin/dashboard/exams/page.jsx b/src/app/(protected)/admin/dashboard/exams/page.jsx new file mode 100644 index 0000000..9ef3bb9 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/exams/page.jsx @@ -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 ( +
+ + +
+ +
+ +

Modelos de Prova

+
+

+ Crie e gerencie modelos de prova reutilizáveis com questões +

+ + + +
+ +

Atribuições de Provas

+
+

+ Atribua modelos de prova às turmas com cronogramas e configurações +

+ + + +
+ +

Estatísticas de Provas

+
+

+ Visualize estatísticas agregadas e métricas de desempenho +

+ +
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/files/[relatedToType]/[relatedToId]/add/page.jsx b/src/app/(protected)/admin/dashboard/files/[relatedToType]/[relatedToId]/add/page.jsx new file mode 100644 index 0000000..0183bce --- /dev/null +++ b/src/app/(protected)/admin/dashboard/files/[relatedToType]/[relatedToId]/add/page.jsx @@ -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 ( +
+ + +
+ ); +} + +export default AddFileClass; diff --git a/src/app/(protected)/admin/dashboard/files/add/page.jsx b/src/app/(protected)/admin/dashboard/files/add/page.jsx new file mode 100644 index 0000000..2b1ad88 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/files/add/page.jsx @@ -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 ( +
+ + +
+ ); +} + +export default AddFileClass; diff --git a/src/app/(protected)/admin/dashboard/files/components/FileUploadForm.jsx b/src/app/(protected)/admin/dashboard/files/components/FileUploadForm.jsx new file mode 100644 index 0000000..2265da9 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/files/components/FileUploadForm.jsx @@ -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 ( +
+

+ Enviar Arquivo{" "} + {relType && relId && para {relatedToTitle(relType)}} +

+ {type && catId && ( + <> + + + + )} + {editing && ( + <> + + + )} + {redirectUrl && ( + + )} + + {/* Server response message */} + {showMessage && state?.message && ( +
+

+ {state.success ? ( + + + + ) : ( + + + + )} + {state.message} +

+
+ )} + + {editing && file && ( +

+ Arquivo atual: {file.title} ( + + Ver + + ) +

+ )} + + {/* File input with validation */} +
+ + + {/* File info / error */} + {fileError && ( +

+ + + + {fileError} +

+ )} + {selectedFile && !fileError && ( +

+ + + + {selectedFile.name} ({fileSize}) +

+ )} +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ + {/* Submit button with loading state */} + +
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/files/edit/[id]/page.jsx b/src/app/(protected)/admin/dashboard/files/edit/[id]/page.jsx new file mode 100644 index 0000000..de91e25 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/files/edit/[id]/page.jsx @@ -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 ( +
+

Registro não encontrado.

+
+ ); + } + + 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 ( +
+ + +
+ ); +} + +export default EditFile; diff --git a/src/app/(protected)/admin/dashboard/files/page.jsx b/src/app/(protected)/admin/dashboard/files/page.jsx new file mode 100644 index 0000000..8985473 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/files/page.jsx @@ -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 ( +
+ + {/* */} + SÓ PÁGINA +
+ ); +} + +export default AddFileClass; diff --git a/src/app/(protected)/admin/dashboard/messages/components/MessageDetailModal.jsx b/src/app/(protected)/admin/dashboard/messages/components/MessageDetailModal.jsx new file mode 100644 index 0000000..bd129aa --- /dev/null +++ b/src/app/(protected)/admin/dashboard/messages/components/MessageDetailModal.jsx @@ -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 ( + + + + ); +} + +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 ( +
+
+
+
+
+

+ Mensagem +

+ + {statusLabels[message.status]} + +
+ +
+ +
+
+
+

+ Nome +

+

+ {message.name} +

+
+
+

+ Contato preferido +

+

+ {contactLabels[message.preferredContact] || message.preferredContact} +

+
+
+

+ Email +

+ + + {message.email} + +
+ {message.phone && ( +
+

+ Telefone / WhatsApp +

+ + + {message.phone} + +
+ )} +
+ +
+

+ Assunto +

+

+ {message.subject} +

+
+ +
+

+ Mensagem +

+
+ {message.message} +
+
+ +
+

+ Recebida em +

+

+ {formatDate(message.createdAt)} +

+
+ +
+ + +
+ +
+ + +
+ + )} +
+ +
+ +
+
+
+
+ )} + + ); +} diff --git a/src/app/(protected)/admin/dashboard/pedidos/page.jsx b/src/app/(protected)/admin/dashboard/pedidos/page.jsx new file mode 100644 index 0000000..1613c22 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/pedidos/page.jsx @@ -0,0 +1,41 @@ +import MainSection from "@/app/(protected)/components/shared/Main"; +import PageHeader from "@/app/(protected)/components/shared/PageHeader"; +import OrdersTable from "./OrdersTable"; +import { getOrdersAction } from "@/app/lib/orders/actions"; +import { auth } from "@/app/lib/utils/auth"; +import { getUserModel } from "@/app/models/User"; +import { redirect } from "next/navigation"; +import NotAuthorized from "@/app/auth/components/NotAuthorized"; + +export default async function AdminOrdersPage() { + const session = await auth(); + if (!session?.user?.id) redirect("/auth/login"); + + const UserModel = await getUserModel(); + const currentUser = await UserModel.findOne({ _id: session.user.id }); + + if (!currentUser.roles.includes("admin")) { + return ; + } + + const result = await getOrdersAction({}); + const orders = result.success ? result.data : []; + + const pending = orders.filter((o) => o.status === "pending_verification").length; + const pendingNoProof = orders.filter((o) => o.status === "pending").length; + const approved = orders.filter((o) => o.status === "approved").length; + const rejected = orders.filter((o) => o.status === "rejected").length; + const total = orders.length; + + const stats = { total, pending, pendingNoProof, approved, rejected }; + + return ( + + + + + ); +} diff --git a/src/app/(protected)/admin/dashboard/produtos/ProductForm.jsx b/src/app/(protected)/admin/dashboard/produtos/ProductForm.jsx new file mode 100644 index 0000000..77539aa --- /dev/null +++ b/src/app/(protected)/admin/dashboard/produtos/ProductForm.jsx @@ -0,0 +1,508 @@ +"use client"; + +import React, { useEffect, useState, useRef } from "react"; +import { useActionState } from "react"; +import { saveProductAction } from "@/app/lib/products/actions"; +import FlashMessage from "@/app/(protected)/components/shared/FlashMessage"; +import { useRouter } from "next/navigation"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { getFileUrl } from "@/app/lib/utils/storage/fileUrl"; + +function ProductForm({ product = {}, classTypes = [], classes = [], onCancel }) { + const router = useRouter(); + const fileInputRef = useRef(null); + const formRef = useRef(null); + + const MAX_FILE_SIZE = 500 * 1024 * 1024; + const [showMessage, setShowMessage] = useState(true); + const [saleType, setSaleType] = useState(product.saleType || "direct"); + const [isFree, setIsFree] = useState(product.price === 0 && product.saleType === "direct"); + const [imageInputType, setImageInputType] = useState( + product.imageUrl?.startsWith("http") ? "url" : "upload" + ); + const [imagePreview, setImagePreview] = useState(product.imageUrl ? getFileUrl(product.imageUrl) : ""); + const [uploadedFileName, setUploadedFileName] = useState(""); + + const initialState = { success: false, message: null }; + + const [state, action, isPending] = useActionState(saveProductAction, 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/produtos"); + } + }, [state?.success, router]); + + 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 = ""; + }; + + const handleSubmit = (e) => { + const formData = new FormData(formRef.current); + const productFiles = formData.getAll("productFiles"); + for (const file of productFiles) { + if (file && 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.preventDefault(); + return; + } + } + }; + + return ( +
+ {state?.message && showMessage && ( + + )} + +
+ {isPending &&

Salvando...

} + + {product._id && } + + +
+ {/* Sale Type Toggle */} +
+ +
+ + + +
+
+ +
+ + +
+ +
+ + +
+ + {/* Image */} +
+ +
+ + +
+ + {imageInputType === "upload" && ( +
+ + {uploadedFileName && ( +

+ Arquivo: {uploadedFileName} +

+ )} +
+ )} + + {imageInputType === "url" && ( + + )} + + {imagePreview && ( +
+ Preview + +
+ )} +
+ + {/* Affiliate-specific fields */} + {saleType === "affiliate" && ( +
+ + +

+ O aluno será redirecionado para este link ao clicar +

+
+ )} + + {/* Price - visible for both, required for direct */} +
+
+ {saleType === "direct" && ( +
+ + +
+ )} + {saleType === "affiliate" && ( + + )} + + {isFree && } + {saleType === "affiliate" && ( +

+ Preço de referência (apenas exibição) +

+ )} + {isFree && ( +

+ Produto gratuito — acesso será liberado imediatamente após "compra" +

+ )} +
+ + {/* Type - only for direct */} + {saleType === "direct" && ( +
+ + +
+ )} +
+ + {/* Stock & Active - only relevant for direct */} + {saleType === "direct" && ( +
+
+ + +
+ +
+ + +
+
+ )} + + {saleType === "affiliate" && ( +
+ + +
+ )} + + {/* Class linking - only for direct */} + {saleType === "direct" && classes.length > 0 && ( +
+ + +

+ Ao aprovar a compra, o usuário será matriculado nesta turma automaticamente +

+
+ )} + + {saleType === "direct" && classTypes.length > 0 && ( +
+ + +
+ )} +
+ + {saleType === "direct" && ( +
+ +

+ Estes arquivos serão liberados ao comprador após aprovação do pagamento +

+ + {product.files && product.files.length > 0 && ( +
+

+ Arquivos já anexados ({product.files.length}): +

+

+ Para remover arquivos, use a gestão de arquivos do sistema. +

+
+ )} +
+ )} + +
+ + +
+
+
+ ); +} + +export default ProductForm; diff --git a/src/app/(protected)/admin/dashboard/produtos/ProductsTable.jsx b/src/app/(protected)/admin/dashboard/produtos/ProductsTable.jsx new file mode 100644 index 0000000..d2d0e99 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/produtos/ProductsTable.jsx @@ -0,0 +1,171 @@ +"use client"; + +import { deleteProductAction } from "@/app/lib/products/actions"; +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"; +import { getFileUrl } from "@/app/lib/utils/storage/fileUrl"; +import ProductImage from "@/app/(protected)/components/shared/ProductImage"; + +const TYPE_LABELS = { + digital: "Digital", + physical: "Físico", + course_access: "Acesso a Curso", +}; + +const SALE_TYPE_LABELS = { + direct: { label: "Venda Direta", color: "indigo" }, + affiliate: { label: "Afiliado", color: "amber" }, +}; + +const formatPrice = (price) => { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + }).format(price); +}; + +export default function ProductsTable({ products = [] }) { + const initialState = { success: false, message: null }; + const [state, action, isPending] = useActionState(deleteProductAction, initialState); + const [showMessage, setShowMessage] = useState(false); + + useEffect(() => { + if (state?.message) { + setShowMessage(true); + const timer = setTimeout(() => setShowMessage(false), 5000); + return () => clearTimeout(timer); + } + }, [state?.message]); + + return ( +
+ {showMessage && state?.message && ( +
+ +
+ )} + + + + + + + + + + + + + {products.length === 0 ? ( + + + + ) : ( + products.map((product) => ( + + + + + + + + + )) + )} + +
+ Produto + + Tipo + + Preço + + Estoque + + Status + + Ações +
+ Nenhum produto cadastrado ainda. +
+
+ +
+

{product.title}

+ {product.description && ( +

+ {product.description} +

+ )} +
+
+
+ {(() => { + const st = SALE_TYPE_LABELS[product.saleType] || SALE_TYPE_LABELS.direct; + return ; + })()} + + {formatPrice(product.price)} + + {product.stock === null ? ( + + ) : ( + {product.stock} + )} + + {product.active ? ( + + ) : ( + + )} + +
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/produtos/add/page.jsx b/src/app/(protected)/admin/dashboard/produtos/add/page.jsx new file mode 100644 index 0000000..a4363ee --- /dev/null +++ b/src/app/(protected)/admin/dashboard/produtos/add/page.jsx @@ -0,0 +1,27 @@ +import ProductForm from "../ProductForm"; +import { getAllClassItems } from "@/app/lib/helpers/getItems"; +import { getClassModel } from "@/app/models/Class"; +import MainSection from "@/app/(protected)/components/shared/Main"; +import PageHeader from "@/app/(protected)/components/shared/PageHeader"; + +export default async function AddProductPage() { + const classTypes = await getAllClassItems(); + const ClassModel = await getClassModel(); + const classesRaw = await ClassModel.find({}).select("classTitle").lean(); + const classes = classesRaw.map((c) => ({ + _id: c._id.toString(), + classTitle: c.classTitle, + })); + + return ( + + +
+ +
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/produtos/edit/[id]/page.jsx b/src/app/(protected)/admin/dashboard/produtos/edit/[id]/page.jsx new file mode 100644 index 0000000..59e6061 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/produtos/edit/[id]/page.jsx @@ -0,0 +1,49 @@ +import ProductForm from "../../ProductForm"; +import { getProductByIdAction } from "@/app/lib/products/actions"; +import { getAllClassItems } from "@/app/lib/helpers/getItems"; +import { getClassModel } from "@/app/models/Class"; +import MainSection from "@/app/(protected)/components/shared/Main"; +import PageHeader from "@/app/(protected)/components/shared/PageHeader"; +import { notFound } from "next/navigation"; +import { isValidObjectId } from "@/app/lib/helpers/validObjectId"; + +export default async function EditProductPage({ params }) { + const { id } = await params; + + if (!isValidObjectId(id)) { + return ( + +
+

Registro não encontrado.

+
+
+ ); + } + + const result = await getProductByIdAction(id); + + if (!result.success || !result.data) { + notFound(); + } + + const product = result.data; + const classTypes = await getAllClassItems(); + const ClassModel = await getClassModel(); + const classesRaw = await ClassModel.find({}).select("classTitle").lean(); + const classes = classesRaw.map((c) => ({ + _id: c._id.toString(), + classTitle: c.classTitle, + })); + + return ( + + +
+ +
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/produtos/page.jsx b/src/app/(protected)/admin/dashboard/produtos/page.jsx new file mode 100644 index 0000000..9efb160 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/produtos/page.jsx @@ -0,0 +1,41 @@ +import Link from "next/link"; +import PageHeader from "@/app/(protected)/components/shared/PageHeader"; +import ProductsTable from "./ProductsTable"; +import { getProductsAction } from "@/app/lib/products/actions"; +import { auth } from "@/app/lib/utils/auth"; +import { getUserModel } from "@/app/models/User"; +import { redirect } from "next/navigation"; +import MainSection from "@/app/(protected)/components/shared/Main"; +import NotAuthorized from "@/app/auth/components/NotAuthorized"; + +export default async function AdminProductsPage() { + const session = await auth(); + if (!session?.user?.id) redirect("/auth/login"); + + const UserModel = await getUserModel(); + const currentUser = await UserModel.findOne({ _id: session.user.id }); + + if (!currentUser.roles.includes("admin")) { + return ; + } + + const result = await getProductsAction({ active: undefined }); + const products = result.success ? result.data : []; + + return ( + + + + + } + /> + + + ); +} diff --git a/src/app/(protected)/admin/dashboard/statistics/exams/page.jsx b/src/app/(protected)/admin/dashboard/statistics/exams/page.jsx new file mode 100644 index 0000000..bb2bf13 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/statistics/exams/page.jsx @@ -0,0 +1,32 @@ +import { getExamStatistics } from '@/app/lib/actions/examActions'; +import ExamStatistics from '@/app/(protected)/components/teacher/ExamStatistics'; +import PageHeader from '@/app/(protected)/components/shared/PageHeader'; +import { ChartBarIcon } from '@heroicons/react/24/outline'; + +export default async function ExamStatisticsPage({ + searchParams, +}) { + const filters = { + classId: searchParams.classId, + studentId: searchParams.studentId, + templateId: searchParams.templateId, + startDate: searchParams.startDate, + endDate: searchParams.endDate, + page: parseInt(searchParams.page) || 1, + limit: parseInt(searchParams.limit) || 20, + }; + + const statistics = await getExamStatistics(filters); + + return ( +
+ } + /> + + +
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/users/components/UpdateUserForm.jsx b/src/app/(protected)/admin/dashboard/users/components/UpdateUserForm.jsx new file mode 100644 index 0000000..3151510 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/users/components/UpdateUserForm.jsx @@ -0,0 +1,483 @@ +"use client"; + +import { useState, useActionState, useEffect } from "react"; +import updateUserData from "@/app/lib/users/updateUserAction"; +import FlashMessage from "@/app/(protected)/components/shared/FlashMessage"; +import RoleCheckbox from "@/app/(protected)/components/shared/RoleCheckbox"; +import Label from "@/app/(protected)/components/shared/Label"; +import Link from "next/link"; +import { formatDateToBR, maskDateToBR } from "@/app/lib/utils/dateUtils"; +import { sanitizeUsername, sanitizeFullName, hasInvalidUsernameChars, hasInvalidFullNameChars } from "@/app/lib/utils/stringUtils"; + +const initialState = { + success: false, + message: "", + inputs: {}, +}; + +const ROLE_OPTIONS = [ + { value: "student", label: "Estudante", color: "emerald" }, + { value: "parent", label: "Responsável", color: "blue" }, + { value: "teacher", label: "Professor", color: "amber" }, + { value: "admin", label: "Administrador", color: "red" }, +]; + +function UpdateUserForm({ user, parents = [], students = [], isAdmin = false }) { + const [state, action, isPending] = useActionState( + updateUserData, + initialState + ); + + const [showMessage, setShowMessage] = useState(false); + const [fullNameWarning, setFullNameWarning] = useState(false); + const [usernameWarning, setUsernameWarning] = useState(false); + const [inputs, setInputs] = useState({ + fullName: user?.fullName || "", + username: user?.username || "", + email: user?.email || "", + dateOfBirth: formatDateToBR(user?.dateOfBirth), + roles: user?.roles || ["student"], + guardiansAccounts: (user?.guardiansAccounts || []).map((g) => + typeof g === "string" ? g : g.toString() + ), + wardAccounts: (user?.wardAccounts || []).map((w) => + typeof w === "string" ? w : w.toString() + ), + }); + + useEffect(() => { + if (state?.message) { + setShowMessage(true); + const timer = setTimeout(() => setShowMessage(false), 15000); + return () => clearTimeout(timer); + } else { + setShowMessage(false); + } + }, [state.message]); + + const onRoleChange = (role) => (e) => { + const currentRoles = inputs.roles || []; + const newRoles = e.target.checked + ? [...currentRoles, role] + : currentRoles.filter((r) => r !== role); + setInputs({ ...inputs, roles: newRoles }); + }; + + const onAddGuardian = (guardianId) => { + setInputs({ + ...inputs, + guardiansAccounts: [...(inputs.guardiansAccounts || []), guardianId], + }); + }; + + const onRemoveGuardian = (guardianId) => { + setInputs({ + ...inputs, + guardiansAccounts: (inputs.guardiansAccounts || []).filter( + (id) => id !== guardianId + ), + }); + }; + + const onAddWard = (wardId) => { + setInputs({ + ...inputs, + wardAccounts: [...(inputs.wardAccounts || []), wardId], + }); + }; + + const onRemoveWard = (wardId) => { + setInputs({ + ...inputs, + wardAccounts: (inputs.wardAccounts || []).filter( + (id) => id !== wardId + ), + }); + }; + + const getRoleColor = (role) => { + return ROLE_OPTIONS.find((r) => r.value === role)?.color || "gray"; + }; + + return ( +
+ {/* FlashMessage */} + {state?.message && showMessage && ( +
+ +
+ )} + +
+ {isPending && ( +
+

Salvando...

+
+ )} + + + + {/* Hidden inputs for arrays */} + {(inputs.roles || []).map((role) => ( + + ))} + {(inputs.guardiansAccounts || []).map((guardianId) => ( + + ))} + {(inputs.wardAccounts || []).map((wardId) => ( + + ))} + + {/* Main Form - Card Layout */} +
+ {/* Header */} +
+

+ Editar Usuário +

+
+ + {/* Form Content */} +
+ {/* 2-Column Grid */} +
+ {/* Nome Completo */} +
+ + { + const raw = e.target.value; + const clean = sanitizeFullName(raw); + setInputs({ ...inputs, fullName: clean }); + setFullNameWarning(hasInvalidFullNameChars(raw)); + }} + 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" + /> + {fullNameWarning && ( +

+ Acentos, caracteres especiais e maiúsculas são removidos automaticamente. Use apenas letras sem acento. +

+ )} +
+ + {/* Nome de Usuário */} +
+ + { + const raw = e.target.value; + const clean = sanitizeUsername(raw); + setInputs({ ...inputs, username: clean }); + setUsernameWarning(hasInvalidUsernameChars(raw)); + }} + 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" + /> + {usernameWarning && ( +

+ Acentos, espaços e maiúsculas são removidos automaticamente. Use apenas letras, números e _. +

+ )} +
+ + {/* Email */} +
+ + + setInputs({ ...inputs, email: 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="email@exemplo.com" + /> +
+ + {/* Data de Nascimento */} +
+ + + setInputs({ ...inputs, dateOfBirth: maskDateToBR(e.target.value) }) + } + placeholder="DD/MM/AAAA" + inputMode="numeric" + maxLength={10} + 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" + /> +
+
+ + {/* Admin Only Section */} + {isAdmin && ( + <> + {/* Roles - Checkboxes */} +
+ +
+ {ROLE_OPTIONS.map((roleOption) => { + const isChecked = (inputs.roles || []).includes( + roleOption.value + ); + return ( + + ); + })} +
+
+ + {/* Guardians - Only for students */} + {(inputs.roles || []).includes("student") && ( +
+ + + {/* Selected Guardians as Removable Tags */} + {(inputs.guardiansAccounts || []).length > 0 && ( +
+ {inputs.guardiansAccounts.map((guardianId) => { + const guardian = parents.find( + (p) => + (typeof p._id === "string" + ? p._id + : p._id.toString()) === + (typeof guardianId === "string" + ? guardianId + : guardianId.toString()) + ); + if (!guardian) return null; + return ( + + ); + })} +
+ )} + + {/* Add Guardian Dropdown */} +
+ +
+ + {parents.length === 0 && ( +

+ Nenhum responsável cadastrado. +

+ )} +
+ )} + + {/* Wards - Only for parents/guardians */} + {(inputs.roles || []).includes("parent") && ( +
+ + + {/* Selected Wards as Removable Tags */} + {(inputs.wardAccounts || []).length > 0 && ( +
+ {inputs.wardAccounts.map((wardId) => { + const ward = students.find( + (s) => + (typeof s._id === "string" + ? s._id + : s._id.toString()) === + (typeof wardId === "string" + ? wardId + : wardId.toString()) + ); + if (!ward) return null; + return ( + + ); + })} +
+ )} + + {/* Add Ward Dropdown */} +
+ +
+ + {students.length === 0 && ( +

+ Nenhum estudante cadastrado. +

+ )} +
+ )} + + )} +
+ + {/* Action Buttons */} +
+ + Voltar + + +
+
+
+
+ ); +} + +export default UpdateUserForm; diff --git a/src/app/(protected)/admin/dashboard/users/components/UsersTable.jsx b/src/app/(protected)/admin/dashboard/users/components/UsersTable.jsx new file mode 100644 index 0000000..98913b8 --- /dev/null +++ b/src/app/(protected)/admin/dashboard/users/components/UsersTable.jsx @@ -0,0 +1,385 @@ +"use client"; + +import { deleteUser } from "@/app/lib/users/deleteUser"; +import { generatePasswordResetTokenAction } from "@/app/lib/users/resetPasswordActions"; +import { useActionState } from "react"; +import { useEffect, useState, useRef, useMemo } from "react"; +import Link from "next/link"; +import { FaEdit, FaTrash, FaKey, FaSearch, FaChevronLeft, FaChevronRight } from "react-icons/fa"; +import { IoCalendarOutline } from "react-icons/io5"; +import Label from "@/app/(protected)/components/shared/Label"; +import FlashMessage from "@/app/(protected)/components/shared/FlashMessage"; + +const ITEMS_PER_PAGE = 15; + +export default function UsersTable({ users }) { + const deleteInitialState = { success: false, message: null }; + const [state, action, isPending] = useActionState(deleteUser, deleteInitialState); + const [showMessage, setShowMessage] = useState(false); + const [resetModal, setResetModal] = useState({ open: false, userId: null, userName: "" }); + const [resetLoading, setResetLoading] = useState(false); + const [resetResult, setResetResult] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const linkRef = useRef(null); + + const sortedUsers = useMemo(() => { + return [...users].sort((a, b) => + a.fullName.localeCompare(b.fullName, "pt-BR", { sensitivity: "base" }) + ); + }, [users]); + + const filteredUsers = useMemo(() => { + if (!searchTerm.trim()) return sortedUsers; + const term = searchTerm.toLowerCase().trim(); + return sortedUsers.filter( + (user) => + user.fullName.toLowerCase().includes(term) || + user.username.toLowerCase().includes(term) || + (user.email && user.email.toLowerCase().includes(term)) + ); + }, [sortedUsers, searchTerm]); + + const totalPages = Math.max(1, Math.ceil(filteredUsers.length / ITEMS_PER_PAGE)); + const safePage = Math.min(currentPage, totalPages); + const paginatedUsers = filteredUsers.slice( + (safePage - 1) * ITEMS_PER_PAGE, + safePage * ITEMS_PER_PAGE + ); + + useEffect(() => { + setCurrentPage(1); + }, [searchTerm]); + + useEffect(() => { + if (state?.message) { + setShowMessage(true); + const timer = setTimeout(() => setShowMessage(false), 5000); + return () => clearTimeout(timer); + } + }, [state?.message]); + + const handleGenerateResetLink = async () => { + if (!resetModal.userId) return; + setResetLoading(true); + setResetResult(null); + + try { + const result = await generatePasswordResetTokenAction(resetModal.userId); + setResetResult(result); + } catch { + setResetResult({ success: false, message: "Erro inesperado." }); + } finally { + setResetLoading(false); + } + }; + + const handleCopyLink = () => { + if (linkRef.current) { + navigator.clipboard.writeText(linkRef.current.value); + } + }; + + const closeResetModal = () => { + setResetModal({ open: false, userId: null, userName: "" }); + setResetResult(null); + setResetLoading(false); + }; + + return ( + <> +
+ {showMessage && state?.message && ( +
+ +
+ )} + +
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-9 pr-4 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-neutral-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors" + /> +
+ + {filteredUsers.length} usuário{filteredUsers.length !== 1 ? "s" : ""} encontrado{filteredUsers.length !== 1 ? "s" : ""} + +
+ + + + + + + + + + + + + + {paginatedUsers.length === 0 ? ( + + + + ) : ( + paginatedUsers.map((user) => ( + + + + + + + + + )) + )} + +
+ Nome Completo + + Nome de Usuário + + Email + + Data de Nascimento + + Funções + + Ações +
+ Nenhum usuário encontrado. +
+ {user.fullName} + + {user.username} + + {user.email || "-"} + + + + {new Date(user.dateOfBirth).toLocaleDateString('pt-BR', {timeZone: 'UTC'})} + + +
+ {user.roles.length > 0 ? ( + user.roles.map(role => { + const roleColors = { + student: "emerald", + parent: "blue", + teacher: "amber", + admin: "red", + }; + return ( + + ); + }) + ) : ( + - + )} +
+
+
+ + + + +
+ + +
+
+
+ + {totalPages > 1 && ( +
+ + Página {safePage} de {totalPages} + +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((page) => { + if (totalPages <= 7) return true; + if (page === 1 || page === totalPages) return true; + if (Math.abs(page - safePage) <= 1) return true; + return false; + }) + .reduce((acc, page, idx, arr) => { + if (idx > 0 && page - arr[idx - 1] > 1) { + acc.push("..."); + } + acc.push(page); + return acc; + }, []) + .map((item, idx) => + item === "..." ? ( + ... + ) : ( + + ) + )} + +
+
+ )} +
+ + {resetModal.open && ( +
+
e.stopPropagation()}> +
+

+ Resetar Senha +

+ +
+ +

+ Gerar link de redefinição de senha para {resetModal.userName}. + O link é válido por 24 horas e deve ser enviado manualmente ao usuário. +

+ + {!resetResult && ( + + )} + + {resetResult?.success && resetResult.data && ( +
+
+

+ Link gerado com sucesso! Expira em 24 horas. +

+
+
+ + +
+ +
+ )} + + {resetResult && !resetResult.success && ( +
+
+

{resetResult.message}

+
+ +
+ )} +
+
+ )} + + ); +} diff --git a/src/app/(protected)/admin/dashboard/users/edit/[id]/page.jsx b/src/app/(protected)/admin/dashboard/users/edit/[id]/page.jsx new file mode 100644 index 0000000..0350cba --- /dev/null +++ b/src/app/(protected)/admin/dashboard/users/edit/[id]/page.jsx @@ -0,0 +1,66 @@ +import { getUserModel } from "@/app/models/User"; +import UpdateUserForm from "@/app/(protected)/admin/dashboard/users/components/UpdateUserForm"; +import PageHeader from "@/app/(protected)/components/shared/PageHeader"; +import { toPlain } from "@/app/lib/helpers/toPlain"; +import { getUsersByRole } from "@/app/lib/users/getUsersByRole"; +import { auth } from "@/auth"; +import NotAuthorized from "@/app/auth/components/NotAuthorized"; +import { isValidObjectId } from "@/app/lib/helpers/validObjectId"; + +export default async function UpdateUserPage({ params }) { + const { id } = await params; + + if (!isValidObjectId(id)) { + return ( +
+

Registro não encontrado.

+
+ ); + } + + const session = await auth(); + + const User = await getUserModel(); + const userDoc = await User.findById(id) + .select("-passwordHash -createdAt -modifiedAt -__v") + .lean(); + + if (!userDoc) { + return ; + } + + if (!session) { + return ; + } + + const user = toPlain(userDoc); + user._id = user._id.toString(); + + const isOwner = session.user.id === id; + const isGuardian = user.guardiansAccounts.some(g => g.toString() === session.user.id); + + // Check admin role regardless of owner/guardian status + const sessionUser = await User.findById(session.user.id).select("roles").lean(); + const isAdmin = sessionUser?.roles?.includes("admin") || false; + + if (!isOwner && !isGuardian && !isAdmin) { + return ; + } + + const parentsResult = await getUsersByRole(["parent"]); + const parents = parentsResult.success ? parentsResult.data : []; + const studentsResult = await getUsersByRole(["student"]); + const students = studentsResult.success ? studentsResult.data : []; + + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/app/(protected)/admin/dashboard/users/page.jsx b/src/app/(protected)/admin/dashboard/users/page.jsx new file mode 100644 index 0000000..2fa9e7a --- /dev/null +++ b/src/app/(protected)/admin/dashboard/users/page.jsx @@ -0,0 +1,18 @@ +import PageHeader from "@/app/(protected)/components/shared/PageHeader"; +import UsersTable from "./components/UsersTable"; +import { getUsersForAdminAction } from "@/app/lib/users/actions"; + +export default async function UsersAdminPage() { + const result = await getUsersForAdminAction(); + const users = result.success ? result.data : []; + + return ( +
+ + +
+ ); +} diff --git a/src/app/(protected)/admin/layout.jsx b/src/app/(protected)/admin/layout.jsx new file mode 100644 index 0000000..461ba20 --- /dev/null +++ b/src/app/(protected)/admin/layout.jsx @@ -0,0 +1,31 @@ +import SideBarNav from "../components/shared/SideBarNav"; +import { auth } from "@/app/lib/utils/auth"; +import { redirect } from "next/navigation"; +import NotAuthorized from "@/app/auth/components/NotAuthorized"; + +export default async function AdminLayout({ children }) { + const session = await auth(); + + // If not authenticated, redirect to login + if (!session?.user?.id) { + redirect("/auth/login"); + } + + // If not admin, render without sidebar + const isAdmin = session.user.roles?.includes("admin"); + + if (!isAdmin) { + return ; + } + + return ( +
+ +
+
+ {children} +
+
+
+ ); +} diff --git a/src/app/(protected)/components/AdminDashboard.jsx b/src/app/(protected)/components/AdminDashboard.jsx new file mode 100644 index 0000000..5ea44d1 --- /dev/null +++ b/src/app/(protected)/components/AdminDashboard.jsx @@ -0,0 +1,85 @@ +import { + BookmarkIcon, + UserGroupIcon, + UsersIcon, + CurrencyDollarIcon, + AcademicCapIcon, + ChatBubbleLeftRightIcon, +} from "@heroicons/react/24/outline"; +import DashboardCard from "./shared/DashboardCard"; +import PageHeader from "./shared/PageHeader"; +import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils"; +import { getContactMessageModel } from "@/app/models/ContactMessage"; + +export const dynamic = "force-dynamic"; + +async function AdminDashboard() { + const ContactMessage = await getContactMessageModel(); + const unreadCount = await ContactMessage.countDocuments({ status: "new" }); + return ( +
+ + +
+ + + + + + + + + + + 0 ? `Mensagens (${unreadCount} novas)` : "Mensagens"} + description="Visualize e responda as mensagens recebidas pelo formulário de contato." + buttonText="Ver Mensagens" + buttonColor="rose" + link="/admin/dashboard/messages" + icon={ChatBubbleLeftRightIcon} + /> +
+
+ ); +} + +export default AdminDashboard; diff --git a/src/app/(protected)/components/DashboardLayout.jsx b/src/app/(protected)/components/DashboardLayout.jsx new file mode 100644 index 0000000..1a299be --- /dev/null +++ b/src/app/(protected)/components/DashboardLayout.jsx @@ -0,0 +1,10 @@ +import PageTitle from "./shared/PageTitle"; + +export function DashboardLayout({ title, subtitle, children, className = "" }) { + return ( +
+ {title && } + {children} +
+ ); +} diff --git a/src/app/(protected)/components/exam-templates/AssignmentForm.jsx b/src/app/(protected)/components/exam-templates/AssignmentForm.jsx new file mode 100644 index 0000000..b05a5a9 --- /dev/null +++ b/src/app/(protected)/components/exam-templates/AssignmentForm.jsx @@ -0,0 +1,598 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { XMarkIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline"; +import { createExamAssignment, updateExamAssignment } from "@/app/lib/actions/examActions"; + +export default function AssignmentForm({ + isOpen, + classId: initialClassId, + templates = [], + classes = [], + assignment = null, // For editing mode + isEditing = false, +}) { + const router = useRouter(); + const [isPending, setIsPending] = useState(false); + const [selectedClassId, setSelectedClassId] = useState(initialClassId || ""); + const [selectedTemplateId, setSelectedTemplateId] = useState(""); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [instructions, setInstructions] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [timeLimit, setTimeLimit] = useState(""); + const [allowRetakes, setAllowRetakes] = useState(false); + const [maxAttempts, setMaxAttempts] = useState("1"); + const [showResultsAfterGrading, setShowResultsAfterGrading] = useState(true); + const [status, setStatus] = useState("active"); + + // Custom questions state + const [useCustomQuestions, setUseCustomQuestions] = useState(false); + const [customQuestions, setCustomQuestions] = useState([]); + + const handleClose = () => { + window.dispatchEvent(new CustomEvent('closeAssignmentModal')); + }; + + // Initialize form when editing + useEffect(() => { + if (isEditing && assignment) { + setSelectedClassId(assignment.classId?._id || assignment.classId || ""); + setSelectedTemplateId(assignment.examTemplateId?._id || assignment.examTemplateId || ""); + setTitle(assignment.title || ""); + setDescription(assignment.description || ""); + setInstructions(assignment.instructions || ""); + setStartDate(assignment.startDate ? new Date(assignment.startDate).toISOString().slice(0, 16) : ""); + setEndDate(assignment.endDate ? new Date(assignment.endDate).toISOString().slice(0, 16) : ""); + setTimeLimit(assignment.timeLimit ? String(assignment.timeLimit) : ""); + setAllowRetakes(assignment.allowRetakes || false); + setMaxAttempts(String(assignment.maxAttempts || 1)); + setShowResultsAfterGrading(assignment.showResultsAfterGrading !== false); + setStatus(assignment.status || "active"); + setUseCustomQuestions(assignment.useCustomQuestions || false); + setCustomQuestions(assignment.customQuestions?.map((q, idx) => ({ + ...q, + _id: q._id || `temp-${idx}`, + options: q.options?.map((opt, optIdx) => ({ + ...opt, + _id: opt._id || `temp-opt-${idx}-${optIdx}`, + })) || [], + })) || []); + } else if (isOpen && !isEditing) { + // Reset form for new assignment + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 7); + setStartDate(now.toISOString().slice(0, 16)); + setEndDate(tomorrow.toISOString().slice(0, 16)); + setTitle(""); + setDescription(""); + setInstructions(""); + setSelectedTemplateId(""); + setTimeLimit(""); + setAllowRetakes(false); + setMaxAttempts("1"); + setShowResultsAfterGrading(true); + setStatus("active"); + setUseCustomQuestions(false); + setCustomQuestions([]); + } + }, [isOpen, isEditing, assignment]); + + const handleTemplateChange = (e) => { + const templateId = e.target.value; + setSelectedTemplateId(templateId); + + const template = templates.find(t => t._id === templateId); + if (template && !isEditing) { + setTitle(template.title); + setDescription(template.description || ""); + setInstructions(template.instructions || ""); + setTimeLimit(template.timeLimit ? String(template.timeLimit) : ""); + + // Always initialize custom questions from template + // This allows the teacher to customize (edit/delete/add) when they enable the toggle + if (template.questions) { + setCustomQuestions(template.questions.map((q, idx) => ({ + _id: `temp-${idx}`, + questionText: q.questionText, + questionType: q.questionType, + options: q.options?.map((opt, optIdx) => ({ + _id: `temp-opt-${idx}-${optIdx}`, + optionText: opt.optionText, + isCorrect: opt.isCorrect, + })) || [], + points: q.points || 1, + order: q.order || idx, + }))); + } else { + setCustomQuestions([]); + } + + // Reset useCustomQuestions to false when changing template + // Teacher needs to explicitly enable it to use custom questions + setUseCustomQuestions(false); + } + }; + + // Question management functions + const addQuestion = () => { + setCustomQuestions([...customQuestions, { + _id: `temp-${Date.now()}`, + questionText: "", + questionType: "multiple_choice", + options: [ + { _id: `temp-opt-${Date.now()}-0`, optionText: "", isCorrect: false }, + { _id: `temp-opt-${Date.now()}-1`, optionText: "", isCorrect: false }, + ], + points: 1, + order: customQuestions.length, + }]); + }; + + const removeQuestion = (questionIndex) => { + const newQuestions = customQuestions.filter((_, idx) => idx !== questionIndex); + // Reorder remaining questions + newQuestions.forEach((q, idx) => { q.order = idx; }); + setCustomQuestions(newQuestions); + }; + + const updateQuestion = (questionIndex, field, value) => { + const newQuestions = [...customQuestions]; + newQuestions[questionIndex][field] = value; + setCustomQuestions(newQuestions); + }; + + const addOption = (questionIndex) => { + const newQuestions = [...customQuestions]; + newQuestions[questionIndex].options.push({ + _id: `temp-opt-${Date.now()}`, + optionText: "", + isCorrect: false, + }); + setCustomQuestions(newQuestions); + }; + + const removeOption = (questionIndex, optionIndex) => { + const newQuestions = [...customQuestions]; + newQuestions[questionIndex].options = newQuestions[questionIndex].options.filter((_, idx) => idx !== optionIndex); + setCustomQuestions(newQuestions); + }; + + const updateOption = (questionIndex, optionIndex, field, value) => { + const newQuestions = [...customQuestions]; + newQuestions[questionIndex].options[optionIndex][field] = value; + setCustomQuestions(newQuestions); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsPending(true); + + const assignmentData = { + examTemplateId: selectedTemplateId, + title, + description, + instructions, + startDate: new Date(startDate).toISOString(), + endDate: new Date(endDate).toISOString(), + timeLimit: timeLimit ? parseInt(timeLimit) : null, + allowRetakes, + maxAttempts: parseInt(maxAttempts), + showResultsAfterGrading, + status, + useCustomQuestions, + customQuestions: useCustomQuestions ? customQuestions.map(q => ({ + questionText: q.questionText, + questionType: q.questionType, + options: q.options.map(opt => ({ + optionText: opt.optionText, + isCorrect: opt.isCorrect, + })), + points: q.points, + order: q.order, + })) : [], + }; + + try { + let result; + if (isEditing && assignment) { + result = await updateExamAssignment(assignment._id, assignmentData); + } else { + result = await createExamAssignment(selectedClassId, assignmentData); + } + + if (result.success) { + handleClose(); + router.refresh(); + } else { + alert(result.error || `Erro ao ${isEditing ? 'atualizar' : 'atribuir'} prova.`); + } + } catch (error) { + console.error(`Erro ao ${isEditing ? 'atualizar' : 'atribuir'} prova:`, error); + alert(error.message || `Erro ao ${isEditing ? 'atualizar' : 'atribuir'} prova.`); + } finally { + setIsPending(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+

+ {isEditing ? "Editar Prova" : "Atribuir Prova à Turma"} +

+ +
+ +
+ {/* Select Class - hidden when editing or when classId is pre-selected */} + {!initialClassId && !isEditing && ( +
+ + +
+ )} + {(initialClassId || isEditing) && ( + + )} + + {/* Select Template */} +
+ + +
+ + {selectedTemplateId && ( + <> + {/* Title and Description */} +
+
+ + setTitle(e.target.value)} + required + className="w-full border border-neutral-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
+ +
+ +