Initial commit
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
# Badge/Label Patterns - Course Platform
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the standardized badge/label patterns used throughout the course platform application. All badges should follow these patterns to ensure consistency across the UI.
|
||||
|
||||
## Base Badge Style
|
||||
|
||||
All badges use a modern UI pattern with:
|
||||
- **Size**: `px-2.5 py-0.5` (horizontal padding 0.625rem, vertical padding 0.125rem)
|
||||
- **Shape**: `rounded-full` (fully rounded corners)
|
||||
- **Typography**: `text-[11px] tracking-wide uppercase` (11px font, wide letter spacing, uppercase text)
|
||||
- **Layout**: `inline-flex items-center` (flexbox with centered items)
|
||||
- **Transition**: `transition-colors` (smooth color transitions)
|
||||
|
||||
## Color Patterns
|
||||
|
||||
### Emerald (Success/Active Status)
|
||||
|
||||
**Light Mode:**
|
||||
- Background: `bg-emerald-50`
|
||||
- Text: `text-emerald-700`
|
||||
- Border: `border border-emerald-200/60`
|
||||
|
||||
**Dark Mode:**
|
||||
- Background: `dark:bg-emerald-500/10`
|
||||
- Text: `dark:text-emerald-400`
|
||||
- Border: `dark:border-emerald-500/20`
|
||||
|
||||
**Usage:** Active status, completed items, success states, positive indicators
|
||||
|
||||
---
|
||||
|
||||
### Blue (Info/Primary Status)
|
||||
|
||||
**Light Mode:**
|
||||
- Background: `bg-blue-50`
|
||||
- Text: `text-blue-700`
|
||||
- Border: `border border-blue-200/60`
|
||||
|
||||
**Dark Mode:**
|
||||
- Background: `dark:bg-blue-500/10`
|
||||
- Text: `dark:text-blue-400`
|
||||
- Border: `dark:border-blue-500/20`
|
||||
|
||||
**Usage:** Informational status, primary actions, neutral indicators
|
||||
|
||||
---
|
||||
|
||||
### Red (Error/Danger Status)
|
||||
|
||||
**Light Mode:**
|
||||
- Background: `bg-red-50`
|
||||
- Text: `text-red-700`
|
||||
- Border: `border border-red-200/60`
|
||||
|
||||
**Dark Mode:**
|
||||
- Background: `dark:bg-red-500/10`
|
||||
- Text: `dark:text-red-400`
|
||||
- Border: `dark:border-red-500/20`
|
||||
|
||||
**Usage:** Error states, danger warnings, negative indicators, failed status
|
||||
|
||||
---
|
||||
|
||||
### Amber (Warning Status)
|
||||
|
||||
**Light Mode:**
|
||||
- Background: `bg-amber-50`
|
||||
- Text: `text-amber-700`
|
||||
- Border: `border border-amber-200/60`
|
||||
|
||||
**Dark Mode:**
|
||||
- Background: `dark:bg-amber-500/10`
|
||||
- Text: `dark:text-amber-400`
|
||||
- Border: `dark:border-amber-500/20`
|
||||
|
||||
**Usage:** Warning states, caution indicators, pending actions
|
||||
|
||||
---
|
||||
|
||||
### Yellow (Caution Status)
|
||||
|
||||
**Light Mode:**
|
||||
- Background: `bg-yellow-50`
|
||||
- Text: `text-yellow-700`
|
||||
- Border: `border border-yellow-200/60`
|
||||
|
||||
**Dark Mode:**
|
||||
- Background: `dark:bg-yellow-500/10`
|
||||
- Text: `dark:text-yellow-400`
|
||||
- Border: `dark:border-yellow-500/20`
|
||||
|
||||
**Usage:** Caution indicators, attention needed, minor warnings
|
||||
|
||||
---
|
||||
|
||||
### Purple (Special/Featured Status)
|
||||
|
||||
**Light Mode:**
|
||||
- Background: `bg-purple-50`
|
||||
- Text: `text-purple-700`
|
||||
- Border: `border border-purple-200/60`
|
||||
|
||||
**Dark Mode:**
|
||||
- Background: `dark:bg-purple-500/10`
|
||||
- Text: `dark:text-purple-400`
|
||||
- Border: `dark:border-purple-500/20`
|
||||
|
||||
**Usage:** Featured items, special categories, premium content
|
||||
|
||||
---
|
||||
|
||||
### Indigo (Secondary Status)
|
||||
|
||||
**Light Mode:**
|
||||
- Background: `bg-indigo-50`
|
||||
- Text: `text-indigo-700`
|
||||
- Border: `border border-indigo-200/60`
|
||||
|
||||
**Dark Mode:**
|
||||
- Background: `dark:bg-indigo-500/10`
|
||||
- Text: `dark:text-indigo-400`
|
||||
- Border: `dark:border-indigo-500/20`
|
||||
|
||||
**Usage:** Secondary status, alternative categories, supplementary info
|
||||
|
||||
---
|
||||
|
||||
### Gray (Neutral/Default Status)
|
||||
|
||||
**Light Mode:**
|
||||
- Background: `bg-gray-50`
|
||||
- Text: `text-gray-700`
|
||||
- Border: `border border-gray-200/60`
|
||||
|
||||
**Dark Mode:**
|
||||
- Background: `dark:bg-gray-500/10`
|
||||
- Text: `dark:text-gray-400`
|
||||
- Border: `dark:border-gray-500/20`
|
||||
|
||||
**Usage:** Default status, neutral indicators, inactive states
|
||||
|
||||
---
|
||||
|
||||
## Usage with @apply Classes
|
||||
|
||||
To reduce HTML class clutter, use the predefined CSS classes in `globals.css`:
|
||||
|
||||
```jsx
|
||||
// Emerald badge
|
||||
<span className="badge-emerald">Active</span>
|
||||
|
||||
// Blue badge
|
||||
<span className="badge-blue">Info</span>
|
||||
|
||||
// Red badge
|
||||
<span className="badge-red">Error</span>
|
||||
|
||||
// Amber badge
|
||||
<span className="badge-amber">Warning</span>
|
||||
|
||||
// Yellow badge
|
||||
<span className="badge-yellow">Caution</span>
|
||||
|
||||
// Purple badge
|
||||
<span className="badge-purple">Featured</span>
|
||||
|
||||
// Indigo badge
|
||||
<span className="badge-indigo">Secondary</span>
|
||||
|
||||
// Gray badge
|
||||
<span className="badge-gray">Inactive</span>
|
||||
```
|
||||
|
||||
## Full Tailwind Classes (Reference)
|
||||
|
||||
If you need to use the full Tailwind classes directly:
|
||||
|
||||
```jsx
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] tracking-wide uppercase bg-emerald-50 text-emerald-700 border border-emerald-200/60 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20 transition-colors">
|
||||
Status
|
||||
</span>
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Status Badges
|
||||
|
||||
| Status | Color | Class |
|
||||
|--------|-------|-------|
|
||||
| Active | Emerald | `badge-emerald` |
|
||||
| Inactive | Gray | `badge-gray` |
|
||||
| Pending | Amber | `badge-amber` |
|
||||
| Completed | Emerald | `badge-emerald` |
|
||||
| Cancelled | Red | `badge-red` |
|
||||
| Draft | Gray | `badge-gray` |
|
||||
| Published | Emerald | `badge-emerald` |
|
||||
|
||||
### Payment Status
|
||||
|
||||
| Status | Color | Class |
|
||||
|--------|-------|-------|
|
||||
| Paid | Emerald | `badge-emerald` |
|
||||
| Unpaid | Red | `badge-red` |
|
||||
| Partial | Amber | `badge-amber` |
|
||||
| Overdue | Red | `badge-red` |
|
||||
|
||||
### Attendance Status
|
||||
|
||||
| Status | Color | Class |
|
||||
|--------|-------|-------|
|
||||
| Present | Emerald | `badge-emerald` |
|
||||
| Absent | Red | `badge-red` |
|
||||
| Late | Amber | `badge-amber` |
|
||||
| Excused | Blue | `badge-blue` |
|
||||
|
||||
### Exam Status
|
||||
|
||||
| Status | Color | Class |
|
||||
|--------|-------|-------|
|
||||
| Available | Emerald | `badge-emerald` |
|
||||
| Completed | Blue | `badge-blue` |
|
||||
| Graded | Purple | `badge-purple` |
|
||||
| Pending | Amber | `badge-amber` |
|
||||
|
||||
### Content Categories
|
||||
|
||||
| Category | Color | Class |
|
||||
|----------|-------|-------|
|
||||
| Slides | Blue | `badge-blue` |
|
||||
| Exercises | Emerald | `badge-emerald` |
|
||||
| Reading | Purple | `badge-purple` |
|
||||
| Videos | Red | `badge-red` |
|
||||
| Audio | Amber | `badge-amber` |
|
||||
| Assignments | Indigo | `badge-indigo` |
|
||||
| Other | Gray | `badge-gray` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **Always use the @apply classes** (`badge-emerald`, `badge-blue`, etc.) instead of inline Tailwind classes for consistency
|
||||
2. **Badge text should be short** (1-2 words) and in uppercase
|
||||
3. **Use semantic colors** based on the meaning (emerald for success, red for danger, etc.)
|
||||
4. **Ensure accessibility** by maintaining good contrast ratios in both light and dark modes
|
||||
5. **Test in both themes** to verify proper appearance
|
||||
|
||||
## Migration Guide
|
||||
|
||||
When updating existing badges:
|
||||
|
||||
1. Replace long Tailwind class strings with the appropriate `badge-*` class
|
||||
2. Ensure text content is uppercase
|
||||
3. Verify the color matches the semantic meaning
|
||||
4. Test in both light and dark themes
|
||||
|
||||
### Before:
|
||||
```jsx
|
||||
<span className="text-xs px-2 py-1 rounded-full border bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:border-emerald-800">
|
||||
Active
|
||||
</span>
|
||||
```
|
||||
|
||||
### After:
|
||||
```jsx
|
||||
<span className="badge-emerald">Active</span>
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
- `src/app/globals.css` - Contains all badge CSS classes with @apply
|
||||
- `STYLE_GUIDE.md` - Overall style guide for the application
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
# Deploy em Produção
|
||||
|
||||
Guia completo para deploy da aplicação Course Plat em VPS/servidor.
|
||||
|
||||
## Índice
|
||||
|
||||
1. [Requisitos](#requisitos)
|
||||
2. [Variáveis de Ambiente](#variáveis-de-ambiente)
|
||||
3. [Deploy com Docker](#deploy-com-docker)
|
||||
4. [Scripts de Backup](#scripts-de-backup)
|
||||
5. [Monitoramento e Manutenção](#monitoramento-e-manutenção)
|
||||
6. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Requisitos
|
||||
|
||||
### Servidor
|
||||
- Ubuntu 22.04+ ou similar
|
||||
- 2GB RAM (mínimo), 4GB+ recomendado
|
||||
- 20GB+ de disco
|
||||
- Docker e Docker Compose instalados
|
||||
|
||||
### Instalação do Docker
|
||||
|
||||
```bash
|
||||
# Atualizar sistema
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Instalar Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
|
||||
# Instalar Docker Compose
|
||||
sudo apt install docker-compose-plugin -y
|
||||
|
||||
# Adicionar usuário ao grupo docker
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
|
||||
# Verificar instalação
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variáveis de Ambiente
|
||||
|
||||
### Criar arquivo `.env.production`
|
||||
|
||||
```bash
|
||||
cd /path/to/course-plat
|
||||
cp .env.example .env.production
|
||||
nano .env.production
|
||||
```
|
||||
|
||||
### Variáveis Obrigatórias
|
||||
|
||||
```bash
|
||||
# ===== NEXT.JS =====
|
||||
NODE_ENV=production
|
||||
NEXTAUTH_URL=https://seu-dominio.com.br
|
||||
NEXTAUTH_SECRET=gerar-um-segredo-aleatorio-min-32-caracteres
|
||||
|
||||
# ===== MONGODB =====
|
||||
MONGO_USERNAME=admin
|
||||
MONGO_PASSWORD=senha-forte-aqui
|
||||
MONGO_DB=course-plat
|
||||
|
||||
# ===== STORAGE (MinIO) =====
|
||||
STORAGE_TYPE=s3 # ou "local" para armazenamento em disco
|
||||
S3_ENDPOINT=http://minio:9000 # interno do docker
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY=minioadmin # mesmo que MINIO_ROOT_USER
|
||||
S3_SECRET_KEY=minioadmin # mesmo que MINIO_ROOT_PASSWORD
|
||||
S3_BUCKET=course-plat
|
||||
|
||||
# ===== MINIO =====
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=senha-forte-aqui
|
||||
MINIO_BUCKET=course-plat
|
||||
|
||||
# ===== APP =====
|
||||
APP_PORT=3000
|
||||
```
|
||||
|
||||
### Gerar NEXTAUTH_SECRET
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deploy com Docker
|
||||
|
||||
### 1. Preparar Servidor
|
||||
|
||||
```bash
|
||||
# Clonar repositório
|
||||
git clone seu-repositorio.git /var/www/course-plat
|
||||
cd /var/www/course-plat
|
||||
|
||||
# Criar arquivo de ambiente
|
||||
cp .env.example .env.production
|
||||
nano .env.production # preencher variáveis
|
||||
```
|
||||
|
||||
### 2. Buildar Imagem
|
||||
|
||||
```bash
|
||||
# Build da imagem da aplicação
|
||||
docker build -f docker/production/Dockerfile -t course-plat:latest .
|
||||
```
|
||||
|
||||
### 3. Iniciar Serviços
|
||||
|
||||
```bash
|
||||
# Iniciar todos os serviços
|
||||
docker compose -f docker/production/docker-compose.yml up -d
|
||||
|
||||
# Ver status
|
||||
docker compose -f docker/production/docker-compose.yml ps
|
||||
|
||||
# Ver logs
|
||||
docker compose -f docker/production/docker-compose.yml logs -f app
|
||||
```
|
||||
|
||||
### 4. Configurar Firewall
|
||||
|
||||
```bash
|
||||
# UFW - Ubuntu/Debian
|
||||
sudo ufw allow 22/tcp # SSH
|
||||
sudo ufw allow 80/tcp # HTTP
|
||||
sudo ufw allow 443/tcp # HTTPS
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
### 5. Configurar Reverse Proxy (Opcional)
|
||||
|
||||
Se usar Nginx no host (não container):
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/course-plat
|
||||
server {
|
||||
listen 80;
|
||||
server_name seu-dominio.com.br;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/course-plat /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 6. SSL com Certbot (Let's Encrypt)
|
||||
|
||||
```bash
|
||||
# Instalar Certbot
|
||||
sudo apt install certbot python3-certbot-nginx -y
|
||||
|
||||
# Obter certificado
|
||||
sudo certbot --nginx -d seu-dominio.com.br
|
||||
|
||||
# Renovação automática já é configurada
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scripts de Backup
|
||||
|
||||
### Backup MongoDB
|
||||
|
||||
Criar `docker/production/backup-mongodb.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Backup MongoDB em produção
|
||||
|
||||
BACKUP_DIR="/var/backups/course-plat/mongodb"
|
||||
RETENTION_DAYS=7
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
CONTAINER_NAME="course-plat-mongodb"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Backup
|
||||
docker exec "$CONTAINER_NAME" mongodump \
|
||||
--uri="mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@localhost:27017/${MONGO_DB}?authSource=admin" \
|
||||
--archive="$BACKUP_DIR/mongodb-$TIMESTAMP.gz" \
|
||||
--gzip
|
||||
|
||||
echo "Backup MongoDB concluído: mongodb-$TIMESTAMP.gz"
|
||||
|
||||
# Remover backups antigos
|
||||
find "$BACKUP_DIR" -name "mongodb-*.gz" -mtime +$RETENTION_DAYS -delete
|
||||
```
|
||||
|
||||
### Backup Completo
|
||||
|
||||
Criar `docker/production/backup-all.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Backup completo do sistema
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
BACKUP_BASE="/var/backups/course-plat"
|
||||
|
||||
# Backup MongoDB
|
||||
./docker/production/backup-mongodb.sh
|
||||
|
||||
# Backup MinIO
|
||||
./docker/minio/backup-minio.sh
|
||||
|
||||
# Backup volumes Docker
|
||||
docker run --rm \
|
||||
-v course-plat_mongodb_data:/data/mongo \
|
||||
-v course-plat_minio_data:/data/minio \
|
||||
-v course-plat_app_storage:/data/app \
|
||||
-v "$BACKUP_BASE":/backup \
|
||||
alpine tar czf "/backup/volumes-$TIMESTAMP.tar.gz" -C /data .
|
||||
|
||||
echo "Backup completo concluído!"
|
||||
```
|
||||
|
||||
### Configurar Cron
|
||||
|
||||
```bash
|
||||
# Tornar scripts executáveis
|
||||
chmod +x docker/production/backup-mongodb.sh
|
||||
chmod +x docker/production/backup-all.sh
|
||||
|
||||
# Editar crontab
|
||||
crontab -e
|
||||
|
||||
# Adicionar:
|
||||
# 0 2 * * * cd /var/www/course-plat && ./docker/production/backup-all.sh >> /var/log/course-plat-backup.log 2>&1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoramento e Manutenção
|
||||
|
||||
### Ver Status dos Serviços
|
||||
|
||||
```bash
|
||||
# Status completo
|
||||
docker compose -f docker/production/docker-compose.yml ps
|
||||
|
||||
# Logs em tempo real
|
||||
docker compose -f docker/production/docker-compose.yml logs -f
|
||||
|
||||
# Logs de serviço específico
|
||||
docker compose -f docker/production/docker-compose.yml logs -f app
|
||||
docker compose -f docker/production/docker-compose.yml logs -f mongodb
|
||||
docker compose -f docker/production/docker-compose.yml logs -f minio
|
||||
```
|
||||
|
||||
### Atualizar Aplicação
|
||||
|
||||
```bash
|
||||
cd /var/www/course-plat
|
||||
|
||||
# Pull do código
|
||||
git pull origin main
|
||||
|
||||
# Rebuildar
|
||||
docker build -f docker/production/Dockerfile -t course-plat:latest .
|
||||
|
||||
# Recrear container
|
||||
docker compose -f docker/production/docker-compose.yml up -d --force-recreate app
|
||||
```
|
||||
|
||||
### Acessar MongoDB
|
||||
|
||||
```bash
|
||||
# Terminal do MongoDB
|
||||
docker exec -it course-plat-mongodb mongosh -u admin -p
|
||||
|
||||
# Comando restore
|
||||
docker exec -i course-plat-mongodb mongorestore \
|
||||
--uri="mongodb://admin:password@localhost:27017/course-plat?authSource=admin" \
|
||||
--gzip --archive < backup.gz
|
||||
```
|
||||
|
||||
### Acessar MinIO
|
||||
|
||||
```bash
|
||||
# Terminal do MinIO
|
||||
docker exec -it course-plat-minio sh
|
||||
|
||||
# Usar mc (MinIO Client)
|
||||
docker exec -it course-plat-minio mc alias set local http://localhost:9000 minioadmin minioadmin
|
||||
docker exec -it course-plat-minio mc ls local/course-plat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### App não inicia
|
||||
|
||||
```bash
|
||||
# Ver logs
|
||||
docker compose -f docker/production/docker-compose.yml logs app
|
||||
|
||||
# Entrar no container
|
||||
docker exec -it course-plat-app sh
|
||||
|
||||
# Ver variáveis de ambiente
|
||||
docker exec course-plat-app env
|
||||
```
|
||||
|
||||
### MongoDB não conecta
|
||||
|
||||
```bash
|
||||
# Ver se container está rodando
|
||||
docker ps | grep mongo
|
||||
|
||||
# Testar conexão
|
||||
docker exec course-plat-mongodb mongosh --eval "db.adminCommand('ping')"
|
||||
|
||||
# Ver logs
|
||||
docker logs course-plat-mongodb
|
||||
```
|
||||
|
||||
### MinIO com Access Denied
|
||||
|
||||
```bash
|
||||
# Verificar política do bucket
|
||||
docker exec course-plat-minio mc anonymous get local/course-plat
|
||||
|
||||
# Setar como privado (se necessário)
|
||||
docker exec course-plat-minio mc anonymous set none local/course-plat
|
||||
|
||||
# Verificar credenciais
|
||||
docker exec course-plat-minio mc admin user list local
|
||||
```
|
||||
|
||||
### Espaço em disco
|
||||
|
||||
```bash
|
||||
# Ver uso de disco
|
||||
df -h
|
||||
|
||||
# Limpar volumes não usados
|
||||
docker volume prune
|
||||
|
||||
# Limpar imagens antigas
|
||||
docker image prune -a
|
||||
|
||||
# Ver tamanho dos volumes
|
||||
docker system df -v
|
||||
```
|
||||
|
||||
### Performance
|
||||
|
||||
```bash
|
||||
# Métricas do container
|
||||
docker stats course-plat-app course-plat-mongodb course-plat-minio
|
||||
|
||||
# Ver processo Node.js
|
||||
docker exec course-plat-app ps aux
|
||||
|
||||
# Memória do MongoDB
|
||||
docker exec course-plat-mongodb mongosh --eval "db.serverStatus().mem"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comandos Úteis
|
||||
|
||||
```bash
|
||||
# Reiniciar todos os serviços
|
||||
docker compose -f docker/production/docker-compose.yml restart
|
||||
|
||||
# Parar tudo
|
||||
docker compose -f docker/production/docker-compose.yml down
|
||||
|
||||
# Parar e remover volumes
|
||||
docker compose -f docker/production/docker-compose.yml down -v
|
||||
|
||||
# Ver recursos usados
|
||||
docker system df
|
||||
|
||||
# Limpar build cache
|
||||
docker builder prune
|
||||
|
||||
# Backup rápido do MongoDB
|
||||
docker exec course-plat-mongodb mongodump --gzip --archive > backup-$(date +%Y%m%d).gz
|
||||
|
||||
# Restore do MongoDB
|
||||
docker exec -i course-plat-mongodb mongorestore --gzip --archive < backup-YYYYMMDD.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Segurança
|
||||
|
||||
1. **Senhas fortes**: Use senhas únicas e fortes para todos os serviços
|
||||
2. **Firewall**: Mantenha apenas portas necessárias abertas
|
||||
3. **SSL**: Sempre use HTTPS em produção
|
||||
4. **Backups**: Teste restores regularmente
|
||||
5. **Atualizações**: Mantenha Docker e sistema atualizados
|
||||
6. **Monitoring**: Configure alertas para disco, CPU e memória
|
||||
|
||||
---
|
||||
|
||||
## URLs de Acesso
|
||||
|
||||
| Serviço | URL |
|
||||
|---------|-----|
|
||||
| Aplicação | https://seu-dominio.com.br |
|
||||
| MinIO Console (interna) | http://localhost:9001 |
|
||||
| Mongo Express (opcional) | http://localhost:8081 |
|
||||
| API Health Check | https://seu-dominio.com.br/api/health |
|
||||
+484
@@ -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.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Correção: login falha ("Credenciais inválidas") para alguns usuários com senha correta
|
||||
|
||||
## Contexto
|
||||
|
||||
Usuários relatam, de forma intermitente entre pessoas (não para todos), que digitam a senha
|
||||
correta e recebem "Credenciais inválidas". A hipótese inicial ("será que está logando mas
|
||||
mostra erro?") foi **descartada**: em `src/app/lib/userLoginAction.js` o `signIn` em caso de
|
||||
sucesso lança `NEXT_REDIRECT`, que é corretamente relançado (linhas 32-39), redirecionando para
|
||||
`/dispatcher`. Não existe caminho de sucesso que ainda exiba o erro. Trata-se de uma **falha real
|
||||
de autenticação** (nenhuma sessão é criada).
|
||||
|
||||
### Causa raiz
|
||||
O `username` é **gravado normalizado** mas **consultado cru**:
|
||||
|
||||
- Schema `src/app/models/User.js:9-10` define `trim: true` + `lowercase: true`. O Mongoose aplica
|
||||
isso em todo `save`/`create`, então no banco todo username está minúsculo e sem espaços.
|
||||
- Login `src/app/lib/utils/auth.js:31-32` busca com o valor cru:
|
||||
`User.findOne({ username: credentials.username })`. O MongoDB faz match **case-sensitive**.
|
||||
|
||||
Resultado: quem cadastrou `Joao` (salvo como `joao`) e faz login digitando `Joao` gera busca por
|
||||
`"Joao"` → nenhum match → `null` → "Credenciais inválidas", **sem nem comparar a senha**.
|
||||
|
||||
### Por que "alguns sim, outros não"
|
||||
Quem digita o usuário todo em minúsculo entra; quem digita com maiúscula/espaço falha **sempre**
|
||||
(consistente por usuário, não aleatório). Gatilho principal: teclado de **celular** auto-capitaliza
|
||||
a primeira letra, e o input de login (`src/app/auth/login/LoginForm.jsx:31-37`) não tem
|
||||
`autoCapitalize`/`autoCorrect`/`autoComplete`. Autofill com espaço final tem o mesmo efeito. Daí o
|
||||
padrão "mobile reclama, desktop não".
|
||||
|
||||
**Observação sobre dados:** não é necessária migração de banco. Como o Mongoose já força lowercase
|
||||
em toda gravação, não há usernames mixed-case armazenados — apenas a consulta precisa ser corrigida.
|
||||
|
||||
## Abordagem (escopo robusto)
|
||||
|
||||
### 1. Normalizar a consulta no provider de auth (correção central)
|
||||
`src/app/lib/utils/auth.js`
|
||||
- Derivar um username normalizado uma vez e usá-lo na consulta:
|
||||
```js
|
||||
const username = (credentials.username || "").trim().toLowerCase();
|
||||
const password = credentials.password;
|
||||
...
|
||||
const user = await User.findOne({ username }).lean();
|
||||
```
|
||||
- Remover o uso duplicado de `credentials.username` na linha 32 (usar a variável já tratada).
|
||||
|
||||
### 2. Normalizar também no server action de login
|
||||
`src/app/lib/userLoginAction.js`
|
||||
- Após `formData.get("username")`, normalizar antes de validar/enviar e antes de preencher
|
||||
`rawData` (para que o `defaultValue` re-exibido no form já volte normalizado):
|
||||
```js
|
||||
const username = (formData.get("username") || "").trim().toLowerCase();
|
||||
```
|
||||
- Mantém o tratamento de `NEXT_REDIRECT`/`CredentialsSignin` como está.
|
||||
|
||||
### 3. Endurecer o input de login contra auto-capitalização (mobile)
|
||||
`src/app/auth/login/LoginForm.jsx` — no `UIInput` de `name="username"` adicionar:
|
||||
`autoCapitalize="none"`, `autoCorrect="off"`, `spellCheck={false}`, `autoComplete="username"`.
|
||||
- Confirmar que `src/components/ui/input.jsx` repassa `...props` (já repassa), então os atributos
|
||||
fluem para o `<input>` nativo.
|
||||
|
||||
### 4. Alinhar a checagem de duplicado no cadastro
|
||||
`src/app/lib/users/createUserAction.js:131` — a verificação `findOne({ username })` usa o valor cru
|
||||
e é case-sensitive, enquanto o `User.create` grava lowercased. Normalizar o `username` logo na
|
||||
leitura (linha 24) para `(formData.get("username") || "").trim().toLowerCase()`, de modo que a
|
||||
checagem de duplicado, a mensagem de erro re-exibida e o valor gravado fiquem consistentes. A
|
||||
validação de "sem espaços" (linha 55) continua válida.
|
||||
- Aplicar a mesma normalização de leitura em `createWardUserAction.js` (criação de ward) para
|
||||
manter consistência, caso ele leia username de formData da mesma forma.
|
||||
|
||||
## Arquivos críticos
|
||||
- `src/app/lib/utils/auth.js` — fix central da consulta (CRÍTICO)
|
||||
- `src/app/lib/userLoginAction.js` — normalização do input do login
|
||||
- `src/app/auth/login/LoginForm.jsx` — atributos anti auto-capitalize
|
||||
- `src/app/lib/users/createUserAction.js` — consistência na checagem/gravação
|
||||
- `src/app/lib/users/createWardUserAction.js` — mesma consistência
|
||||
- (referência, sem alteração) `src/app/models/User.js`, `src/components/ui/input.jsx`
|
||||
|
||||
## Verificação
|
||||
1. **Repro do bug (antes do fix):** com um usuário existente (ex.: `joao`), tentar login digitando
|
||||
`Joao` ou `joao ` (com espaço) → deve dar "Credenciais inválidas".
|
||||
2. **Depois do fix:**
|
||||
- Login com `Joao`, `JOAO`, ` joao `, `joao` → todos devem autenticar e redirecionar para
|
||||
`/dispatcher`.
|
||||
- Senha errada → continua "Credenciais inválidas" (regressão de segurança garantida).
|
||||
- Usuário inexistente → "Credenciais inválidas".
|
||||
3. **Mobile:** abrir `/auth/login` em viewport mobile / dispositivo e confirmar que o campo Usuário
|
||||
não auto-capitaliza a primeira letra.
|
||||
4. **Cadastro:** registrar `Maria`; conferir no banco que foi salvo como `maria`; tentar cadastrar
|
||||
`maria` de novo → "Nome de usuario ja cadastrado".
|
||||
5. Rodar a app localmente (`npm run dev`) e exercitar os fluxos acima; rodar a suíte de testes se
|
||||
existir.
|
||||
@@ -0,0 +1,213 @@
|
||||
# Course Plat
|
||||
|
||||
Plataforma de gerenciamento de cursos, turmas, alunos e professores.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend**: Next.js 15, React, Tailwind CSS
|
||||
- **Backend**: Next.js API Routes, Server Actions
|
||||
- **Database**: MongoDB
|
||||
- **Storage**: MinIO (S3-compatible) ou Local
|
||||
- **Auth**: NextAuth.js
|
||||
|
||||
## Quick Start (Desenvolvimento)
|
||||
|
||||
### 1. Clone e instale dependências
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd course-plat
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure variáveis de ambiente
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Edite `.env.local` com suas configurações:
|
||||
|
||||
```bash
|
||||
# Mínimo para desenvolvimento:
|
||||
NODE_ENV=development
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=qualquer-segredo-aqui-min-32-caracteres
|
||||
MONGODB_URI=mongodb://localhost:27017/course-plat
|
||||
```
|
||||
|
||||
### 3. Inicie os serviços com Docker
|
||||
|
||||
```bash
|
||||
# MongoDB + MinIO
|
||||
docker compose up -d
|
||||
|
||||
# Verifique que os containers estão rodando
|
||||
docker ps
|
||||
```
|
||||
|
||||
### 4. Inicie a aplicação
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Acesse: http://localhost:3000
|
||||
|
||||
## Serviços Docker
|
||||
|
||||
| Serviço | Porta | Descrição |
|
||||
|---------|-------|-----------|
|
||||
| App | 3000 | Aplicação Next.js |
|
||||
| MongoDB | 27017 | Banco de dados |
|
||||
| Mongo Express | 8081 | UI do MongoDB (opcional) |
|
||||
| MinIO API | 9000 | Storage S3-compatible |
|
||||
| MinIO Console | 9001 | UI do MinIO |
|
||||
|
||||
## Estrutura de Projetos
|
||||
|
||||
```
|
||||
course-plat/
|
||||
├── src/app/ # App Router Next.js
|
||||
│ ├── (auth)/ # Rotas de autenticação
|
||||
│ ├── (protected)/ # Rotas protegidas
|
||||
│ ├── api/ # API Routes
|
||||
│ └── lib/ # Utilitários e helpers
|
||||
├── docker/ # Configurações Docker
|
||||
│ ├── minio/ # MinIO setup
|
||||
│ └── production/ # Configurações de produção
|
||||
├── storage/ # Armazenamento local (se usado)
|
||||
└── .env.local # Variáveis de ambiente (não commitar)
|
||||
```
|
||||
|
||||
## Deploy em Produção
|
||||
|
||||
Veja [DEPLOYMENT.md](./DEPLOYMENT.md) para instruções completas.
|
||||
|
||||
Resumo rápido:
|
||||
|
||||
```bash
|
||||
# 1. Configurar ambiente
|
||||
cp .env.example .env.production
|
||||
nano .env.production # preencher variáveis
|
||||
|
||||
# 2. Buildar imagem
|
||||
docker build -f docker/production/Dockerfile -t course-plat:latest .
|
||||
|
||||
# 3. Iniciar serviços
|
||||
docker compose -f docker/production/docker-compose.yml up -d
|
||||
|
||||
# 4. Configurar backup (crontab)
|
||||
0 2 * * * cd /var/www/course-plat && ./docker/production/backup-all.sh
|
||||
```
|
||||
|
||||
## Variáveis de Ambiente
|
||||
|
||||
Veja `.env.example` para todas as variáveis disponíveis.
|
||||
|
||||
Principais:
|
||||
|
||||
| Variável | Descrição | Default |
|
||||
|----------|-----------|---------|
|
||||
| `NODE_ENV` | Ambiente | `development` |
|
||||
| `NEXTAUTH_URL` | URL da app | `http://localhost:3000` |
|
||||
| `NEXTAUTH_SECRET` | Segredo auth | (obrigatório) |
|
||||
| `MONGODB_URI` | String conexão MongoDB | (obrigatório) |
|
||||
| `STORAGE_TYPE` | `s3` ou `local` | `s3` |
|
||||
| `S3_ENDPOINT` | Endpoint MinIO/S3 | `http://localhost:9000` |
|
||||
| `S3_BUCKET` | Nome do bucket | `course-plat` |
|
||||
|
||||
## Storage de Arquivos
|
||||
|
||||
### Sistema Híbrido
|
||||
|
||||
A aplicação suporta dois tipos de armazenamento:
|
||||
|
||||
**1. S3/MinIO (recomendado)**
|
||||
- Arquivos armazenados em bucket privado
|
||||
- Acesso via proxy da aplicação (`/api/files/...`)
|
||||
- Controle de permissões por turma/classe
|
||||
|
||||
**2. Local**
|
||||
- Arquivos em disco (`./storage/uploads`)
|
||||
- Útil para desenvolvimento ou servidores pequenos
|
||||
|
||||
### URLs de Arquivos
|
||||
|
||||
| Onde | Formato |
|
||||
|------|---------|
|
||||
| Banco de dados | `proxy://pasta/arquivo.pdf` |
|
||||
| Frontend | `/api/files/pasta/arquivo.pdf` |
|
||||
|
||||
Use `getFileUrl(url)` de `@/app/lib/utils/storage/fileUrl` para converter.
|
||||
|
||||
## Comandos Úteis
|
||||
|
||||
```bash
|
||||
# Desenvolvimento
|
||||
npm run dev # Inicia dev server
|
||||
npm run build # Build para produção
|
||||
npm run start # Inicia produção server
|
||||
|
||||
# Docker
|
||||
docker compose up -d # Inicia serviços
|
||||
docker compose down # Para serviços
|
||||
docker compose logs -f # Ver logs
|
||||
|
||||
# MongoDB (via Docker)
|
||||
docker exec -it course-plat-mongodb mongosh
|
||||
docker exec course-plat-mongodb mongodump --archive > backup.gz
|
||||
|
||||
# MinIO (via Docker)
|
||||
docker exec -it course-plat-minio mc alias set local http://localhost:9000 minioadmin minioadmin
|
||||
docker exec course-plat-minio mc ls local/course-plat
|
||||
```
|
||||
|
||||
## Backup
|
||||
|
||||
### MongoDB
|
||||
```bash
|
||||
# Script de backup
|
||||
./docker/production/backup-mongodb.sh
|
||||
|
||||
# Manual
|
||||
docker exec course-plat-mongodb mongodump --gzip --archive > backup.gz
|
||||
```
|
||||
|
||||
### MinIO
|
||||
```bash
|
||||
# Script de backup
|
||||
./docker/minio/backup-minio.sh
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### MongoDB não conecta
|
||||
```bash
|
||||
# Verificar se container está rodando
|
||||
docker ps | grep mongo
|
||||
|
||||
# Ver logs
|
||||
docker logs course-plat-mongodb
|
||||
```
|
||||
|
||||
### MinIO Access Denied
|
||||
```bash
|
||||
# Verificar política do bucket
|
||||
docker exec course-plat-minio mc anonymous get local/course-plat
|
||||
|
||||
# Bucket deve ser "none" (privado)
|
||||
docker exec course-plat-minio mc anonymous set none local/course-plat
|
||||
```
|
||||
|
||||
### Arquivos antigos não funcionam
|
||||
Verifique se `getFileUrl()` está sendo usado para converter URLs. Arquivos antigos com URLs diretas do MinIO precisam ser convertidas para o proxy.
|
||||
|
||||
## Documentação Adicional
|
||||
|
||||
- [DEPLOYMENT.md](./DEPLOYMENT.md) - Deploy em produção
|
||||
- [docker/minio/README.md](./docker/minio/README.md) - Setup do MinIO
|
||||
|
||||
## Licença
|
||||
|
||||
[Adicionar licença]
|
||||
+893
@@ -0,0 +1,893 @@
|
||||
# Padrões de Estilo - Course Platform
|
||||
|
||||
Este documento define os padrões de estilo unificados para todo o sistema, garantindo consistência visual e facilidade de manutenção.
|
||||
|
||||
## Índice
|
||||
|
||||
- [Princípios Fundamentais](#princípios-fundamentais)
|
||||
- [Cores e Tema](#cores-e-tema)
|
||||
- [Tipografia](#tipografia)
|
||||
- [Espaçamento](#espaçamento)
|
||||
- [Badges e Labels](#badges-e-labels)
|
||||
- [Componentes](#componentes)
|
||||
- [Transições e Animações](#transições-e-animações)
|
||||
- [Regras CSS Globais](#regras-css-globais)
|
||||
|
||||
---
|
||||
|
||||
## Princípios Fundamentais
|
||||
|
||||
1. **Usar CSS Variables** para cores, backgrounds e bordas (definidas em `:root` e `.dark`)
|
||||
2. **Padronizar em `neutral-*`** em vez de `gray-*` para consistência
|
||||
3. **Usar `blue-600`** como cor primária para botões de ação principal
|
||||
4. **Manter border-radius de 8px** (`rounded-lg` ou `rounded-xl`)
|
||||
5. **Usar sombras sutis**: `shadow-sm` padrão, `shadow-md` no hover
|
||||
6. **Evitar regras CSS globais com `!important`** que sobrescrevem classes Tailwind
|
||||
|
||||
---
|
||||
|
||||
## Cores e Tema
|
||||
|
||||
### CSS Variables (Definidas em `globals.css`)
|
||||
|
||||
```css
|
||||
/* Tema Claro (:root) */
|
||||
--background: #ffffff;
|
||||
--foreground: #09090b;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #09090b;
|
||||
--primary: #18181b;
|
||||
--primary-foreground: #fafafa;
|
||||
--secondary: #f4f4f5;
|
||||
--secondary-foreground: #18181b;
|
||||
--muted: #f4f4f5;
|
||||
--muted-foreground: #71717a;
|
||||
--accent: #f4f4f5;
|
||||
--accent-foreground: #18181b;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #fafafa;
|
||||
--border: #e4e4e7;
|
||||
--input: #e4e4e7;
|
||||
--ring: #18181b;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Tema Escuro (.dark) */
|
||||
--background: #09090b;
|
||||
--foreground: #fafafa;
|
||||
--card: #09090b;
|
||||
--card-foreground: #fafafa;
|
||||
--primary: #fafafa;
|
||||
--primary-foreground: #18181b;
|
||||
--secondary: #27272a;
|
||||
--secondary-foreground: #fafafa;
|
||||
--muted: #27272a;
|
||||
--muted-foreground: #a1a1aa;
|
||||
--accent: #27272a;
|
||||
--accent-foreground: #fafafa;
|
||||
--destructive: #7f1d1d;
|
||||
--destructive-foreground: #fafafa;
|
||||
--border: #27272a;
|
||||
--input: #27272a;
|
||||
--ring: #d4d4d8;
|
||||
```
|
||||
|
||||
### Cores Semânticas (Tailwind)
|
||||
|
||||
| Propósito | Light Mode | Dark Mode |
|
||||
|-----------|------------|-----------|
|
||||
| Primary (Ação principal) | `blue-600` | `blue-500` |
|
||||
| Primary Hover | `blue-700` | `blue-600` |
|
||||
| Success | `green-600` | `green-500` |
|
||||
| Success Hover | `green-700` | `green-600` |
|
||||
| Warning | `yellow-500` | `yellow-400` |
|
||||
| Warning Hover | `yellow-600` | `yellow-500` |
|
||||
| Error/Destructive | `red-600` | `red-500` |
|
||||
| Error Hover | `red-700` | `red-600` |
|
||||
| Info | `blue-600` | `blue-500` |
|
||||
|
||||
### Cores Neutras (Padrão)
|
||||
|
||||
**Use sempre `neutral-*` em vez de `gray-*` para consistência:**
|
||||
|
||||
| Escala | Light Mode | Dark Mode |
|
||||
|--------|------------|-----------|
|
||||
| 50 | `#fafafa` | `#18181b` |
|
||||
| 100 | `#f5f5f5` | `#27272a` |
|
||||
| 200 | `#e5e5e5` | `#3f3f46` |
|
||||
| 300 | `#d4d4d4` | `#52525b` |
|
||||
| 400 | `#a3a3a3` | `#71717a` |
|
||||
| 500 | `#737373` | `#a1a1aa` |
|
||||
| 600 | `#525252` | `#d4d4d8` |
|
||||
| 700 | `#404040` | `#e4e4e7` |
|
||||
| 800 | `#262626` | `#f4f4f5` |
|
||||
| 900 | `#171717` | `#fafafa` |
|
||||
| 950 | `#0a0a0a` | `#fafafa` |
|
||||
|
||||
---
|
||||
|
||||
## Tipografia
|
||||
|
||||
### Fonte
|
||||
|
||||
- **Fonte Principal**: Geist Sans (Google Fonts)
|
||||
- **Fonte Monospace**: Geist Mono (Google Fonts)
|
||||
|
||||
### Tamanhos e Pesos
|
||||
|
||||
| Elemento | Classe Tailwind | Uso |
|
||||
|----------|-----------------|-----|
|
||||
| Título Principal | `text-2xl md:text-3xl font-semibold` | Títulos de página |
|
||||
| Título de Seção | `text-lg font-semibold` | Títulos de seção |
|
||||
| Subtítulo | `text-base font-medium` | Subtítulos |
|
||||
| Texto Normal | `text-sm` | Texto de conteúdo |
|
||||
| Texto Secundário | `text-sm text-muted-foreground` | Texto de apoio |
|
||||
| Label | `text-sm font-medium` | Labels de formulário |
|
||||
| Caption/Small | `text-xs` | Textos pequenos |
|
||||
|
||||
### Exemplos
|
||||
|
||||
```jsx
|
||||
// Título de Página
|
||||
<h1 className="text-2xl md:text-3xl font-semibold text-foreground">
|
||||
Título da Página
|
||||
</h1>
|
||||
|
||||
// Título de Seção
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Título da Seção
|
||||
</h2>
|
||||
|
||||
// Subtítulo
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Descrição ou subtítulo
|
||||
</p>
|
||||
|
||||
// Label
|
||||
<label className="text-sm font-medium leading-none">
|
||||
Nome do Campo
|
||||
</label>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Espaçamento
|
||||
|
||||
### Padrões de Padding
|
||||
|
||||
| Elemento | Padding/Margin |
|
||||
|----------|----------------|
|
||||
| Card padding | `p-5` |
|
||||
| Card padding (compacto) | `p-4` |
|
||||
| Button padding | `px-4 py-2` |
|
||||
| Input padding | `px-3 py-2` |
|
||||
| Table cell padding | `px-6 py-4` |
|
||||
| Section gap | `gap-3` ou `gap-4` |
|
||||
| Vertical spacing entre elementos | `space-y-4` |
|
||||
| Horizontal spacing entre elementos | `space-x-3` |
|
||||
|
||||
### Exemplos
|
||||
|
||||
```jsx
|
||||
// Card
|
||||
<div className="border rounded-xl p-5 shadow-sm bg-card">
|
||||
{/* conteúdo */}
|
||||
</div>
|
||||
|
||||
// Formulário com espaçamento vertical
|
||||
<form className="space-y-4">
|
||||
{/* campos */}
|
||||
</form>
|
||||
|
||||
// Botões com espaçamento horizontal
|
||||
<div className="flex gap-3">
|
||||
<button>Cancelar</button>
|
||||
<button>Salvar</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Badges e Labels
|
||||
|
||||
Badges são pequenos labels coloridos usados para indicar status, categorias ou outros metadados. Devem ser usados consistentemente em toda a aplicação.
|
||||
|
||||
### Estrutura Base
|
||||
|
||||
```jsx
|
||||
<span className="text-xs px-2 py-1 rounded-full border [badge-classes]">
|
||||
Label Text
|
||||
</span>
|
||||
```
|
||||
|
||||
### Padrões de Cores
|
||||
|
||||
#### Success/Green (Disponível, Pago, Ativo, Presente)
|
||||
|
||||
**Tema Claro:** `bg-emerald-100 text-emerald-700 border-emerald-200`
|
||||
**Tema Escuro:** `dark:bg-emerald-900/20 dark:text-emerald-300 dark:border-emerald-800`
|
||||
|
||||
```jsx
|
||||
<span className="text-xs px-2 py-1 rounded-full border bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-300 dark:border-emerald-800">
|
||||
Disponível
|
||||
</span>
|
||||
```
|
||||
|
||||
**Casos de Uso:**
|
||||
- "Disponível" (Available)
|
||||
- "Pago" (Paid)
|
||||
- "Ativa" (Active)
|
||||
- "Presente" (Present)
|
||||
- Status "Verified"
|
||||
|
||||
---
|
||||
|
||||
#### Info/Blue (Em breve, Aguardando Verificação, Justificado)
|
||||
|
||||
**Tema Claro:** `bg-blue-100 text-blue-700 border-blue-200`
|
||||
**Tema Escuro:** `dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800`
|
||||
|
||||
```jsx
|
||||
<span className="text-xs px-2 py-1 rounded-full border bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800">
|
||||
Em breve
|
||||
</span>
|
||||
```
|
||||
|
||||
**Casos de Uso:**
|
||||
- "Em breve" (Upcoming)
|
||||
- "Aguardando Verificação" (Pending Verification)
|
||||
- "Justificado" (Excused)
|
||||
- Templates "Public"
|
||||
|
||||
---
|
||||
|
||||
#### Danger/Red (Encerrada, Rejeitado, Ausente)
|
||||
|
||||
**Tema Claro:** `bg-red-100 text-red-700 border-red-200`
|
||||
**Tema Escuro:** `dark:bg-red-900/20 dark:text-red-300 dark:border-red-800`
|
||||
|
||||
```jsx
|
||||
<span className="text-xs px-2 py-1 rounded-full border bg-red-100 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800">
|
||||
Encerrada
|
||||
</span>
|
||||
```
|
||||
|
||||
**Casos de Uso:**
|
||||
- "Encerrada" (Expired)
|
||||
- "Rejeitado" (Rejected)
|
||||
- "Ausente" (Absent)
|
||||
- Status "Rejected"
|
||||
|
||||
---
|
||||
|
||||
#### Warning/Amber (Aguardando Pagamento, Atrasado, Arquivada)
|
||||
|
||||
**Tema Claro:** `bg-amber-100 text-amber-700 border-amber-200`
|
||||
**Tema Escuro:** `dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800`
|
||||
|
||||
```jsx
|
||||
<span className="text-xs px-2 py-1 rounded-full border bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800">
|
||||
Aguardando Pagamento
|
||||
</span>
|
||||
```
|
||||
|
||||
**Casos de Uso:**
|
||||
- "Aguardando Pagamento" (Pending Payment)
|
||||
- "Atrasado" (Late)
|
||||
- "Arquivada" (Archived)
|
||||
- Status "Pending"
|
||||
|
||||
---
|
||||
|
||||
#### Warning/Yellow (Obrigações)
|
||||
|
||||
**Tema Claro:** `bg-yellow-100 text-yellow-800 border-yellow-300`
|
||||
**Tema Escuro:** `dark:bg-yellow-900/50 dark:text-yellow-200 dark:border-yellow-700`
|
||||
|
||||
```jsx
|
||||
<span className="text-xs px-2 py-1 rounded-full border bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-900/50 dark:text-yellow-200 dark:border-yellow-700">
|
||||
Obrigação
|
||||
</span>
|
||||
```
|
||||
|
||||
**Casos de Uso:**
|
||||
- Obrigações de pagamento
|
||||
- "Obrigação" (Obligation)
|
||||
|
||||
---
|
||||
|
||||
#### Purple (Leitura)
|
||||
|
||||
**Tema Claro:** `bg-purple-100 text-purple-700 border-purple-200`
|
||||
**Tema Escuro:** `dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800`
|
||||
|
||||
```jsx
|
||||
<span className="text-xs px-2 py-1 rounded-full border bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800">
|
||||
Leitura
|
||||
</span>
|
||||
```
|
||||
|
||||
**Casos de Uso:**
|
||||
- "Leitura" (Reading)
|
||||
- Categoria "Indigo"
|
||||
|
||||
---
|
||||
|
||||
#### Indigo (Trabalhos)
|
||||
|
||||
**Tema Claro:** `bg-indigo-100 text-indigo-700 border-indigo-200`
|
||||
**Tema Escuro:** `dark:bg-indigo-900/20 dark:text-indigo-300 dark:border-indigo-800`
|
||||
|
||||
```jsx
|
||||
<span className="text-xs px-2 py-1 rounded-full border bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-900/20 dark:text-indigo-300 dark:border-indigo-800">
|
||||
Trabalhos
|
||||
</span>
|
||||
```
|
||||
|
||||
**Casos de Uso:**
|
||||
- "Trabalhos" (Homework)
|
||||
- Itens relacionados a assignments
|
||||
|
||||
---
|
||||
|
||||
#### Neutral/Gray (Outros, Padrão)
|
||||
|
||||
**Tema Claro:** `bg-gray-100 text-gray-700 border-gray-200`
|
||||
**Tema Escuro:** `dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700`
|
||||
|
||||
```jsx
|
||||
<span className="text-xs px-2 py-1 rounded-full border bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700">
|
||||
Outros
|
||||
</span>
|
||||
```
|
||||
|
||||
**Casos de Uso:**
|
||||
- "Outros" (Other)
|
||||
- Categorias padrão/desconhecidas
|
||||
- Status neutro
|
||||
|
||||
### Badges por Categoria
|
||||
|
||||
#### Categorias de Arquivos
|
||||
|
||||
| Categoria | Cor |
|
||||
|-----------|-----|
|
||||
| Slides | Blue |
|
||||
| Exercícios | Green |
|
||||
| Leitura | Purple |
|
||||
| Vídeos | Red |
|
||||
| Áudio | Yellow |
|
||||
| Trabalhos | Indigo |
|
||||
| Outros | Gray |
|
||||
|
||||
#### Status de Presença
|
||||
|
||||
| Status | Cor |
|
||||
|--------|-----|
|
||||
| Presente | Green |
|
||||
| Ausente | Red |
|
||||
| Atrasado | Amber |
|
||||
| Justificado | Blue |
|
||||
|
||||
#### Status de Pagamento
|
||||
|
||||
| Status | Cor |
|
||||
|--------|-----|
|
||||
| Pago | Green |
|
||||
| Aguardando Verificação | Blue |
|
||||
| Rejeitado | Red |
|
||||
| Aguardando Pagamento | Amber |
|
||||
| Obrigação | Yellow |
|
||||
|
||||
#### Status de Provas
|
||||
|
||||
| Status | Cor |
|
||||
|--------|-----|
|
||||
| Disponível | Green |
|
||||
| Em breve | Blue |
|
||||
| Encerrada | Red |
|
||||
|
||||
#### Status de Turma
|
||||
|
||||
| Status | Cor |
|
||||
|--------|-----|
|
||||
| Ativa | Green |
|
||||
| Arquivada | Amber |
|
||||
|
||||
### Exemplo de Implementação
|
||||
|
||||
```jsx
|
||||
const categoryColors = {
|
||||
'Slides': 'text-xs px-2 py-1 rounded-full border bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800',
|
||||
'Exercícios': 'text-xs px-2 py-1 rounded-full border bg-green-100 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800',
|
||||
'Leitura': 'text-xs px-2 py-1 rounded-full border bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800',
|
||||
'Vídeos': 'text-xs px-2 py-1 rounded-full border bg-red-100 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800',
|
||||
'Áudio': 'text-xs px-2 py-1 rounded-full border bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-800',
|
||||
'Trabalhos': 'text-xs px-2 py-1 rounded-full border bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-900/20 dark:text-indigo-300 dark:border-indigo-800',
|
||||
'Outros': 'text-xs px-2 py-1 rounded-full border bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700'
|
||||
};
|
||||
|
||||
<span className={categoryColors[category] || categoryColors['Outros']}>
|
||||
{category}
|
||||
</span>
|
||||
```
|
||||
|
||||
### Notas Importantes
|
||||
|
||||
1. **Consistência**: Sempre use a string de classes completa incluindo variantes de tema claro e escuro
|
||||
2. **Acessibilidade**: As combinações de cores fornecem contraste suficiente para ambos os temas
|
||||
3. **Opacidade**: O tema escuro usa `/20` de opacidade para a maioria das cores (exceto yellow que usa `/50`)
|
||||
4. **Borda**: Sempre inclua borda para melhor separação visual
|
||||
5. **Arredondamento**: Use `rounded-full` para badges em formato de pílula
|
||||
6. **Tamanho**: Use `text-xs` para badges pequenos, `text-sm` para badges maiores se necessário
|
||||
|
||||
---
|
||||
|
||||
## Componentes
|
||||
|
||||
### Cards
|
||||
|
||||
**Padrão Unificado:**
|
||||
|
||||
```jsx
|
||||
<div className="border rounded-xl p-5 shadow-sm bg-card dark:bg-card dark:border-border hover:shadow-md hover:border-neutral-300 dark:hover:border-neutral-700 transition-all">
|
||||
{/* conteúdo */}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Card com Link:**
|
||||
|
||||
```jsx
|
||||
<Link href="/path" className="block no-underline">
|
||||
<div className="border rounded-xl p-5 shadow-sm bg-card hover:shadow-md transition-all">
|
||||
{/* conteúdo */}
|
||||
</div>
|
||||
</Link>
|
||||
```
|
||||
|
||||
### Botões
|
||||
|
||||
**Primary (Ação Principal):**
|
||||
|
||||
```jsx
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white h-10 px-4 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
Salvar
|
||||
</button>
|
||||
```
|
||||
|
||||
**Secondary (Ação Secundária):**
|
||||
|
||||
```jsx
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
```
|
||||
|
||||
**Destructive (Ação de Exclusão):**
|
||||
|
||||
```jsx
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium bg-red-600 hover:bg-red-700 text-white h-10 px-4 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
Excluir
|
||||
</button>
|
||||
```
|
||||
|
||||
**Botão com Ícone:**
|
||||
|
||||
```jsx
|
||||
<button className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium shadow-sm hover:shadow transition-shadow">
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>Texto do Botão</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
**Input de Texto:**
|
||||
|
||||
```jsx
|
||||
<input
|
||||
type="text"
|
||||
className="flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Digite aqui..."
|
||||
/>
|
||||
```
|
||||
|
||||
**Select:**
|
||||
|
||||
```jsx
|
||||
<div className="relative">
|
||||
<select
|
||||
className="flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 appearance-none"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{/* opções */}
|
||||
</select>
|
||||
<svg className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground">
|
||||
{/* ícone de seta */}
|
||||
</svg>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Textarea:**
|
||||
|
||||
```jsx
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Digite sua mensagem..."
|
||||
rows={4}
|
||||
/>
|
||||
```
|
||||
|
||||
### Tabelas
|
||||
|
||||
**Tabela Padrão:**
|
||||
|
||||
```jsx
|
||||
<div className="w-full overflow-x-auto rounded-xl border border-border bg-card shadow-sm">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-muted text-muted-foreground font-semibold uppercase text-xs">
|
||||
<tr>
|
||||
<th className="px-6 py-4 border-b border-border">Coluna 1</th>
|
||||
<th className="px-6 py-4 border-b border-border">Coluna 2</th>
|
||||
<th className="px-6 py-4 border-b border-border text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
<tr className="hover:bg-accent transition-colors">
|
||||
<td className="px-6 py-4 text-foreground">Valor 1</td>
|
||||
<td className="px-6 py-4 text-foreground">Valor 2</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
{/* ações */}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Badges
|
||||
|
||||
**Status Badges:**
|
||||
|
||||
```jsx
|
||||
// Success (Pago)
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium border bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200 border-green-300 dark:border-green-700">
|
||||
Pago
|
||||
</span>
|
||||
|
||||
// Warning (Pendente)
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium border bg-yellow-100 dark:bg-yellow-900/50 text-yellow-800 dark:text-yellow-200 border-yellow-300 dark:border-yellow-700">
|
||||
Pendente
|
||||
</span>
|
||||
|
||||
// Error (Rejeitado)
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium border bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200 border-red-300 dark:border-red-700">
|
||||
Rejeitado
|
||||
</span>
|
||||
|
||||
// Info (Aguardando Verificação)
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium border bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200 border-blue-300 dark:border-blue-700">
|
||||
Aguardando Verificação
|
||||
</span>
|
||||
|
||||
// Neutral (Não Pago)
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium border bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 border-neutral-300 dark:border-neutral-600">
|
||||
Não Pago
|
||||
</span>
|
||||
```
|
||||
|
||||
### Flash Messages
|
||||
|
||||
```jsx
|
||||
// Success
|
||||
<div className="px-4 py-3 rounded relative mb-4 text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/50 border border-green-400 dark:border-green-700">
|
||||
<div className="flex items-center">
|
||||
<strong className="font-bold mr-1">Sucesso:</strong>
|
||||
<span className="block sm:inline">Mensagem de sucesso</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Error
|
||||
<div className="px-4 py-3 rounded relative mb-4 text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-900/50 border border-red-400 dark:border-red-700">
|
||||
<div className="flex items-center">
|
||||
<strong className="font-bold mr-1">Erro:</strong>
|
||||
<span className="block sm:inline">Mensagem de erro</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Warning
|
||||
<div className="px-4 py-3 rounded relative mb-4 text-yellow-700 dark:text-yellow-300 bg-yellow-100 dark:bg-yellow-900/50 border border-yellow-400 dark:border-yellow-700">
|
||||
<div className="flex items-center">
|
||||
<strong className="font-bold mr-1">Aviso:</strong>
|
||||
<span className="block sm:inline">Mensagem de aviso</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Info
|
||||
<div className="px-4 py-3 rounded relative mb-4 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 border border-blue-400 dark:border-blue-700">
|
||||
<div className="flex items-center">
|
||||
<strong className="font-bold mr-1">Info:</strong>
|
||||
<span className="block sm:inline">Mensagem informativa</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Sidebar Navigation
|
||||
|
||||
```jsx
|
||||
<aside className="w-64 flex-shrink-0 border-r border-neutral-200 dark:border-neutral-800 p-4 bg-white dark:bg-neutral-950">
|
||||
<nav className="mt-2">
|
||||
<ul className="space-y-1">
|
||||
{/* itens de navegação */}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
```
|
||||
|
||||
### Stats Cards
|
||||
|
||||
```jsx
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-800 p-3 bg-neutral-50 dark:bg-neutral-800/40">
|
||||
<p className="text-xs text-neutral-700 dark:text-neutral-300 font-medium">Label</p>
|
||||
<p className="mt-1 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Valor</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Transições e Animações
|
||||
|
||||
### Padrões de Transição
|
||||
|
||||
```jsx
|
||||
// Transição suave para todas as propriedades
|
||||
className="transition-all duration-200"
|
||||
|
||||
// Transição apenas para cores
|
||||
className="transition-colors"
|
||||
|
||||
// Transição para sombra
|
||||
className="transition-shadow"
|
||||
|
||||
// Transição para transformações
|
||||
className="transition-transform"
|
||||
```
|
||||
|
||||
### Hover Effects
|
||||
|
||||
```jsx
|
||||
// Card hover
|
||||
<div className="hover:shadow-md hover:border-neutral-300 dark:hover:border-neutral-700 transition-all">
|
||||
|
||||
// Button hover
|
||||
<button className="hover:bg-blue-700 transition-colors">
|
||||
|
||||
// Link hover
|
||||
<a className="hover:text-blue-600 transition-colors">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Regras CSS Globais
|
||||
|
||||
### Importante: Evitar `!important` em regras globais
|
||||
|
||||
**NÃO FAÇA:**
|
||||
|
||||
```css
|
||||
button[type="submit"] {
|
||||
background-color: var(--primary) !important;
|
||||
color: var(--primary-foreground) !important;
|
||||
}
|
||||
```
|
||||
|
||||
Isso sobrescreve todas as classes Tailwind e causa problemas de manutenção.
|
||||
|
||||
**FAÇA:**
|
||||
|
||||
Deixe cada componente definir seu próprio estilo usando classes Tailwind ou CSS variables sem `!important`.
|
||||
|
||||
### Estilos Base (Definidos em `globals.css`)
|
||||
|
||||
```css
|
||||
/* Form elements */
|
||||
input, select, textarea {
|
||||
border: 1px solid var(--input);
|
||||
background-color: var(--background);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--foreground);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background-color: var(--card);
|
||||
color: var(--card-foreground);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
padding: 0.125rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus,
|
||||
a:active {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist de Implementação
|
||||
|
||||
Ao criar ou modificar componentes, verifique:
|
||||
|
||||
- [ ] Usar CSS variables (`bg-card`, `text-foreground`, `border-border`)
|
||||
- [ ] Usar `neutral-*` em vez de `gray-*`
|
||||
- [ ] Usar `blue-600` para botões primary
|
||||
- [ ] Usar `rounded-lg` ou `rounded-xl` para border-radius
|
||||
- [ ] Adicionar `transition-colors` ou `transition-all` para elementos interativos
|
||||
- [ ] Testar em tema claro e escuro
|
||||
- [ ] Verificar contraste de cores
|
||||
- [ ] Usar classes Tailwind em vez de CSS customizado quando possível
|
||||
- [ ] Evitar `!important` em regras CSS
|
||||
|
||||
---
|
||||
|
||||
## Exemplos de Componentes Completos
|
||||
|
||||
### Formulário Padrão
|
||||
|
||||
```jsx
|
||||
function StandardForm({ onSubmit, onCancel }) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="bg-card text-card-foreground rounded-xl border shadow-sm p-6 max-w-lg mx-auto space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-sm font-medium leading-none">
|
||||
Nome
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
className="flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
placeholder="Digite o nome"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium leading-none">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
className="flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
placeholder="Digite o email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white h-10 px-4 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 flex-1"
|
||||
>
|
||||
Salvar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex-1"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Card de Dashboard
|
||||
|
||||
```jsx
|
||||
function DashboardCard({ title, description, buttonText, link, icon: Icon }) {
|
||||
return (
|
||||
<Link href={link} className="block no-underline">
|
||||
<div className="h-full border rounded-xl p-5 shadow-sm bg-card dark:bg-card dark:border-border hover:shadow-md hover:border-neutral-300 dark:hover:border-neutral-700 transition-all">
|
||||
<div className="flex items-start gap-4">
|
||||
{Icon && (
|
||||
<div className="p-2.5 rounded-lg bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{description}</p>
|
||||
<span className="inline-flex justify-center text-white text-sm font-medium py-2 px-6 rounded-lg transition-colors bg-blue-600 hover:bg-blue-700">
|
||||
{buttonText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conclusão
|
||||
|
||||
Seguir estes padrões garantirá:
|
||||
|
||||
✅ **Consistência visual** em todo o sistema
|
||||
✅ **Manutenção mais fácil** com código previsível
|
||||
✅ **Melhor suporte** a tema claro/escuro
|
||||
✅ **Código mais limpo** e organizado
|
||||
✅ **Experiência do usuário** mais coesa
|
||||
|
||||
Para dúvidas ou sugestões de melhoria, consulte a equipe de desenvolvimento.
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script de Deploy para produção
|
||||
# Uso: ./deploy.sh [ambiente]
|
||||
# Exemplo: ./deploy.sh production
|
||||
|
||||
set -e # Para em caso de erro
|
||||
|
||||
echo "🚀 Iniciando deploy..."
|
||||
|
||||
# Cores para output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Função para imprimir mensagens coloridas
|
||||
print_success() {
|
||||
echo -e "${GREEN}✓ $1${NC}"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}✗ $1${NC}"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠ $1${NC}"
|
||||
}
|
||||
|
||||
# Detectar o dono do diretório para rodar comandos como ele
|
||||
DIR_OWNER=$(stat -c '%U' .)
|
||||
PROJECT_DIR=$(pwd)
|
||||
|
||||
run_as_owner() {
|
||||
su - "$DIR_OWNER" -c "cd $PROJECT_DIR && source ~/.bashrc 2>/dev/null; source ~/.profile 2>/dev/null; export NVM_DIR=\"\$HOME/.nvm\"; [ -s \"\$NVM_DIR/nvm.sh\" ] && . \"\$NVM_DIR/nvm.sh\"; $1"
|
||||
}
|
||||
|
||||
# Verificar se está rodando como root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
print_warning "Este script precisa de sudo para configurar o nginx"
|
||||
SUDO="sudo"
|
||||
else
|
||||
SUDO=""
|
||||
fi
|
||||
|
||||
# 1. Atualizar código (se estiver em git)
|
||||
echo ""
|
||||
echo "📦 Atualizando código..."
|
||||
if [ -d ".git" ]; then
|
||||
run_as_owner "git pull"
|
||||
print_success "Código atualizado"
|
||||
else
|
||||
print_warning "Não é um repositório git, pulando atualização"
|
||||
fi
|
||||
|
||||
# 2. Instalar dependências limpas
|
||||
echo ""
|
||||
echo "📥 Instalando dependências com npm ci..."
|
||||
if [ -f "package-lock.json" ]; then
|
||||
run_as_owner "rm -rf node_modules && npm ci"
|
||||
print_success "Dependências instaladas"
|
||||
else
|
||||
print_warning "package-lock.json não encontrado, usando npm install"
|
||||
run_as_owner "npm install"
|
||||
fi
|
||||
|
||||
# 3. Configurar nginx para uploads maiores
|
||||
echo ""
|
||||
echo "🌐 Configurando nginx..."
|
||||
|
||||
NGINX_CONF="/etc/nginx/sites-available/inglescomideiasvivas"
|
||||
NGINX_BACKUP="/tmp/nginx_inglescomideiasvivas.backup"
|
||||
|
||||
# Verificar se o arquivo de configuração existe
|
||||
if [ -f "$NGINX_CONF" ]; then
|
||||
# Fazer backup
|
||||
$SUDO cp "$NGINX_CONF" "$NGINX_BACKUP"
|
||||
|
||||
# Verificar se client_max_body_size já existe
|
||||
if $SUDO grep -q "client_max_body_size" "$NGINX_CONF"; then
|
||||
print_warning "client_max_body_size já configurado, atualizando para 500M..."
|
||||
$SUDO sed -i 's/client_max_body_size .*/client_max_body_size 500M;/' "$NGINX_CONF"
|
||||
else
|
||||
# Adicionar client_max_body_size após client_body_timeout ou no bloco server
|
||||
$SUDO sed -i '/server {/a \ client_max_body_size 500M;' "$NGINX_CONF"
|
||||
print_success "client_max_body_size 500M adicionado ao nginx"
|
||||
fi
|
||||
|
||||
# Testar configuração do nginx
|
||||
echo "Testando configuração do nginx..."
|
||||
if $SUDO nginx -t 2>&1 | grep -q "successful"; then
|
||||
print_success "Configuração do nginx válida"
|
||||
$SUDO systemctl reload nginx
|
||||
print_success "Nginx recarregado"
|
||||
else
|
||||
print_error "Configuração do nginx inválida!"
|
||||
echo "Restorando backup..."
|
||||
$SUDO cp "$NGINX_BACKUP" "$NGINX_CONF"
|
||||
$SUDO nginx -t
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
print_warning "Arquivo de configuração do nginx não encontrado em $NGINX_CONF"
|
||||
echo "Você pode precisar configurar manualmente:"
|
||||
echo " sudo nano $NGINX_CONF"
|
||||
echo "Adicione 'client_max_body_size 500M;' no bloco server"
|
||||
fi
|
||||
|
||||
# 4. Limpar cache do Next.js e rebuild
|
||||
echo ""
|
||||
echo "🔨 Limpar cache e rebuild..."
|
||||
run_as_owner "rm -rf .next .next.cache"
|
||||
print_success "Cache limpo"
|
||||
|
||||
echo "Building..."
|
||||
run_as_owner "npm run build"
|
||||
print_success "Build concluído"
|
||||
|
||||
# 5. Reiniciar PM2
|
||||
echo ""
|
||||
echo "🔄 Reiniciando aplicação com PM2..."
|
||||
|
||||
# Verificar se o app existe no PM2
|
||||
if run_as_owner "pm2 list" | grep -q "iciv"; then
|
||||
run_as_owner "pm2 delete iciv"
|
||||
print_success "App antigo removido"
|
||||
fi
|
||||
|
||||
run_as_owner "pm2 start ecosystem.config.js"
|
||||
run_as_owner "pm2 save"
|
||||
print_success "Aplicação reiniciada"
|
||||
|
||||
# 6. Mostrar status
|
||||
echo ""
|
||||
echo "📊 Status da aplicação:"
|
||||
run_as_owner "pm2 list"
|
||||
|
||||
echo ""
|
||||
echo "🎉 Deploy concluído com sucesso!"
|
||||
echo ""
|
||||
echo "Logs em tempo real:"
|
||||
echo " pm2 logs iciv"
|
||||
echo ""
|
||||
echo "Para verificar logs de erro:"
|
||||
echo " pm2 logs iciv --err"
|
||||
@@ -0,0 +1,93 @@
|
||||
# Docker Compose para Desenvolvimento
|
||||
# Para produção, veja docker/production/docker-compose.yml
|
||||
services:
|
||||
# MongoDB - Banco de dados
|
||||
mongodb:
|
||||
image: mongo:8
|
||||
container_name: course-plat-mongodb
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "27017:27017"
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME:-admin}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password}
|
||||
MONGO_INITDB_DATABASE: ${MONGO_DB:-course-plat}
|
||||
volumes:
|
||||
- mongodb_data:/data/db
|
||||
- mongodb_config:/data/configdb
|
||||
healthcheck:
|
||||
test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- course-plat-network
|
||||
|
||||
# MongoDB Express (opcional - UI para o MongoDB)
|
||||
mongo-express:
|
||||
image: mongo-express:latest
|
||||
container_name: course-plat-mongo-express
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:8081"
|
||||
environment:
|
||||
ME_CONFIG_MONGODB_URL: mongodb://${MONGO_USERNAME:-admin}:${MONGO_PASSWORD:-password}@mongodb:27017/
|
||||
ME_CONFIG_BASICAUTH_USERNAME: ${MONGO_EXPR_USER:-admin}
|
||||
ME_CONFIG_BASICAUTH_PASSWORD: ${MONGO_EXPR_PASS:-admin}
|
||||
depends_on:
|
||||
mongodb:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- course-plat-network
|
||||
|
||||
# MinIO - Armazenamento de arquivos (S3 compatible)
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: course-plat-minio
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9000:9000" # API
|
||||
- "9001:9001" # Console (UI)
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
command: server /data --console-address ":9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
networks:
|
||||
- course-plat-network
|
||||
|
||||
# MinIO Init - Cria bucket privado automaticamente
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
container_name: course-plat-minio-init
|
||||
depends_on:
|
||||
- minio
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
sleep 5 &&
|
||||
mc alias set myminio http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin} &&
|
||||
mc mb myminio/${MINIO_BUCKET:-course-plat} --ignore-existing &&
|
||||
mc anonymous set none myminio/${MINIO_BUCKET:-course-plat} &&
|
||||
echo 'MinIO initialized! Bucket is private.'
|
||||
"
|
||||
restart: "no"
|
||||
networks:
|
||||
- course-plat-network
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
||||
driver: local
|
||||
mongodb_config:
|
||||
driver: local
|
||||
minio_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
course-plat-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,4 @@
|
||||
# MinIO Credentials
|
||||
MINIO_ROOT_USER=admin
|
||||
MINIO_ROOT_PASSWORD=change-this-password-please
|
||||
MINIO_BUCKET=course-plat
|
||||
@@ -0,0 +1,157 @@
|
||||
# MinIO + Backup Setup
|
||||
|
||||
Setup completo do MinIO com scripts de backup para VPS e download local.
|
||||
|
||||
## 📁 Estrutura
|
||||
|
||||
```
|
||||
docker/minio/
|
||||
├── docker-compose.yml # Serviço MinIO
|
||||
├── backup-minio.sh # Script de backup (roda no servidor)
|
||||
├── pull-backup.sh # Script para baixar backup (roda no seu PC)
|
||||
└── .env.example # Variáveis de ambiente
|
||||
```
|
||||
|
||||
## 🔒 Segurança
|
||||
|
||||
**Bucket é PRIVADO** por padrão. Arquivos são servidos via proxy da aplicação (`/api/files/...`), o que permite:
|
||||
- Controle de autenticação
|
||||
- Verificação de permissões
|
||||
- Logs de acesso
|
||||
|
||||
## 🚀 Setup Inicial
|
||||
|
||||
### 1. Criar arquivo .env
|
||||
|
||||
```bash
|
||||
cd docker/minio
|
||||
cat > .env << 'EOF'
|
||||
MINIO_ROOT_USER=admin
|
||||
MINIO_ROOT_PASSWORD=sua-senha-forte-aqui
|
||||
MINIO_BUCKET=course-plat
|
||||
EOF
|
||||
```
|
||||
|
||||
### 2. Iniciar MinIO
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3. Acessar
|
||||
|
||||
- **Console (UI)**: http://seu-servidor:9001
|
||||
- **API**: http://seu-servidor:9000
|
||||
|
||||
## 💾 Backup Automático
|
||||
|
||||
### No Servidor (VPS)
|
||||
|
||||
1. Copiar o script para o servidor:
|
||||
```bash
|
||||
chmod +x backup-minio.sh
|
||||
sudo cp backup-minio.sh /usr/local/bin/
|
||||
```
|
||||
|
||||
2. Testar manualmente:
|
||||
```bash
|
||||
/usr/local/bin/backup-minio.sh
|
||||
```
|
||||
|
||||
3. Adicionar ao crontab (backup todo dia às 2h da manhã):
|
||||
```bash
|
||||
crontab -e
|
||||
# Adicionar linha:
|
||||
0 2 * * * /usr/local/bin/backup-minio.sh >> /var/log/minio-backup.log 2>&1
|
||||
```
|
||||
|
||||
### No Seu PC
|
||||
|
||||
1. Editar `pull-backup.sh` com seus dados:
|
||||
```bash
|
||||
SERVER_USER="seu-usuario"
|
||||
SERVER_HOST="seu-servidor.com"
|
||||
```
|
||||
|
||||
2. Dar permissão e testar:
|
||||
```bash
|
||||
chmod +x pull-backup.sh
|
||||
./pull-backup.sh
|
||||
```
|
||||
|
||||
3. Agendar no crontab do SEU PC (todo dia às 3h):
|
||||
```bash
|
||||
crontab -e
|
||||
# Adicionar linha:
|
||||
0 3 * * * /caminho/para/pull-backup.sh
|
||||
```
|
||||
|
||||
## 🔄 Como Funciona
|
||||
|
||||
```
|
||||
┌─────────────────┐ 02:00 ┌──────────────────┐
|
||||
│ Servidor VPS │ ────────────────▶│ Backup Local │
|
||||
│ (MinIO) │ backup-minio.sh│ (/var/backups/) │
|
||||
└─────────────────┘ └──────────────────┘
|
||||
│
|
||||
│ 03:00
|
||||
│ pull-backup.sh
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Seu PC │
|
||||
│ (Backup final) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## 📦 Restaurar Backup
|
||||
|
||||
### No servidor:
|
||||
```bash
|
||||
# Parar MinIO
|
||||
docker compose down
|
||||
|
||||
# Restaurar volume
|
||||
docker run --rm \
|
||||
-v course-plat_minio_data:/data \
|
||||
-v /var/backups/minio:/backup \
|
||||
alpine tar xzf /backup/minio-backup-YYYYMMDD-HHMMSS.tar.gz -C /data
|
||||
|
||||
# Reiniciar
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## ⚠️ Considerações
|
||||
|
||||
| Aspecto | Nota |
|
||||
|---------|------|
|
||||
| **Segurança** | Use chaves SSH para o rsync |
|
||||
| **Backup external** | Considere também mandar para S3/Backblaze |
|
||||
| **Teste** | Teste restaurar pelo menos uma vez |
|
||||
| **Logs** | Verifique `/var/log/minio-backup.log` regularmente |
|
||||
|
||||
## 🔧 Variáveis do backup-minio.sh
|
||||
|
||||
```bash
|
||||
BACKUP_DIR="/var/backups/minio" # Onde salvar no servidor
|
||||
RETENTION_DAYS=7 # Dias para manter backup
|
||||
VOLUME_NAME="course-plat_minio_data" # Nome do volume
|
||||
```
|
||||
|
||||
## 🔧 Variáveis do pull-backup.sh
|
||||
|
||||
```bash
|
||||
SERVER_USER="usuario" # Usuário SSH
|
||||
SERVER_HOST="seu-servidor.com" # Host do servidor
|
||||
SERVER_BACKUP_DIR="/var/backups/minio"
|
||||
LOCAL_BACKUP_DIR="$HOME/backups/minio"
|
||||
KEEP_LOCAL_DAYS=30 # Dias para manter localmente
|
||||
```
|
||||
|
||||
## 🔗 URLs de Arquivos
|
||||
|
||||
| Onde | Formato |
|
||||
|------|---------|
|
||||
| **Banco de dados** | `proxy://iciv/ClassTypes/arquivo.pdf` |
|
||||
| **Front-end** | `/api/files/iciv/ClassTypes/arquivo.pdf` |
|
||||
|
||||
Use `getFileUrl(url)` do `@/app/lib/utils/storage/fileUrl` para converter.
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# backup-minio.sh - Script de backup do MinIO no servidor
|
||||
# Uso: ./backup-minio.sh
|
||||
# Agendar no crontab: 0 2 * * * /caminho/para/backup-minio.sh
|
||||
|
||||
set -e
|
||||
|
||||
# ============ CONFIGURAÇÃO ============
|
||||
BACKUP_DIR="/var/backups/minio" # Onde salvar os backups no servidor
|
||||
RETENTION_DAYS=7 # Quantos dias manter
|
||||
VOLUME_NAME="course-plat_minio_data" # Nome do volume Docker
|
||||
COMPRESSION="gzip" # gzip ou none
|
||||
|
||||
# Criar diretório de backup se não existir
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Data atual para o nome do arquivo
|
||||
DATE=$(date +%Y%m%d-%H%M%S)
|
||||
BACKUP_FILE="$BACKUP_DIR/minio-backup-$DATE.tar.gz"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Backup MinIO iniciado em: $(date)"
|
||||
echo "=========================================="
|
||||
|
||||
# ============ FAZER BACKUP ============
|
||||
echo "Criando backup do volume $VOLUME_NAME..."
|
||||
|
||||
if [ "$COMPRESSION" = "gzip" ]; then
|
||||
docker run --rm \
|
||||
-v "$VOLUME_NAME:/data:ro" \
|
||||
-v "$BACKUP_DIR:/backup" \
|
||||
alpine tar czf "/backup/$(basename "$BACKUP_FILE")" -C /data .
|
||||
else
|
||||
docker run --rm \
|
||||
-v "$VOLUME_NAME:/data:ro" \
|
||||
-v "$BACKUP_DIR:/backup" \
|
||||
alpine tar cf "/backup/$(basename "$BACKUP_FILE")" -C /data .
|
||||
fi
|
||||
|
||||
# ============ VERIFICAR ============
|
||||
if [ -f "$BACKUP_FILE" ]; then
|
||||
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
echo "✓ Backup criado: $BACKUP_FILE ($SIZE)"
|
||||
else
|
||||
echo "✗ ERRO: Backup não foi criado!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ============ LIMPAR BACKUPS ANTIGOS ============
|
||||
echo "Limpando backups com mais de $RETENTION_DAYS dias..."
|
||||
find "$BACKUP_DIR" -name "minio-backup-*.tar.gz" -mtime +$RETENTION_DAYS -delete
|
||||
|
||||
# ============ LISTAR BACKUPS RESTANTES ============
|
||||
echo ""
|
||||
echo "Backups disponíveis:"
|
||||
ls -lh "$BACKUP_DIR"/minio-backup-*.tar.gz 2>/dev/null || echo "Nenhum backup encontrado."
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Backup concluído em: $(date)"
|
||||
echo "=========================================="
|
||||
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: course-plat-minio
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9000:9000" # API
|
||||
- "9001:9001" # Console (UI)
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
command: server /data --console-address ":9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
|
||||
# Opcional: cria bucket automaticamente ao iniciar
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
container_name: course-plat-minio-init
|
||||
depends_on:
|
||||
- minio
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
sleep 5 &&
|
||||
mc alias set myminio http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin} &&
|
||||
mc mb myminio/${MINIO_BUCKET:-course-plat} --ignore-existing &&
|
||||
mc anonymous set none myminio/${MINIO_BUCKET:-course-plat} &&
|
||||
echo 'MinIO initialized! Bucket is private.'
|
||||
"
|
||||
restart: "no"
|
||||
|
||||
volumes:
|
||||
minio_data:
|
||||
driver: local
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# pull-backup.sh - Script para baixar backups do servidor para seu PC
|
||||
# Uso: ./pull-backup.sh
|
||||
# Agendar no crontab do SEU PC: 0 3 * * * /caminho/para/pull-backup.sh
|
||||
|
||||
set -e
|
||||
|
||||
# ============ CONFIGURAÇÃO ============
|
||||
# Preencha com seus dados
|
||||
SERVER_USER="usuario" # Usuário SSH no servidor
|
||||
SERVER_HOST="seu-servidor.com" # IP ou domínio do servidor
|
||||
SERVER_BACKUP_DIR="/var/backups/minio" # Mesmo diretório do backup-minio.sh
|
||||
LOCAL_BACKUP_DIR="$HOME/backups/minio" # Onde salvar no seu PC
|
||||
KEEP_LOCAL_DAYS=30 # Quanto tempo manter no seu PC
|
||||
|
||||
# ============ CÓDIGO ============
|
||||
mkdir -p "$LOCAL_BACKUP_DIR"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Baixando backups do MinIO"
|
||||
echo "De: $SERVER_USER@$SERVER_HOST:$SERVER_BACKUP_DIR"
|
||||
echo "Para: $LOCAL_BACKUP_DIR"
|
||||
echo "Iniciado em: $(date)"
|
||||
echo "=========================================="
|
||||
|
||||
# Baixar backups (somente os que ainda não temos)
|
||||
echo "Sincronizando arquivos..."
|
||||
rsync -avz --progress \
|
||||
-e "ssh -o StrictHostKeyChecking=no" \
|
||||
"$SERVER_USER@$SERVER_HOST:$SERVER_BACKUP_DIR/" \
|
||||
"$LOCAL_BACKUP_DIR/"
|
||||
|
||||
# ============ LIMPAR BACKUPS ANTIGOS (LOCAL) ============
|
||||
echo ""
|
||||
echo "Limpando backups locais com mais de $KEEP_LOCAL_DAYS dias..."
|
||||
find "$LOCAL_BACKUP_DIR" -name "minio-backup-*.tar.gz" -mtime +$KEEP_LOCAL_DAYS -delete
|
||||
|
||||
# ============ RESUMO ============
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Backups locais:"
|
||||
ls -lh "$LOCAL_BACKUP_DIR"/minio-backup-*.tar.gz 2>/dev/null | tail -5
|
||||
echo ""
|
||||
echo "Espaço usado:"
|
||||
du -sh "$LOCAL_BACKUP_DIR"
|
||||
echo ""
|
||||
echo "Concluído em: $(date)"
|
||||
echo "=========================================="
|
||||
|
||||
# ============ ALERTAS ============
|
||||
BACKUP_COUNT=$(ls -1 "$LOCAL_BACKUP_DIR"/minio-backup-*.tar.gz 2>/dev/null | wc -l)
|
||||
if [ "$BACKUP_COUNT" -eq 0 ]; then
|
||||
echo "⚠️ ATENÇÃO: Nenhum backup encontrado localmente!"
|
||||
fi
|
||||
@@ -0,0 +1,55 @@
|
||||
# Multi-stage Dockerfile para Produção
|
||||
# Uso: docker build -f docker/production/Dockerfile -t course-plat .
|
||||
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Instalar dependências apenas para produção
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar package files
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Build da aplicação
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Build Next.js
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# Imagem final de produção
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Criar usuário não-root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copiar arquivos necessários
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Criar diretório para storage local
|
||||
RUN mkdir -p /app/storage/uploads && chown -R nextjs:nodejs /app/storage
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
# ==========================================
|
||||
# BACKUP MONGODB - PRODUÇÃO
|
||||
# ==========================================
|
||||
# Suporta MongoDB nativo ou Docker
|
||||
# Uso: ./backup-mongodb.sh
|
||||
#
|
||||
# Adicionar ao crontab (deployuser):
|
||||
# 0 2 * * * cd /var/www/iciv && ./docker/production/backup-mongodb.sh >> ~/backup-mongodb.log 2>&1
|
||||
|
||||
set -e
|
||||
|
||||
# ==========================================
|
||||
# CONFIGURAÇÕES
|
||||
# ==========================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups/mongodb}"
|
||||
|
||||
RETENTION_DAYS="${RETENTION_DAYS:-7}"
|
||||
|
||||
CONTAINER_NAME="${MONGO_CONTAINER:-course-plat-mongodb}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-$PROJECT_DIR/.env.production}"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
set -a
|
||||
source <(grep -v '^\s*#' "$ENV_FILE")
|
||||
set +a
|
||||
fi
|
||||
|
||||
MONGO_USERNAME="${MONGO_USERNAME:-admin}"
|
||||
MONGO_PASSWORD="${MONGO_PASSWORD:-password}"
|
||||
MONGO_DB="${MONGO_DB:-course-plat}"
|
||||
|
||||
# ==========================================
|
||||
# FUNÇÕES
|
||||
# ==========================================
|
||||
|
||||
log() {
|
||||
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
log "ERROR: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
build_mongo_uri() {
|
||||
if [ -n "$MONGO_USERNAME" ] && [ -n "$MONGO_PASSWORD" ]; then
|
||||
echo "mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@127.0.0.1:27017/${MONGO_DB}?authSource=admin"
|
||||
else
|
||||
echo "mongodb://127.0.0.1:27017/${MONGO_DB}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# DETECÇÃO: Docker ou nativo
|
||||
# ==========================================
|
||||
|
||||
DOCKER_CMD="$(command -v docker 2>/dev/null || true)"
|
||||
|
||||
USE_DOCKER=false
|
||||
if [ -n "$DOCKER_CMD" ] && "$DOCKER_CMD" ps 2>/dev/null | grep -q "$CONTAINER_NAME"; then
|
||||
USE_DOCKER=true
|
||||
log "Modo: Docker (container: $CONTAINER_NAME)"
|
||||
else
|
||||
MONGODUMP="$(command -v mongodump 2>/dev/null || true)"
|
||||
if [ -z "$MONGODUMP" ]; then
|
||||
error "mongodump nao encontrado. Instale: sudo apt install mongo-tools"
|
||||
fi
|
||||
log "Modo: MongoDB nativo"
|
||||
fi
|
||||
|
||||
# ==========================================
|
||||
# INÍCIO
|
||||
# ==========================================
|
||||
|
||||
log "Iniciando backup do MongoDB..."
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
BACKUP_FILE="$BACKUP_DIR/mongodb-$TIMESTAMP.gz"
|
||||
|
||||
MONGO_URI=$(build_mongo_uri)
|
||||
log "Executando mongodump..."
|
||||
|
||||
if [ "$USE_DOCKER" = true ]; then
|
||||
"$DOCKER_CMD" exec "$CONTAINER_NAME" mongodump \
|
||||
--uri="$MONGO_URI" \
|
||||
--archive \
|
||||
--gzip \
|
||||
> "$BACKUP_FILE" 2>&1 || error "Falha no mongodump (docker)"
|
||||
else
|
||||
"$MONGODUMP" \
|
||||
--uri="$MONGO_URI" \
|
||||
--archive="$BACKUP_FILE" \
|
||||
--gzip \
|
||||
2>&1 || error "Falha no mongodump"
|
||||
fi
|
||||
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
error "Arquivo de backup nao foi criado"
|
||||
fi
|
||||
|
||||
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
log "Backup concluido: $BACKUP_FILE ($BACKUP_SIZE)"
|
||||
|
||||
# ==========================================
|
||||
# LIMPEZA
|
||||
# ==========================================
|
||||
|
||||
log "Removendo backups antigos (mais de $RETENTION_DAYS dias)..."
|
||||
|
||||
REMOVED=$(find "$BACKUP_DIR" \
|
||||
-name "mongodb-*.gz" \
|
||||
-type f \
|
||||
-mtime +$RETENTION_DAYS \
|
||||
-print -delete 2>/dev/null | wc -l)
|
||||
|
||||
log "Backups removidos: $REMOVED"
|
||||
|
||||
# ==========================================
|
||||
# RESUMO
|
||||
# ==========================================
|
||||
|
||||
TOTAL_BACKUPS=$(find "$BACKUP_DIR" -name "mongodb-*.gz" -type f | wc -l)
|
||||
TOTAL_SIZE=$(du -sh "$BACKUP_DIR" 2>/dev/null | cut -f1)
|
||||
|
||||
log "Resumo:"
|
||||
log " - Backup atual: $BACKUP_FILE"
|
||||
log " - Total de backups: $TOTAL_BACKUPS"
|
||||
log " - Tamanho total: $TOTAL_SIZE"
|
||||
log "Backup do MongoDB concluido com sucesso!"
|
||||
@@ -0,0 +1,125 @@
|
||||
# Docker Compose para Produção
|
||||
# Uso: docker compose -f docker/production/docker-compose.yml up -d
|
||||
#
|
||||
# Antes de usar:
|
||||
# 1. Copiar .env.example para .env.production
|
||||
# 2. Preencher todas as variáveis de ambiente
|
||||
# 3. Buildar a imagem da aplicação: docker build -t course-plat .
|
||||
# 4. Ou usar build automático com compose (descomentar build section)
|
||||
|
||||
services:
|
||||
# Aplicação Next.js
|
||||
app:
|
||||
image: course-plat:latest
|
||||
# Para build automático, descomente abaixo:
|
||||
# build:
|
||||
# context: ../..
|
||||
# dockerfile: docker/production/Dockerfile
|
||||
container_name: course-plat-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
environment:
|
||||
# Node
|
||||
NODE_ENV: production
|
||||
|
||||
# Next.js
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||
|
||||
# MongoDB
|
||||
MONGO_URI: mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@mongodb:27017/${MONGO_DB}?authSource=admin
|
||||
|
||||
# Storage
|
||||
STORAGE_TYPE: ${STORAGE_TYPE:-s3}
|
||||
LOCAL_STORAGE_PATH: /app/storage/uploads
|
||||
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
S3_BUCKET: ${S3_BUCKET}
|
||||
volumes:
|
||||
- app_storage:/app/storage/uploads
|
||||
depends_on:
|
||||
- mongodb
|
||||
- minio
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- course-plat-network
|
||||
|
||||
# MongoDB - Banco de dados
|
||||
mongodb:
|
||||
image: mongo:8
|
||||
container_name: course-plat-mongodb
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
|
||||
MONGO_INITDB_DATABASE: ${MONGO_DB}
|
||||
volumes:
|
||||
- mongodb_data:/data/db
|
||||
- mongodb_config:/data/configdb
|
||||
- ./backups/mongodb:/backups # Para restore
|
||||
healthcheck:
|
||||
test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
networks:
|
||||
- course-plat-network
|
||||
|
||||
# MinIO - Armazenamento de arquivos
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: course-plat-minio
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
command: server /data --console-address ":9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
networks:
|
||||
- course-plat-network
|
||||
|
||||
# Nginx (opcional) - Reverse Proxy
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: course-plat-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
- nginx_logs:/var/log/nginx
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
- course-plat-network
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
||||
driver: local
|
||||
mongodb_config:
|
||||
driver: local
|
||||
minio_data:
|
||||
driver: local
|
||||
app_storage:
|
||||
driver: local
|
||||
nginx_logs:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
course-plat-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,131 @@
|
||||
# Nginx Reverse Proxy para Course Plat
|
||||
# Uso: Copiar para docker/production/nginx.conf ou /etc/nginx/sites-available/course-plat
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# Performance
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml text/javascript
|
||||
application/json application/javascript application/xml+rss
|
||||
application/rss+xml font/truetype font/opentype
|
||||
application/vnd.ms-fontobject image/svg+xml;
|
||||
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||
|
||||
# Upstream da aplicação
|
||||
upstream app_backend {
|
||||
server app:3000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# HTTP -> HTTPS redirect (descomentar em prod com SSL)
|
||||
# server {
|
||||
# listen 80;
|
||||
# server_name seu-dominio.com.br;
|
||||
# return 301 https://$server_name$request_uri;
|
||||
# }
|
||||
|
||||
# Server principal
|
||||
server {
|
||||
listen 80;
|
||||
# listen 443 ssl http2; # Descomentar para SSL
|
||||
server_name _;
|
||||
|
||||
# SSL Configuration (descomentar para production)
|
||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
|
||||
# Client body size limit (uploads)
|
||||
client_max_body_size 500M;
|
||||
|
||||
# Proxy settings
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Next.js static files (com cache)
|
||||
location /_next/static {
|
||||
alias /var/www/.next/static;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /static {
|
||||
alias /var/www/public;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# API routes (sem cache, com rate limit)
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
proxy_pass http://app_backend;
|
||||
}
|
||||
|
||||
# Auth endpoints (rate limit mais estrito)
|
||||
location /api/auth/ {
|
||||
limit_req zone=login burst=5 nodelay;
|
||||
proxy_pass http://app_backend;
|
||||
}
|
||||
|
||||
# File downloads (proxy)
|
||||
location /api/files/ {
|
||||
proxy_pass http://app_backend;
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
}
|
||||
|
||||
# Todas as outras requests
|
||||
location / {
|
||||
proxy_pass http://app_backend;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
access_log off;
|
||||
proxy_pass http://app_backend/api/health;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
# Auditoria de Seguranca - course-plat
|
||||
**Data:** 2026-05-18
|
||||
**Escopo:** Analise completa de autenticacao, autorizacao e vulnerabilidades de seguranca
|
||||
|
||||
---
|
||||
|
||||
## CRITICO (Exploracao Imediata Possivel)
|
||||
|
||||
### 1. Middleware de Protecao de Rotas INATIVO
|
||||
**Arquivo:** `src/proxy.js`
|
||||
|
||||
O middleware existe mas **nao ha `src/middleware.js`** no projeto. O Next.js so reconhece middleware nos caminhos padrao (`src/middleware.js` ou `middleware.js` na raiz). Toda a protecao de rotas por pagina esta desativada. Qualquer usuario pode acessar `/admin/dashboard` diretamente sem login.
|
||||
|
||||
### 2. `updateUserData` - Escalacao de Privilegio TOTAL (sem auth)
|
||||
**Arquivo:** `src/app/lib/users/updateUserAction.js:11`
|
||||
|
||||
- `"use server"` com ZERO verificacao de autenticacao
|
||||
- Qualquer cliente (nao autenticado) pode modificar qualquer usuario, incluindo alterar o campo `roles` para `["admin"]`
|
||||
- **Impacto:** Um atacante pode se conceder acesso admin completo
|
||||
|
||||
### 3. `examActions.js` - 18 funcoes SEM autenticacao
|
||||
**Arquivo:** `src/app/lib/actions/examActions.js` (1102 linhas)
|
||||
|
||||
| Funcao | Linha | Impacto |
|
||||
|--------|-------|---------|
|
||||
| `getExamTemplates` | 82 | Vazar todas as questoes + respostas corretas de provas |
|
||||
| `getExamTemplateById` | 104 | Ver gabarito completo de qualquer prova |
|
||||
| `updateExamTemplate` | 155 | Alterar respostas corretas de qualquer prova |
|
||||
| `deleteExamTemplate` | 184 | Deletar qualquer modelo de prova |
|
||||
| `duplicateExamTemplate` | 218 | Duplicar e ver conteudo de qualquer prova |
|
||||
| `getExamAssignments` | 256 | Ver todas as atribuicoes de prova |
|
||||
| `getExamAssignmentById` | ~298 | Ver detalhes completos de atribuicao |
|
||||
| `createExamAssignment` | ~330 | Criar atribuicoes para qualquer turma |
|
||||
| `updateExamAssignment` | ~350 | Modificar atribuicoes de prova |
|
||||
| `deleteExamAssignment` | ~370 | Deletar atribuicoes |
|
||||
| `archiveExamAssignment` | ~390 | Arquivar/desarquivar |
|
||||
| `getExamAttempts` | ~410 | Ver todas as respostas e notas de todos os alunos |
|
||||
| `getExamAttemptById` | ~430 | Ver detalhes de qualquer tentativa |
|
||||
| `createExamAttempt` | ~450 | Criar tentativas impersonando qualquer aluno |
|
||||
| `submitExamAttempt` | ~470 | Submeter respostas por qualquer tentativa |
|
||||
| `getExamStatistics` | ~500 | Ver estatisticas gerais |
|
||||
| `getStudentExamStatistics` | ~520 | Ver historico de qualquer aluno |
|
||||
| `getClasses` | ~540 | Listar todas as turmas |
|
||||
|
||||
### 4. Server Actions administrativas sem autenticacao
|
||||
|
||||
15 funcoes com `"use server"` e ZERO verificacao de login:
|
||||
|
||||
| Funcao | Arquivo | Impacto |
|
||||
|--------|---------|---------|
|
||||
| `updateUserData` | `users/updateUserAction.js` | Escalacao de privilegio para admin |
|
||||
| `saveUserData` | `users/createWardUserAction.js` | Criar contas de aluno sem auth |
|
||||
| `saveProductAction` | `products/actions.js` | Criar/modificar produtos e precos |
|
||||
| `deleteProductAction` | `products/actions.js` | Deletar qualquer produto |
|
||||
| `saveClassAction` | `classes/saveClassAction.js` | Criar/modificar turmas |
|
||||
| `toggleClassStatus` | `classes/toggleClassStatus.js` | Arquivar/ativar qualquer turma |
|
||||
| `deleteClass` | `classes/deleteClass.js` | Deletar qualquer turma |
|
||||
| `deleteClassTypeAction` | `classes/deleteClassType.js` | Deletar tipo de turma |
|
||||
| `saveClassLink` | `classes/saveClassLinkAction.js` | Alterar link de aula |
|
||||
| `saveClassTypeAction` | `classes/saveClassTypeAction.js` | Criar/modificar tipos de turma |
|
||||
| `saveCategoryAction` | `categories/saveCategoryAction.js` | Criar/modificar categorias |
|
||||
| `deleteCategory` | `categories/deleteCategory.js` | Deletar categorias |
|
||||
| `deleteFile` | `generalActions/deleteFile.js` | Deletar qualquer arquivo do DB e S3 |
|
||||
| `updateExamTemplate` | `examActions.js` | Alterar gabaritos de provas |
|
||||
| `submitExamAttempt` | `examActions.js` | Submeter respostas por qualquer tentativa |
|
||||
|
||||
---
|
||||
|
||||
## ALTO (Risco Significativo)
|
||||
|
||||
### 5. IDOR em `/api/orders/[id]`
|
||||
**Arquivo:** `src/app/api/orders/[id]/route.js`
|
||||
|
||||
Qualquer usuario autenticado pode ver qualquer pedido por ID, sem verificacao de propriedade.
|
||||
|
||||
### 6. Vazamento de Informacoes em `/api/health`
|
||||
**Arquivo:** `src/app/api/health/route.js` - Totalmente publico
|
||||
|
||||
Expoe: status do MongoDB, contagem de usuarios, tipo de storage, endpoint S3, bucket name, `NODE_ENV`, `NEXTAUTH_URL`.
|
||||
|
||||
### 7. Endpoint de Debug em Producao
|
||||
**Arquivo:** `src/app/api/debug-token/route.js`
|
||||
|
||||
Qualquer usuario autenticado pode ver sessao completa, roles e status admin.
|
||||
|
||||
### 8. Aulas sem verificacao de membros
|
||||
**Arquivo:** `src/app/api/classes/[id]/lessons/route.js` (GET)
|
||||
|
||||
Qualquer usuario autenticado pode ver aulas de qualquer turma, sem verificar se e membro.
|
||||
|
||||
### 9. Headers de Debug no Middleware
|
||||
**Arquivo:** `src/proxy.js:92-96`
|
||||
|
||||
Headers `x-debug-roles`, `x-debug-is-logged-in`, `x-debug-default-route` vazam informacoes de autenticacao em toda resposta.
|
||||
|
||||
---
|
||||
|
||||
## MEDIO
|
||||
|
||||
### 10. Ausencia de Headers de Seguranca
|
||||
**Arquivo:** `next.config.mjs`
|
||||
|
||||
Nenhum header de seguranca configurado:
|
||||
- Sem `Content-Security-Policy`
|
||||
- Sem `X-Frame-Options`
|
||||
- Sem `X-Content-Type-Options`
|
||||
- Sem `Strict-Transport-Security`
|
||||
- Sem `Referrer-Policy`
|
||||
|
||||
### 11. Vazamento de Erros Internos
|
||||
Multiplas funcoes retornam erros raw ao cliente: `Erro: ${err}`, `error.message` - podem vazar stack traces, detalhes de conexao DB, etc.
|
||||
|
||||
### 12. `console.log` com Dados Sensiveis
|
||||
`examActions.js` usa `console.log` para imprimir respostas de alunos e dados de questoes no log do servidor.
|
||||
|
||||
### 13. Upload de Arquivo sem Restricao de Entidade
|
||||
**Arquivo:** `src/app/lib/generalActions/saveFileAction.js`
|
||||
|
||||
Qualquer usuario autenticado pode vincular arquivos a qualquer entidade (qualquer turma, tipo de turma, etc.).
|
||||
|
||||
### 14. Limite de Upload Muito Alto
|
||||
**Arquivo:** `next.config.mjs:11`
|
||||
|
||||
`bodySizeLimit: '200mb'` permite uploads de ate 200MB, facilitando ataques de negacao de servico.
|
||||
|
||||
### 15. Recibos como Base64 no MongoDB
|
||||
`submitOrderPaymentAction` armazena recibos como data URI base64 diretamente no documento do banco, causando growth descontrolado do DB.
|
||||
|
||||
---
|
||||
|
||||
## Resumo Quantitativo
|
||||
|
||||
| Severidade | Quantidade | Descricao |
|
||||
|------------|------------|-----------|
|
||||
| CRITICO | 15 | Server actions sem autenticacao nenhuma |
|
||||
| ALTO | 12 | Autenticado mas sem verificacao de role/autorizacao |
|
||||
| MEDIO | 5 | Autenticado e com role mas outros problemas |
|
||||
| OK | 27 | Protegido corretamente com auth + autorizacao |
|
||||
| **Total de funcoes auditadas** | **68** | |
|
||||
|
||||
---
|
||||
|
||||
## Plano de Correcao (Prioridade Decrescente)
|
||||
|
||||
1. **Renomear `src/proxy.js` para `src/middleware.js`** - Ativa a protecao de rotas por pagina
|
||||
2. **Adicionar `auth()` + verificacao de role em TODAS as 15 server actions criticas** - Usar o padrao de `requireRole("admin")` ou `requireAuth()` do `authorization.js`
|
||||
3. **Refazer `examActions.js`** - Todas as funcoes precisam de auth. Leitura de templates nao deve retornar `isCorrect`. Considerar consolidar com `examFlowActions.js` (que ja tem seguranca correta)
|
||||
4. **Adicionar verificacao de propriedade em `/api/orders/[id]`**
|
||||
5. **Proteger `/api/health`** com autenticacao admin ou remover dados sensiveis
|
||||
6. **Remover `/api/debug-token`** em producao
|
||||
7. **Remover headers de debug** do middleware
|
||||
8. **Adicionar headers de seguranca** no `next.config.mjs`
|
||||
9. **Sanitizar mensagens de erro** antes de enviar ao cliente
|
||||
10. **Remover `console.log`** com dados sensiveis
|
||||
11. **Reduzir `bodySizeLimit`** para algo mais razoavel (ex: 10MB)
|
||||
12. **Adicionar verificacao de membership em GET lessons**
|
||||
|
||||
---
|
||||
|
||||
## Comparativo: examActions.js vs examFlowActions.js
|
||||
|
||||
`examFlowActions.js` demonstra **praticas de seguranca corretas** (auth + role + class membership checks em todas as 6 funcoes), enquanto `examActions.js` (21 funcoes) tem **quase nenhuma autenticacao ou autorizacao**. Sugere-se que `examFlowActions.js` foi escrito depois como substituicao mais segura, mas as funcoes inseguras em `examActions.js` nunca foram removidas ou protegidas.
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
# Manual do Administrador
|
||||
|
||||
## Visão Geral
|
||||
|
||||
O painel de administrador permite gerenciar todos os aspectos da plataforma de cursos, incluindo usuários, turmas, provas, pagamentos e arquivos.
|
||||
|
||||
## Acesso
|
||||
|
||||
- **URL**: `/admin/dashboard`
|
||||
- **Role Necessária**: `admin`
|
||||
|
||||
---
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
### 1. Dashboard Principal
|
||||
**Rota**: `/admin/dashboard`
|
||||
|
||||
Página inicial com cards de navegação para todas as funcionalidades administrativas.
|
||||
|
||||
---
|
||||
|
||||
### 2. Gerenciamento de Tipos de Turma
|
||||
**Rota**: `/admin/dashboard/classTypes`
|
||||
|
||||
#### Funcionalidades
|
||||
- Criar novos tipos de turma (ex: "Starters", "Movers")
|
||||
- Definir faixa etária
|
||||
- Definir preço mensal
|
||||
- Gerenciar arquivos por tipo de turma
|
||||
|
||||
#### Como Testar
|
||||
1. Acesse `/admin/dashboard/classTypes`
|
||||
2. Clique em "Novo Tipo de Turma"
|
||||
3. Preencha nome, idade mínima, idade máxima e preço
|
||||
4. Salve
|
||||
5. **Esperado**: Tipo de turma aparece na lista
|
||||
|
||||
#### Campos
|
||||
- `name`: Nome do tipo de turma
|
||||
- `minAge`: Idade mínima
|
||||
- `maxAge`: Idade máxima
|
||||
- `price`: Preço mensal
|
||||
|
||||
---
|
||||
|
||||
### 3. Gerenciamento de Turmas
|
||||
**Rota**: `/admin/dashboard/class`
|
||||
|
||||
#### Funcionalidades
|
||||
- Criar novas turmas
|
||||
- Atribuir professores
|
||||
- Definir horário e dias da semana
|
||||
- Ativar/desativar turmas
|
||||
- Gerenciar alunos matriculados
|
||||
- Upload de materiais
|
||||
|
||||
#### Como Testar
|
||||
1. Acesse `/admin/dashboard/class`
|
||||
2. Clique em "Nova Turma"
|
||||
3. Selecione o tipo de turma
|
||||
4. Adicione professores (multi-select)
|
||||
5. Defina os dias da semana
|
||||
6. Salve
|
||||
7. **Esperado**: Turma criada e visível na lista
|
||||
|
||||
#### Campos
|
||||
- `classType`: Tipo de turma (obrigatório)
|
||||
- `teachers`: Professores (pode ter vários)
|
||||
- `schedule`: Horário das aulas
|
||||
- `days`: Dias da semana
|
||||
- `active`: Status ativo/inativo
|
||||
|
||||
---
|
||||
|
||||
### 4. Gerenciamento de Usuários
|
||||
**Rota**: `/admin/dashboard/users`
|
||||
|
||||
#### Funcionalidades
|
||||
- Visualizar todos os usuários
|
||||
- Editar informações de usuários
|
||||
- Gerenciar roles (admin, teacher, student, parent)
|
||||
- Vincular responsáveis a estudantes
|
||||
- Excluir usuários
|
||||
|
||||
#### Como Testar
|
||||
1. Acesse `/admin/dashboard/users`
|
||||
2. Clique no ícone de editar em um usuário
|
||||
3. Altere as roles marcando/desmarcando checkboxes
|
||||
4. Para vincular responsável, selecione na lista
|
||||
5. Salve
|
||||
6. **Esperado**: Roles atualizadas, vínculos criados
|
||||
|
||||
#### Roles Disponíveis
|
||||
- `admin`: Acesso completo ao painel administrativo
|
||||
- `teacher`: Acesso a turmas atribuídas
|
||||
- `student`: Acesso a turmas matriculadas
|
||||
- `parent`: Acesso a dados dos filhos vinculados
|
||||
|
||||
---
|
||||
|
||||
### 5. Gerenciamento de Pagamentos
|
||||
**Rotas**:
|
||||
- `/admin/dashboard/payments` - Visão geral
|
||||
- `/admin/dashboard/payments/by-class` - Por turma
|
||||
|
||||
#### Funcionalidades
|
||||
- Criar obrigações de pagamento para alunos
|
||||
- Aprovar/rejeitar pagamentos
|
||||
- Visualizar comprovantes
|
||||
- Estatísticas de pagamentos
|
||||
|
||||
#### Como Testar - Criar Obrigação
|
||||
1. Acesse `/admin/dashboard/payments`
|
||||
2. Clique em "Nova Obrigação"
|
||||
3. Selecione o aluno
|
||||
4. Digite o valor e data de vencimento
|
||||
5. Salve
|
||||
6. **Esperado**: Obrigação aparece na lista do aluno
|
||||
|
||||
#### Como Testar - Aprovar Pagamento
|
||||
1. Na lista de pagamentos, encontre um pendente
|
||||
2. Clique para ver detalhes
|
||||
3. Visualize o comprovante
|
||||
4. Clique em "Aprovar" ou "Rejeitar"
|
||||
5. **Esperado**: Status atualizado
|
||||
|
||||
#### Status de Pagamento
|
||||
- `pending`: Aguardando aprovação
|
||||
- `verified`: Pagamento aprovado
|
||||
- `rejected`: Pagamento rejeitado
|
||||
|
||||
---
|
||||
|
||||
### 6. Provas e Tarefas
|
||||
**Rotas**:
|
||||
- `/admin/dashboard/exams` - Gerenciar provas
|
||||
- `/admin/dashboard/exam-templates` - Templates de prova
|
||||
- `/admin/dashboard/assignments` - Atribuir provas a turmas
|
||||
- `/admin/dashboard/statistics/exams` - Estatísticas
|
||||
|
||||
#### Funcionalidades
|
||||
- Criar templates de provas reutilizáveis
|
||||
- Adicionar perguntas (múltipla escolha, texto)
|
||||
- Atribuir provas a turmas
|
||||
- Definir data/hora de início e fim
|
||||
- Configurar tentativas permitidas
|
||||
- Ver resultados e estatísticas
|
||||
|
||||
#### Como Testar - Criar Template
|
||||
1. Acesse `/admin/dashboard/exam-templates`
|
||||
2. Clique em "Novo Template"
|
||||
3. Digite título e descrição
|
||||
4. Adicione perguntas:
|
||||
- Pergunta de múltipla escolha: adicione opções e marque a correta
|
||||
- Pergunta de texto: apenas o enunciado
|
||||
5. Salve
|
||||
6. **Esperado**: Template criado
|
||||
|
||||
#### Como Testar - Atribuir Prova
|
||||
1. Acesse `/admin/dashboard/assignments`
|
||||
2. Clique em "Nova Atribuição"
|
||||
3. Selecione o template
|
||||
4. Selecione a turma
|
||||
5. Defina data/hora início e fim
|
||||
6. Configure tentativas permitidas
|
||||
7. Salve
|
||||
8. **Esperado**: Prova aparece para os alunos da turma
|
||||
|
||||
---
|
||||
|
||||
### 7. Categorias de Arquivos
|
||||
**Rota**: `/admin/dashboard/categories`
|
||||
|
||||
#### Funcionalidades
|
||||
- Criar categorias para organizar arquivos
|
||||
- Ex: "Material de Aula", "Lição de Casa", "Provas Antigas"
|
||||
|
||||
#### Como Testar
|
||||
1. Acesse `/admin/dashboard/categories`
|
||||
2. Clique em "Nova Categoria"
|
||||
3. Digite o nome
|
||||
4. Salve
|
||||
5. **Esperado**: Categoria disponível ao fazer upload de arquivos
|
||||
|
||||
---
|
||||
|
||||
### 8. Gerenciamento de Arquivos
|
||||
**Rotas**:
|
||||
- `/admin/dashboard/files` - Lista de arquivos
|
||||
- `/admin/dashboard/files/add` - Upload de arquivo
|
||||
- `/admin/dashboard/files/[id]` - Editar arquivo
|
||||
|
||||
#### Funcionalidades
|
||||
- Fazer upload de arquivos
|
||||
- Associar a turmas ou tipos de turma
|
||||
- Categorizar arquivos
|
||||
- Gerenciar permissões
|
||||
|
||||
#### Como Testar
|
||||
1. Acesse `/admin/dashboard/files/add`
|
||||
2. Digite o nome do arquivo
|
||||
3. Selecione o arquivo no computador
|
||||
4. Selecione a categoria
|
||||
5. Associe a uma turma ou tipo de turma (opcional)
|
||||
6. Salve
|
||||
7. **Esperado**: Arquivo disponível para download na turma
|
||||
|
||||
---
|
||||
|
||||
## Checklist de Testes
|
||||
|
||||
- [ ] Criar tipo de turma
|
||||
- [ ] Criar turma com professores
|
||||
- [ ] Criar usuário com role student
|
||||
- [ ] Criar usuário com role teacher
|
||||
- [ ] Vincular responsável a estudante
|
||||
- [ ] Matricular estudante em turma
|
||||
- [ ] Criar obrigação de pagamento
|
||||
- [ ] Aprovar pagamento
|
||||
- [ ] Criar template de prova
|
||||
- [ ] Atribuir prova a turma
|
||||
- [ ] Fazer upload de material para turma
|
||||
|
||||
---
|
||||
|
||||
## Possíveis Problemas
|
||||
|
||||
### Usuário não acessa o admin
|
||||
- **Verifique**: O usuário tem a role `admin`?
|
||||
- **Solução**: Edite o usuário em `/admin/dashboard/users`
|
||||
|
||||
### Turma não aparece para o professor
|
||||
- **Verifique**: O professor está atribuído à turma?
|
||||
- **Solução**: Edite a turma em `/admin/dashboard/class`
|
||||
|
||||
### Aluno não vê a prova
|
||||
- **Verifique**: Data/hora da atribuição está correta?
|
||||
- **Verifique**: Aluno está matriculado na turma?
|
||||
- **Solução**: Verifique a atribuição em `/admin/dashboard/assignments`
|
||||
@@ -0,0 +1,100 @@
|
||||
# Teste Manual - Link da Aula (Professor, Estudante e Responsavel)
|
||||
|
||||
## Objetivo
|
||||
|
||||
Validar o fluxo completo de cadastro e visualizacao do campo **Link da Aula**:
|
||||
- Professor registra aula com link
|
||||
- Estudante visualiza e acessa o link no historico
|
||||
- Responsavel visualiza e acessa o link no historico do estudante
|
||||
|
||||
## Pre-requisitos
|
||||
|
||||
- Aplicacao rodando localmente (ex: `http://localhost:3000`)
|
||||
- 1 usuario com role `teacher`
|
||||
- 1 usuario com role `student` matriculado na mesma turma do professor
|
||||
- 1 usuario com role `guardian` vinculado ao estudante
|
||||
- ID de turma valido (exemplo: `/dashboard/teacher/class/<classId>`)
|
||||
- URL de teste para usar como link da aula (ex: `https://example.com/aula-01`)
|
||||
|
||||
## Cenário 1 - Professor registra aula com link
|
||||
|
||||
1. Fazer login como professor.
|
||||
2. Acessar a turma: `/dashboard/teacher/class/<classId>`.
|
||||
3. Clicar em **Registrar Aula**.
|
||||
4. Preencher os campos obrigatorios:
|
||||
- Data da aula
|
||||
- Topico/Assunto
|
||||
- Presenca (qualquer combinacao valida)
|
||||
5. Preencher **Link da Aula** com URL valida (ex: `https://example.com/aula-01`).
|
||||
6. Clicar em **Salvar**.
|
||||
7. Acessar **Historico** da turma: `/dashboard/teacher/class/<classId>/history`.
|
||||
|
||||
### Resultado esperado
|
||||
|
||||
- Aula criada com sucesso.
|
||||
- No card da aula no historico deve aparecer o botao/link **Abrir link da aula**.
|
||||
- Ao clicar, o link abre em nova aba.
|
||||
|
||||
## Cenário 2 - Professor edita aula e atualiza/remove link
|
||||
|
||||
1. No historico da turma, abrir a aula registrada.
|
||||
2. Editar a aula (fluxo de edicao ja existente).
|
||||
3. Alterar o valor de **Link da Aula** para outra URL valida.
|
||||
4. Salvar e validar no historico.
|
||||
5. Editar novamente e limpar o campo **Link da Aula**.
|
||||
6. Salvar.
|
||||
|
||||
### Resultado esperado
|
||||
|
||||
- Ao atualizar, o novo link passa a ser exibido no historico.
|
||||
- Ao remover (campo vazio), o botao/link **Abrir link da aula** nao deve mais aparecer.
|
||||
|
||||
## Cenário 3 - Estudante visualiza link no historico
|
||||
|
||||
1. Fazer logout do professor e login como estudante da mesma turma.
|
||||
2. Acessar: `/dashboard/student/class/<classId>/history`.
|
||||
3. Localizar a aula registrada no Cenario 1.
|
||||
4. Clicar em **Abrir link da aula**.
|
||||
|
||||
### Resultado esperado
|
||||
|
||||
- O link da aula aparece no card quando cadastrado pelo professor.
|
||||
- O clique abre a URL em nova aba.
|
||||
- Se o professor removeu o link (Cenario 2), o estudante nao deve ver o botao/link.
|
||||
|
||||
## Cenário 4 - Responsavel visualiza link no historico do estudante
|
||||
|
||||
1. Fazer logout do estudante e login como responsavel vinculado.
|
||||
2. Acessar estudante e turma:
|
||||
- `/dashboard/guardian/<studentId>/class/<classId>/history`
|
||||
3. Localizar a aula registrada.
|
||||
4. Clicar em **Abrir link da aula**.
|
||||
|
||||
### Resultado esperado
|
||||
|
||||
- O responsavel ve o mesmo link da aula registrado pelo professor.
|
||||
- O clique abre a URL em nova aba.
|
||||
- Se o link foi removido, o responsavel nao deve ver o botao/link.
|
||||
|
||||
## Cenário 5 - Validacao de URL invalida no formulario
|
||||
|
||||
1. Login como professor.
|
||||
2. Abrir **Registrar Aula**.
|
||||
3. Preencher `Link da Aula` com valor invalido (ex: `meu-link`).
|
||||
4. Tentar salvar.
|
||||
|
||||
### Resultado esperado
|
||||
|
||||
- O navegador deve bloquear o envio por ser `type="url"` (ou indicar formato invalido).
|
||||
- Aula nao deve ser salva ate corrigir para URL valida.
|
||||
|
||||
## Checklist rapido
|
||||
|
||||
- [ ] Professor consegue criar aula com link
|
||||
- [ ] Link aparece no historico do professor
|
||||
- [ ] Professor consegue atualizar link
|
||||
- [ ] Professor consegue remover link
|
||||
- [ ] Estudante visualiza link quando existir
|
||||
- [ ] Responsavel visualiza link quando existir
|
||||
- [ ] Link abre em nova aba para os 3 perfis
|
||||
- [ ] URL invalida nao permite envio do formulario
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
# Manual do Responsável
|
||||
|
||||
## Visão Geral
|
||||
|
||||
O painel do responsável permite gerenciar os estudantes vinculados (filhos), acompanhar seu progresso, acessar materiais e gerenciar pagamentos.
|
||||
|
||||
## Acesso
|
||||
|
||||
- **URL**: `/dashboard/guardian`
|
||||
- **Role Necessária**: `parent` ou `guardian`
|
||||
|
||||
---
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
### 1. Dashboard Principal
|
||||
**Rota**: `/dashboard/guardian`
|
||||
|
||||
Visão geral de todos os estudantes vinculados.
|
||||
|
||||
#### Informações Exibidas
|
||||
- Número total de estudantes
|
||||
- Turmas ativas
|
||||
- Total de turmas
|
||||
|
||||
#### Lista de Estudantes
|
||||
Cards com:
|
||||
- Nome do estudante
|
||||
- Nome de usuário
|
||||
- Número de turmas
|
||||
- Link para dashboard individual
|
||||
|
||||
#### Como Testar
|
||||
1. Faça login como responsável
|
||||
2. Acesse `/dashboard/guardian`
|
||||
3. **Esperado**: Ver todos os estudantes vinculados
|
||||
|
||||
---
|
||||
|
||||
### 2. Dashboard Individual do Estudante
|
||||
**Rota**: `/dashboard/guardian/[id]`
|
||||
|
||||
Página dedicada a um estudante específico.
|
||||
|
||||
#### Informações do Estudante
|
||||
- Nome completo
|
||||
- Nome de usuário
|
||||
- Email
|
||||
- Data de nascimento
|
||||
|
||||
#### Turmas Ativas
|
||||
Cards das turmas em que o estudante está matriculado:
|
||||
- Nome da turma
|
||||
- Professores
|
||||
- Horário
|
||||
- Status
|
||||
|
||||
#### Turmas Arquivadas
|
||||
Turmas inativas ou concluídas.
|
||||
|
||||
#### Como Testar
|
||||
1. No dashboard principal, clique em um estudante
|
||||
2. **Esperado**: Ver informações completas do estudante e suas turmas
|
||||
|
||||
---
|
||||
|
||||
### 3. Detalhes da Turma do Estudante
|
||||
**Rota**: `/dashboard/guardian/[id]/class/[classId]`
|
||||
|
||||
Visão detalhada de uma turma específica do estudante.
|
||||
|
||||
#### Informações da Turma
|
||||
- Nome
|
||||
- Professores
|
||||
- Horário e dias
|
||||
- Status
|
||||
|
||||
#### Estatísticas do Estudante
|
||||
- Taxa de presença (%)
|
||||
- Total de aulas
|
||||
- Presentes, atrasados, ausentes
|
||||
|
||||
#### Materiais Disponíveis
|
||||
Arquivos organizados por categoria para download.
|
||||
|
||||
#### Como Testar - Acessar Turma
|
||||
1. No dashboard do estudante, clique em uma turma
|
||||
2. **Esperado**: Ver detalhes da turma e materiais
|
||||
|
||||
#### Como Testar - Baixar Material
|
||||
1. Na seção "Materiais", clique em um arquivo
|
||||
2. **Esperado**: Download iniciado
|
||||
|
||||
---
|
||||
|
||||
### 4. Histórico de Aulas
|
||||
**Rota**: `/dashboard/guardian/[id]/class/[classId]/history`
|
||||
|
||||
Histórico de presença do estudante em uma turma.
|
||||
|
||||
#### Informações por Aula
|
||||
- Data
|
||||
- Conteúdo/tema
|
||||
- Professor
|
||||
- Status de presença do estudante
|
||||
|
||||
#### Status de Presença
|
||||
- `Presente`: Estudante presente
|
||||
- `Atrasado`: Estudante chegou atrasado
|
||||
- `Ausente`: Estudante faltou
|
||||
- `Justificado`: Falta justificada
|
||||
|
||||
#### Como Testar
|
||||
1. Acesse uma turma do estudante
|
||||
2. Clique em "Histórico"
|
||||
3. **Esperado**: Ver todas as aulas com presença do estudante
|
||||
|
||||
---
|
||||
|
||||
### 5. Registrar Novo Estudante
|
||||
**Rota**: `/dashboard/guardian/register-student`
|
||||
|
||||
Formulário para cadastrar um novo estudante vinculado ao responsável.
|
||||
|
||||
#### Campos Obrigatórios
|
||||
- Nome completo
|
||||
- Nome de usuário
|
||||
- Data de nascimento
|
||||
- Senha
|
||||
|
||||
#### Regras
|
||||
- Estudante deve ter menos de 18 anos
|
||||
- Senha deve ter no mínimo 8 caracteres
|
||||
- Estudante é automaticamente vinculado ao responsável
|
||||
|
||||
#### Como Testar
|
||||
1. Acesse `/dashboard/guardian`
|
||||
2. Clique em "Registrar Novo Estudante"
|
||||
3. Preencha todos os campos
|
||||
4. Clique em "Registrar"
|
||||
5. **Esperado**: Estudante criado e vinculado automaticamente
|
||||
|
||||
---
|
||||
|
||||
### 6. Gerenciamento de Pagamentos
|
||||
**Rota**: `/dashboard/guardian/payments`
|
||||
|
||||
Gerenciar pagamentos de todos os estudantes vinculados.
|
||||
|
||||
#### Funcionalidades
|
||||
- Ver obrigações de todos os estudantes
|
||||
- Filtrar por estudante específico
|
||||
- Registrar pagamentos
|
||||
- Fazer upload de comprovantes
|
||||
- Ver histórico completo
|
||||
- Visualizar comprovantes
|
||||
|
||||
#### Como Testar - Registrar Pagamento
|
||||
1. Acesse `/dashboard/guardian/payments`
|
||||
2. Na seção "Obrigações", encontre uma pendente
|
||||
3. Clique para registrar pagamento
|
||||
4. Preencha:
|
||||
- Valor (preenchido automaticamente da obrigação)
|
||||
- Data do pagamento
|
||||
- Método (PIX, transferência, dinheiro, cartão)
|
||||
- Nome do pagador (opcional)
|
||||
- Upload de comprovante (opcional)
|
||||
- Observações (opcional)
|
||||
5. Salve
|
||||
6. **Esperado**: Pagamento registrado com status "pendente"
|
||||
|
||||
#### Abas de Navegação
|
||||
- **Todos**: Ver pagamentos de todos os estudantes
|
||||
- **Por Estudante**: Filtrar por estudante específico
|
||||
|
||||
#### Status de Pagamento
|
||||
- `pending`: Aguardando aprovação
|
||||
- `verified`: Pagamento aprovado
|
||||
- `rejected`: Pagamento rejeitado
|
||||
|
||||
#### Métodos de Pagamento
|
||||
- `pix`: PIX
|
||||
- `transferência`: Transferência bancária
|
||||
- `dinheiro`: Dinheiro
|
||||
- `cartão_crédito`: Cartão de crédito
|
||||
- `cartão_débito`: Cartão de débito
|
||||
|
||||
---
|
||||
|
||||
### 7. Registro de Pagamento Específico
|
||||
**Rota**: `/dashboard/guardian/payments/register`
|
||||
|
||||
Formulário para registrar um pagamento a partir de uma obrigação específica.
|
||||
|
||||
#### Diferença para a Página Principal
|
||||
- Campo de valor já vem preenchido
|
||||
- Estudante e turma já estão selecionados
|
||||
- Fluxo mais rápido para pagamentos individuais
|
||||
|
||||
#### Como Testar
|
||||
1. A partir de uma obrigação, clique em registrar
|
||||
2. Preencha os campos restantes
|
||||
3. Salve
|
||||
4. **Esperado**: Pagamento registrado
|
||||
|
||||
---
|
||||
|
||||
## Fluxo de Uso Típico
|
||||
|
||||
### Acompanhamento Diário
|
||||
1. Acesse `/dashboard/guardian`
|
||||
2. Clique em um estudante
|
||||
3. Verifique as turmas e atividades recentes
|
||||
|
||||
### Acompanhamento de Pagamentos
|
||||
1. Acesse `/dashboard/guardian/payments`
|
||||
2. Verifique obrigações pendentes
|
||||
3. Registre os pagamentos feitos
|
||||
4. Aguarde aprovação
|
||||
|
||||
### Acompanhamento Acadêmico
|
||||
1. Acesse um estudante
|
||||
2. Entre em uma turma
|
||||
3. Baixe os materiais para acompanhar
|
||||
4. Verifique o histórico de presença
|
||||
|
||||
---
|
||||
|
||||
## Checklist de Testes
|
||||
|
||||
- [ ] Ver lista de estudantes vinculados
|
||||
- [ ] Acessar dashboard de um estudante
|
||||
- [ ] Ver turmas de um estudante
|
||||
- [ ] Acessar detalhes de uma turma
|
||||
- [ ] Baixar material da turma
|
||||
- [ ] Ver histórico de presença
|
||||
- [ ] Registrar novo estudante
|
||||
- [ ] Registrar pagamento para um estudante
|
||||
- [ ] Ver histórico de pagamentos
|
||||
|
||||
---
|
||||
|
||||
## Possíveis Problemas
|
||||
|
||||
### Não vejo um estudante
|
||||
- **Verifique**: O estudante está vinculado à sua conta?
|
||||
- **Solução**: Entre em contato com o admin
|
||||
|
||||
### Não consigo registrar estudante
|
||||
- **Verifique**: A data de nascimento indica menor de 18 anos?
|
||||
- **Solução**: Estudantes maiores de idade devem se cadastrar sozinhos
|
||||
|
||||
### Pagamento não é aprovado
|
||||
- **Verifique**: O comprovante foi anexado corretamente?
|
||||
- **Solução**: Entre em contato com o admin
|
||||
|
||||
### Não vejo todas as turmas do estudante
|
||||
- **Possível causa**: Algumas turmas podem estar arquivadas
|
||||
- **Solução**: Verifique na seção "Turmas Arquivadas"
|
||||
|
||||
---
|
||||
|
||||
## Relação Responsável-Estudante
|
||||
|
||||
### Como Funciona o Vínculo
|
||||
- Estudantes têm um campo `guardiansAccounts` com IDs dos responsáveis
|
||||
- Responsáveis têm um campo `wardAccounts` com IDs dos estudantes
|
||||
- A relação é bidirecional
|
||||
|
||||
### Criando Vínculo (Admin)
|
||||
1. Acesse `/admin/dashboard/users`
|
||||
2. Edite o estudante
|
||||
3. Selecione o responsável na lista
|
||||
4. Salve
|
||||
|
||||
### Criando Vínculo (Auto-registro)
|
||||
- Ao registrar um estudante pelo dashboard do responsável, o vínculo é automático
|
||||
- Isso só funciona para menores de 18 anos
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
# Manual do Estudante
|
||||
|
||||
## Visão Geral
|
||||
|
||||
O painel do estudante permite visualizar turmas, acessar materiais, fazer provas, registrar presença e gerenciar pagamentos.
|
||||
|
||||
## Acesso
|
||||
|
||||
- **URL**: `/dashboard/student`
|
||||
- **Role Necessária**: `student`
|
||||
|
||||
---
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
### 1. Dashboard Principal
|
||||
**Rota**: `/dashboard/student`
|
||||
|
||||
Lista de todas as turmas matriculadas com cards mostrando:
|
||||
- Nome da turma
|
||||
- Professores
|
||||
- Horário
|
||||
- Status
|
||||
|
||||
#### Como Testar
|
||||
1. Faça login como estudante
|
||||
2. Acesse `/dashboard/student`
|
||||
3. **Esperado**: Ver todas as turmas matriculadas
|
||||
|
||||
---
|
||||
|
||||
### 2. Detalhes da Turma
|
||||
**Rota**: `/dashboard/student/class/[id]`
|
||||
|
||||
Página principal de uma turma específica.
|
||||
|
||||
#### Informações Exibidas
|
||||
- Nome da turma
|
||||
- Professores
|
||||
- Horário e dias
|
||||
- Status
|
||||
|
||||
#### Estatísticas
|
||||
- Taxa de presença (%)
|
||||
- Total de aulas
|
||||
- Presentes, atrasados, ausentes
|
||||
|
||||
#### Materiais Disponíveis
|
||||
Arquivos organizados por categoria com opção de download.
|
||||
|
||||
#### Provas Disponíveis
|
||||
Lista de provas para fazer com:
|
||||
- Data limite
|
||||
- Tempo disponível
|
||||
- Botão para iniciar
|
||||
|
||||
#### Feed de Atividades Recentes
|
||||
Mostra as últimas atividades da turma.
|
||||
|
||||
#### Como Testar - Acessar Turma
|
||||
1. No dashboard, clique em uma turma
|
||||
2. **Esperado**: Ver detalhes completos da turma
|
||||
|
||||
#### Como Testar - Baixar Material
|
||||
1. Acesse uma turma
|
||||
2. Na seção "Materiais", clique em um arquivo
|
||||
3. **Esperado**: Download do arquivo iniciado
|
||||
|
||||
---
|
||||
|
||||
### 3. Histórico da Turma
|
||||
**Rota**: `/dashboard/student/class/[id]/history`
|
||||
|
||||
Lista de todas as aulas com:
|
||||
- Data
|
||||
- Conteúdo/tema
|
||||
- Professor
|
||||
- Status de presença do aluno
|
||||
|
||||
#### Status de Presença
|
||||
- `Presente`: Aluno presente
|
||||
- `Atrasado`: Aluno chegou atrasado
|
||||
- `Ausente`: Aluno faltou
|
||||
- `Justificado`: Falta justificada
|
||||
|
||||
#### Como Testar
|
||||
1. Acesse uma turma
|
||||
2. Clique em "Histórico"
|
||||
3. **Esperado**: Ver todas as aulas com sua presença
|
||||
|
||||
---
|
||||
|
||||
### 4. Fazendo Provas
|
||||
**Rota**: `/dashboard/student/assignments/[id]/take`
|
||||
|
||||
Interface para fazer provas online.
|
||||
|
||||
#### Funcionalidades
|
||||
- Timer com contagem regressiva
|
||||
- Perguntas de múltipla escolha e texto
|
||||
- Barra de progresso
|
||||
- Salvar automaticamente
|
||||
- Entender automaticamente ao acabar o tempo
|
||||
|
||||
#### Como Testar
|
||||
1. Acesse uma turma
|
||||
2. Na seção "Provas Disponíveis", clique em "Iniciar"
|
||||
3. Responda às perguntas
|
||||
4. Clique em "Entregar" ou aguarde o tempo acabar
|
||||
5. **Esperado**: Prova entregue, redirecionado para resultados
|
||||
|
||||
#### Importante
|
||||
- O timer não para se você sair da página
|
||||
- Após o tempo acabar, a prova é entregue automaticamente
|
||||
- Você pode ver suas respostas antes de entregar
|
||||
|
||||
---
|
||||
|
||||
### 5. Resultados de Provas
|
||||
**Rota**: `/dashboard/student/assignments/[id]/results`
|
||||
|
||||
Ver resultados das provas feitas.
|
||||
|
||||
#### Informações Exibidas
|
||||
- Nota final
|
||||
- Tempo gasto
|
||||
- Tentativas anteriores
|
||||
- Feedback por pergunta (se disponível)
|
||||
- Opção de tentar novamente (se permitido)
|
||||
|
||||
#### Como Testar
|
||||
1. Após fazer uma prova, você será redirecionado para resultados
|
||||
2. Ou acesse através da turma > Provas
|
||||
3. **Esperado**: Ver nota, respostas e feedback
|
||||
|
||||
---
|
||||
|
||||
### 6. Pagamentos
|
||||
**Rota**: `/dashboard/student/payments`
|
||||
|
||||
Gerenciar obrigações de pagamento e histórico.
|
||||
|
||||
#### Funcionalidades
|
||||
- Ver obrigações pendentes
|
||||
- Registrar novos pagamentos
|
||||
- Ver histórico de pagamentos
|
||||
- Fazer upload de comprovantes
|
||||
- Ver status (pendente, aprovado, rejeitado)
|
||||
|
||||
#### Como Testar - Registrar Pagamento
|
||||
1. Acesse `/dashboard/student/payments`
|
||||
2. Na seção "Obrigações", clique em uma pendente
|
||||
3. Preencha:
|
||||
- Data do pagamento
|
||||
- Método (PIX, transferência, dinheiro, cartão)
|
||||
- Nome do pagador (opcional)
|
||||
- Upload de comprovante (opcional)
|
||||
4. Salve
|
||||
5. **Esperado**: Pagamento registrado com status "pendente"
|
||||
|
||||
#### Métodos de Pagamento
|
||||
- `pix`: PIX
|
||||
- `transferência`: Transferência bancária
|
||||
- `dinheiro`: Dinheiro
|
||||
- `cartão_crédito`: Cartão de crédito
|
||||
- `cartão_débito`: Cartão de débito
|
||||
|
||||
#### Status
|
||||
- `pending`: Aguardando aprovação
|
||||
- `verified`: Pagamento aprovado
|
||||
- `rejected`: Pagamento rejeitado
|
||||
|
||||
---
|
||||
|
||||
### 7. Perfil
|
||||
**Rota**: `/profile`
|
||||
|
||||
Editar informações pessoais.
|
||||
|
||||
#### Informações Editáveis
|
||||
- Nome completo
|
||||
- Nome de usuário
|
||||
- Email
|
||||
- Data de nascimento
|
||||
|
||||
#### Informações Somente Leitura
|
||||
- Roles (funções)
|
||||
- Responsáveis (se menor de idade)
|
||||
- Estudantes sob responsabilidade (se responsável)
|
||||
|
||||
#### Como Testar
|
||||
1. Acesse `/profile`
|
||||
2. Altere qualquer campo permitido
|
||||
3. Clique em "Salvar Alterações"
|
||||
4. **Esperado**: Dados atualizados, mensagem de sucesso
|
||||
|
||||
#### Restrições para Menores de Idade
|
||||
- Estudantes menores de 18 não podem remover responsáveis existentes
|
||||
- Podem apenas ver quais responsáveis estão vinculados
|
||||
|
||||
---
|
||||
|
||||
## Fluxo de Uso Típico
|
||||
|
||||
### Dia a Dia
|
||||
1. **Antes da aula**: Acesse a turma e baixe o material
|
||||
2. **Durante o curso**: Faça as provas disponíveis antes da data limite
|
||||
3. **Após provas**: Acompanhe seus resultados
|
||||
|
||||
### Mensalmente
|
||||
1. Acesse `/dashboard/student/payments`
|
||||
2. Verifique obrigações pendentes
|
||||
3. Registre os pagamentos feitos
|
||||
4. Aguarde aprovação
|
||||
|
||||
---
|
||||
|
||||
## Checklist de Testes
|
||||
|
||||
- [ ] Ver lista de turmas matriculadas
|
||||
- [ ] Acessar detalhes de uma turma
|
||||
- [ ] Baixar material da turma
|
||||
- [ ] Ver histórico de presença
|
||||
- [ ] Fazer uma prova
|
||||
- [ ] Ver resultados de prova
|
||||
- [ ] Registrar um pagamento
|
||||
- [ ] Editar perfil
|
||||
|
||||
---
|
||||
|
||||
## Possíveis Problemas
|
||||
|
||||
### Não vejo uma turma
|
||||
- **Verifique**: Você está matriculado nessa turma?
|
||||
- **Solução**: Entre em contato com o admin
|
||||
|
||||
### Prova não aparece
|
||||
- **Verifique**: A data/hora da prova está dentro do período?
|
||||
- **Verifique**: Você já tentou o número máximo de vezes?
|
||||
- **Solução**: Verifique com o professor
|
||||
|
||||
### Pagamento não é aprovado
|
||||
- **Verifique**: O comprovante foi anexado corretamente?
|
||||
- **Solução**: Entre em contato com o admin
|
||||
|
||||
### Não consigo editar responsável
|
||||
- **Possível causa**: Você tem 18 anos ou mais
|
||||
- **Solução**: Adultos não podem ter novos responsáveis, apenas remover existentes
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
# Manual do Professor
|
||||
|
||||
## Visão Geral
|
||||
|
||||
O painel do professor permite gerenciar turmas, fazer upload de materiais, registrar presença e criar/corrigir provas.
|
||||
|
||||
## Acesso
|
||||
|
||||
- **URL**: `/dashboard/teacher`
|
||||
- **Role Necessária**: `teacher`
|
||||
|
||||
---
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
### 1. Dashboard Principal
|
||||
**Rota**: `/dashboard/teacher`
|
||||
|
||||
Lista de todas as turmas atribuídas ao professor com cards mostrando:
|
||||
- Nome da turma
|
||||
- Número de alunos
|
||||
- Horário
|
||||
|
||||
#### Como Testar
|
||||
1. Faça login como professor
|
||||
2. Acesse `/dashboard/teacher`
|
||||
3. **Esperado**: Ver todas as turmas atribuídas
|
||||
|
||||
---
|
||||
|
||||
### 2. Detalhes da Turma
|
||||
**Rota**: `/dashboard/teacher/class/[id]`
|
||||
|
||||
Página principal de gerenciamento de uma turma específica.
|
||||
|
||||
#### Informações Exibidas
|
||||
- Nome da turma
|
||||
- Professores atribuídos
|
||||
- Horário e dias
|
||||
- Status (ativo/inativo)
|
||||
- Estatísticas: número de alunos, taxa de presença, média das notas
|
||||
|
||||
#### Lista de Alunos
|
||||
Tabela com:
|
||||
- Nome do aluno
|
||||
- Taxa de presença
|
||||
- Média das provas
|
||||
- Status
|
||||
|
||||
#### Ações Disponíveis
|
||||
- **Upload de Materiais**: Adicionar arquivos para a turma
|
||||
- **Registrar Aula**: Registrar presença e conteúdo da aula
|
||||
- **Ver Histórico**: Ver todas as aulas anteriores
|
||||
- **Copiar Link**: Copiar link da aula virtual
|
||||
|
||||
#### Como Testar - Upload de Material
|
||||
1. Acesse uma turma
|
||||
2. Clique em "Upload de Materiais"
|
||||
3. Digite o nome do arquivo
|
||||
4. Selecione o arquivo no computador
|
||||
5. Selecione a categoria
|
||||
6. Salve
|
||||
7. **Esperado**: Arquivo aparece na seção de materiais
|
||||
|
||||
#### Como Testar - Registrar Aula
|
||||
1. Acesse uma turma
|
||||
2. Clique em "Registrar Aula"
|
||||
3. Digite o tema/conteúdo da aula
|
||||
4. Selecione a data
|
||||
5. Marque a presença dos alunos (Presente, Atrasado, Ausente, Justificado)
|
||||
6. Salve
|
||||
7. **Esperado**: Aula registrada no histórico
|
||||
|
||||
---
|
||||
|
||||
### 3. Histórico da Turma
|
||||
**Rota**: `/dashboard/teacher/class/[id]/history`
|
||||
|
||||
Lista de todas as aulas registradas com:
|
||||
- Data da aula
|
||||
- Conteúdo/tema
|
||||
- Professor que registrou
|
||||
- Resumo de presença
|
||||
|
||||
#### Como Testar
|
||||
1. Acesse uma turma
|
||||
2. Clique em "Ver Histórico"
|
||||
3. **Esperado**: Ver todas as aulas com presença registrada
|
||||
|
||||
---
|
||||
|
||||
### 4. Templates de Provas
|
||||
**Rota**: `/dashboard/teacher/exam-templates`
|
||||
|
||||
Gerenciar templates de provas reutilizáveis.
|
||||
|
||||
#### Funcionalidades
|
||||
- Criar novo template
|
||||
- Editar templates existentes
|
||||
- Adicionar perguntas (múltipla escolha ou texto)
|
||||
- Definir como público ou privado
|
||||
|
||||
#### Como Testar - Criar Template
|
||||
1. Acesse `/dashboard/teacher/exam-templates`
|
||||
2. Clique em "Novo Template"
|
||||
3. Digite título e descrição
|
||||
4. Clique em "Adicionar Pergunta"
|
||||
5. Selecione o tipo:
|
||||
- **Múltipla Escolha**: Digite a pergunta, adicione opções, marque a correta
|
||||
- **Texto**: Digite apenas a pergunta
|
||||
6. Salve
|
||||
7. **Esperado**: Template criado e disponível para atribuir
|
||||
|
||||
---
|
||||
|
||||
### 5. Resultados de Provas
|
||||
**Rota**: `/dashboard/teacher/assignments/[id]/results`
|
||||
|
||||
Ver e corrigir provas dos alunos.
|
||||
|
||||
#### Funcionalidades
|
||||
- Ver tentativas dos alunos
|
||||
- Corrigir provas manualmente
|
||||
- Ver notas automáticas (para múltipla escolha)
|
||||
- Acompanhar status de correção
|
||||
|
||||
#### Status de Correção
|
||||
- `pending`: Aguardando correção
|
||||
- `auto-graded`: Corrigido automaticamente
|
||||
- `partially-graded`: Parcialmente corrigido
|
||||
- `fully-graded`: Totalmente corrigido
|
||||
|
||||
#### Como Testar
|
||||
1. Acesse os resultados de uma atribuição
|
||||
2. Clique em uma tentativa de aluno
|
||||
3. Para perguntas de texto, digite a nota e comentários
|
||||
4. Salve
|
||||
5. **Esperado**: Nota atribuída, status atualizado
|
||||
|
||||
---
|
||||
|
||||
## Fluxo de Trabalho Típico
|
||||
|
||||
### Preparar uma Turma
|
||||
1. Acesse a turma
|
||||
2. Faça upload dos materiais da primeira aula
|
||||
3. Verifique a lista de alunos
|
||||
|
||||
### Durante o Curso
|
||||
1. **Antes de cada aula**: Faça upload do material
|
||||
2. **Após cada aula**: Registre a presença e o conteúdo
|
||||
3. **Periodicamente**: Crie e atribua provas
|
||||
|
||||
### Criar uma Prova
|
||||
1. Crie o template em `/dashboard/teacher/exam-templates`
|
||||
2. Atribua à turma (isso pode precisar de intervenção do admin)
|
||||
3. Acompanhe os resultados em `/dashboard/teacher/assignments/[id]/results`
|
||||
|
||||
---
|
||||
|
||||
## Checklist de Testes
|
||||
|
||||
- [ ] Ver lista de turmas atribuídas
|
||||
- [ ] Acessar detalhes de uma turma
|
||||
- [ ] Fazer upload de material
|
||||
- [ ] Registrar uma aula com presença
|
||||
- [ ] Ver histórico de aulas
|
||||
- [ ] Criar template de prova
|
||||
- [ ] Ver resultados de prova
|
||||
- [ ] Corrigir prova manualmente
|
||||
|
||||
---
|
||||
|
||||
## Possíveis Problemas
|
||||
|
||||
### Não consigo ver uma turma
|
||||
- **Verifique**: Você está atribuído a essa turma?
|
||||
- **Solução**: Entre em contato com o admin
|
||||
|
||||
### Não consigo atribuir prova à turma
|
||||
- **Possível causa**: Essa funcionalidade pode estar apenas no admin
|
||||
- **Solução**: Peça ao admin para atribuir a prova
|
||||
|
||||
### Material não aparece para os alunos
|
||||
- **Verifique**: O upload foi concluído com sucesso?
|
||||
- **Verifique**: A turma está correta?
|
||||
- **Solução**: Tente fazer upload novamente
|
||||
@@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "iciv",
|
||||
script: ".next/standalone/server.js",
|
||||
instances: 4,
|
||||
exec_mode: "cluster",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
NEXTAUTH_SECRET: "ARcy7zqFmEXBStnMf6T8uVWyvHfKcFdfCgjVzGjFCZo=",
|
||||
MONGODB_URI: "mongodb://localhost:27017/course-plat",
|
||||
STORAGE_TYPE: "local",
|
||||
PORT: 3000,
|
||||
NEXTAUTH_URL: "https://inglescomideiasvivas.com.br",
|
||||
},
|
||||
error_file: "/var/log/iciv/err.log",
|
||||
out_file: "/var/log/iciv/out.log",
|
||||
merge_logs: true,
|
||||
time: true,
|
||||
max_memory_restart: "700M",
|
||||
kill_timeout: 5000,
|
||||
listen_timeout: 5000,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [...compat.extends("next/core-web-vitals")];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/auth": ["./src/app/lib/utils/auth.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '500mb',
|
||||
},
|
||||
},
|
||||
// --- ADICIONE ESTA SEÇÃO ---
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'inglescomideiasvivas.com.br',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
// ---------------------------
|
||||
outputFileTracingRoot: __dirname,
|
||||
output: process.env.NODE_ENV === 'production' ? 'standalone' : undefined,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+8081
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "course-plat",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
|
||||
"start": "node .next/standalone/server.js",
|
||||
"lint": "next lint",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:debug": "playwright test --debug",
|
||||
"test:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.665.0",
|
||||
"@headlessui/react": "^2.2.7",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@next/env": "^16.0.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"mongoose": "^8.16.0",
|
||||
"next": "^16.0.0",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-select": "^5.10.2",
|
||||
"zod": "^4.0.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^16.0.0",
|
||||
"tailwindcss": "^4"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "^5.0.9",
|
||||
"postcss": "^8.5.18",
|
||||
"sharp": "^0.35.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
# Plano de Responsividade Mobile + Reutilizacao de Codigo
|
||||
|
||||
## Breakpoints alvo
|
||||
|
||||
| Nome | Largura | Dispositivo |
|
||||
|-------|---------|--------------------------------|
|
||||
| xs | < 640px | iPhone SE, pequenos |
|
||||
| sm | 640px+ | Smartphones grandes |
|
||||
| md | 768px+ | Tablets retrato |
|
||||
| lg | 1024px+ | Tablets paisagem / laptops |
|
||||
|
||||
---
|
||||
|
||||
## Fase 0: Extrair componentes base (reutilizacao)
|
||||
|
||||
Antes de aplicar responsividade, consolidar codigo duplicado.
|
||||
|
||||
### 0.1 Componentes novos a criar
|
||||
|
||||
| Componente | Substitui | Arquivos afetados |
|
||||
|----------------------|------------------------------------------------|-------------------|
|
||||
| `Modal` | 11 copias do overlay `fixed inset-0 bg-black/50` | 11 arquivos |
|
||||
| `TableWrapper` | 9 wrappers identicos de tabela admin | 9 arquivos |
|
||||
| `MaterialsList` | Listagem de materiais nos 3 ClassDetail | 3 arquivos |
|
||||
| `ClassHistoryList` | 3 versoes (teacher/student/guardian) | 3 arquivos |
|
||||
| `PaymentForm` unificado | 2 PaymentForms 98% identicos | 2 arquivos |
|
||||
| `FormField` | Padrao label+input+helper repetido em 4 forms | 4 arquivos |
|
||||
| `Spinner` | SVG de loading copiado em 2+ arquivos | 2+ arquivos |
|
||||
|
||||
### 0.2 Componentes existentes nao utilizados
|
||||
|
||||
| Componente existente | Redefinido inline em | Acao |
|
||||
|---------------------------------------------------|-------------------------------------|---------------------------|
|
||||
| `Card` (`dashboard/components/Card.jsx`) | 20 ocorrencias em 10 arquivos | Importar em vez de inline |
|
||||
| `Stat` (`dashboard/components/Stat.jsx`) | Redefinido localmente em 2 arquivos | Importar |
|
||||
| `Row` (`dashboard/components/Row.jsx`) | Redefinido em `StudentClassDetail` | Importar |
|
||||
| `Button` (`shared/Button.jsx`) | Redefinido em `StudentClassDetail` | Importar |
|
||||
| `DashboardLayout` | Existe mas zero paginas usam | Adotar ou remover |
|
||||
| `dateUtils.js` | `formatDateBR` redefinido em 12 arquivos (22 instancias) | Importar |
|
||||
|
||||
### 0.3 Funcoes utilitarias a centralizar
|
||||
|
||||
| Funcao | Onde centralizar | Redefinida em |
|
||||
|--------------------|------------------------------------|---------------------|
|
||||
| `formatDateBR` | `@/app/lib/utils/dateUtils.js` | 12 arquivos |
|
||||
| `formatTime` | `@/app/lib/utils/dateUtils.js` | 2 ExamResults |
|
||||
| `normalizeStatus` | `@/app/lib/utils/statusPatterns.js`| 5 arquivos |
|
||||
|
||||
---
|
||||
|
||||
## Fase 1: Layout responsivo global
|
||||
|
||||
### 1.1 Admin Sidebar -- Drawer off-canvas
|
||||
|
||||
**Arquivos**: `SideBarNav.jsx:7`, `admin/layout.jsx:22-28`
|
||||
|
||||
**Problema**: Sidebar fixa `w-64` (256px) visivel em todas as telas. Em celulares de 375px, sobram ~119px para conteudo.
|
||||
|
||||
**Solucao**:
|
||||
- Criar `MobileSidebarToggle` (hamburger button) visivel apenas em `md:hidden`
|
||||
- Sidebar: `hidden md:flex` + state para abrir/fechar no mobile com overlay
|
||||
- Usar `@headlessui/react` (ja no projeto) para o Dialog/Drawer
|
||||
- Drawer aberto: largura `w-72` + overlay escuro
|
||||
- Animação de slide-in/slide-out
|
||||
|
||||
### 1.2 Page container padronizado
|
||||
|
||||
**Problema**: 14+ paginas com container `max-w-6xl mx-auto p-6` hardcoded de formas inconsistentes.
|
||||
|
||||
**Solucao**: Padronizar usando `MainSection` ou `DashboardLayout` com padding responsivo:
|
||||
```
|
||||
px-4 sm:px-6 lg:px-8 py-4 sm:py-6
|
||||
```
|
||||
|
||||
| Pagina | Container atual | Correcao |
|
||||
|----------------------------|----------------------------------|----------------------------|
|
||||
| `student/StudentClassDetail` | `max-w-6xl p-6` (sem mx-auto) | Adicionar `mx-auto` |
|
||||
| `guardian/GuardianClassDetail` | `w-full p-6` | `max-w-6xl mx-auto p-4 sm:p-6` |
|
||||
| `guardian/GuardianClassHistoryList` | `w-full` sem padding | Adicionar `px-4 sm:px-6` |
|
||||
| Todas as outras | Variacoes de `max-w-6xl mx-auto p-6` | Padronizar via `MainSection` |
|
||||
|
||||
### 1.3 `PageHeader` -- actions com wrap
|
||||
|
||||
**Arquivo**: `shared/PageHeader.jsx:29`
|
||||
|
||||
**Correcao**: Adicionar `flex-wrap` no div de actions. Resolve ~9 paginas de uma vez.
|
||||
|
||||
---
|
||||
|
||||
## Fase 2: Componentes responsivos (1 correcao aplica-se a N paginas)
|
||||
|
||||
### 2.1 `Modal` (novo componente)
|
||||
|
||||
```
|
||||
max-w-[95vw] sm:max-w-2xl md:max-w-4xl
|
||||
```
|
||||
+ padding responsivo `p-4 sm:p-6`
|
||||
|
||||
**Afeta**: 11 modais
|
||||
|
||||
### 2.2 `TableWrapper` (novo componente)
|
||||
|
||||
- `overflow-x-auto` por padrao
|
||||
- Borda e estilo consistentes
|
||||
|
||||
**Afeta**: 9 tabelas admin
|
||||
|
||||
### 2.3 `MainSection` -- padding responsivo
|
||||
|
||||
```
|
||||
p-4 sm:p-6
|
||||
```
|
||||
|
||||
**Afeta**: todas as paginas que usam `MainSection`
|
||||
|
||||
---
|
||||
|
||||
## Fase 3: Ajustes por componente
|
||||
|
||||
### 3.1 Grids sem breakpoint responsivo
|
||||
|
||||
| Arquivo | Linha | Atual | Correcao |
|
||||
|------------------------------------------|-------|-------------------|---------------------------------|
|
||||
| `teacher/ExamResults.jsx` | 140 | `grid-cols-3` | `grid-cols-1 sm:grid-cols-3` |
|
||||
| `teacher/ExamResults.jsx` | 251 | `grid-cols-2` | `grid-cols-1 sm:grid-cols-2` |
|
||||
| `exam-templates/TemplateForm.jsx` | 485 | `grid-cols-2` | `grid-cols-1 sm:grid-cols-2` |
|
||||
| `exam-templates/AssignmentForm.jsx` | 485 | `grid-cols-2` | `grid-cols-1 sm:grid-cols-2` |
|
||||
|
||||
### 3.2 Tabela com overflow-hidden
|
||||
|
||||
| Arquivo | Linha | Atual | Correcao |
|
||||
|----------------------------|-------|-------------------|-------------------|
|
||||
| `teacher/ClassDetail.jsx` | 304 | `overflow-hidden` | `overflow-x-auto` |
|
||||
|
||||
### 3.3 Flex layouts que transbordam
|
||||
|
||||
| Arquivo | Linha | Correcao |
|
||||
|----------------------------------|----------|---------------------------------------------|
|
||||
| `student/TakeExam.jsx` | 211 | `flex-wrap` + `gap-2` |
|
||||
| `dashboard/student/page.jsx` | 29 | `flex-wrap` nos botoes |
|
||||
| `teacher/ExamResults.jsx` | 169 | `flex-wrap` nos metadados |
|
||||
| `student/ExamResults.jsx` | 269 | `flex-wrap gap-4` no resumo |
|
||||
| `lessons/LessonForm.jsx` | 372-376 | `flex-col sm:flex-row` |
|
||||
| `teacher/AssignmentList.jsx` | 127 | `flex-wrap gap-2` |
|
||||
| `exam-templates/TemplateList.jsx`| 153 | `flex-wrap` nos botoes |
|
||||
| `student/AvailableExams.jsx` | 187, 231 | `flex-col sm:flex-row` |
|
||||
|
||||
### 3.4 Texto longo sem quebra
|
||||
|
||||
| Arquivo | Linha | Correcao |
|
||||
|----------------------------------------|-------|---------------------|
|
||||
| `guardian/GuardianClassDetail.jsx` | 40 | `break-words` |
|
||||
| `teacher/class/[id]/page.jsx` | 212 | Truncar no mobile |
|
||||
|
||||
### 3.5 Tabelas com muitas colunas -- ocultar no mobile
|
||||
|
||||
| Arquivo | Colunas | Correcao |
|
||||
|--------------------------------|---------|---------------------------------------|
|
||||
| `teacher/ExamStatistics.jsx` | 7 | `hidden sm:table-cell` nas colunas menos importantes |
|
||||
| `guardian/payments/page.jsx` | 6 | Ocultar "Comprovante" no mobile |
|
||||
|
||||
### 3.6 Botoes que podem empilhar
|
||||
|
||||
| Arquivo | Linha | Correcao |
|
||||
|------------------|-------|-------------------------|
|
||||
| `PaymentForm.jsx`| 193 | `flex-col sm:flex-row` |
|
||||
| `RegisterForm.jsx`| 253 | `flex-col sm:flex-row` |
|
||||
|
||||
---
|
||||
|
||||
## Header mobile -- ja parcialmente implementado
|
||||
|
||||
O `Header.jsx` ja tem navegacao mobile (`md:hidden` com scroll horizontal). Pontos de melhoria:
|
||||
- Chips de navegacao mobile (linhas 147-162) poderiam usar icones maiores e mais touch-friendly
|
||||
- Considerar colapsar os chips em menu hamburger quando ha mais de 4 items (admin tem 6 links)
|
||||
|
||||
---
|
||||
|
||||
## Resumo de prioridade de execucao
|
||||
|
||||
1. **Fase 0** -- Extrair componentes base (maior esforco, maior ganho em manutenibilidade)
|
||||
2. **Fase 1** -- Layout global responsivo (sidebar + containers + page header)
|
||||
3. **Fase 2** -- Componentes responsivos reutilizaveis (Modal + TableWrapper)
|
||||
4. **Fase 3** -- Ajustes pontuais por componente (grids, flex, tabelas)
|
||||
|
||||
---
|
||||
|
||||
## Metricas estimadas
|
||||
|
||||
| Metricica | Valor |
|
||||
|------------------------------------|----------------------|
|
||||
| Arquivos alterados | ~18 |
|
||||
| Linhas removidas (duplicacao) | ~800-1000 |
|
||||
| Componentes novos reutilizaveis | 7 |
|
||||
| Componentes importados (ja existiam)| 6 |
|
||||
| Correcoes de responsividade | ~26 |
|
||||
| Modais afetados por 1 componente | 11 |
|
||||
| Tabelas afetadas por 1 componente | 9 |
|
||||
|
||||
---
|
||||
|
||||
## Componentes que NAO precisam de correcao de responsividade
|
||||
|
||||
Ja estao corretos:
|
||||
- `page.jsx` (landing page) -- bem estruturado com breakpoints
|
||||
- `LoginForm.jsx` -- formulario centralizado simples
|
||||
- `ClassCard.jsx` -- card layout responsivo
|
||||
- `ClassCardsComponent.jsx` -- `grid-cols-1 sm:grid-cols-2`
|
||||
- `WardClassCardComponent.jsx` -- `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`
|
||||
- `DashboardCard.jsx` -- card layout
|
||||
- `AdminDashboard.jsx` -- `grid-cols-1 sm:grid-cols-2`
|
||||
- `RoleDispatcher.jsx` -- `grid-cols-1 sm:grid-cols-2`
|
||||
- `ProfileForm.jsx` -- `grid-cols-1 md:grid-cols-2`
|
||||
- `PasswordUpdateForm.jsx` -- `grid-cols-1 md:grid-cols-2`
|
||||
- `AffiliateProductsSection.jsx` -- `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`
|
||||
- `ToggleTheme.jsx` -- botao de tamanho fixo
|
||||
- `UserMenu.jsx` -- esconde nome em telas pequenas com `hidden sm:inline`
|
||||
@@ -0,0 +1,102 @@
|
||||
# Dashboard Design System - Standardization Plan
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
Current dashboards have **inconsistent layouts** causing width and spacing issues:
|
||||
|
||||
| Dashboard | Width Constraint | Padding | Layout Component |
|
||||
|-----------|------------------|---------|------------------|
|
||||
| Admin | `max-w-6xl mx-auto p-6` | 1.5rem | Page-level wrapper |
|
||||
| Teacher | None (uses MainSection) | 2rem (p-8) | MainSection with no max-width |
|
||||
| Student | `max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6` | Variable | Page-level wrapper |
|
||||
| Guardian | `max-w-5xl mx-auto` | 2rem (p-8) | MainSection + inner wrapper |
|
||||
|
||||
### Additional Issues:
|
||||
1. **Global CSS** (lines 451-459) adds underline to all links on hover
|
||||
2. **ClassCardsComponent** has `lg:grid-cols-3` causing 3 cards per row on large screens
|
||||
3. **ClassCard** links have default underline on hover
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### 1. Update MainSection Component
|
||||
Add width constraint to standardize all dashboards using it:
|
||||
```jsx
|
||||
// Current
|
||||
<main className={`flex-1 p-8 overflow-y-auto ${className}`}>
|
||||
|
||||
// Proposed
|
||||
<main className={`flex-1 p-6 overflow-y-auto ${className}`}>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
```
|
||||
|
||||
### 2. Remove Global Link Underline
|
||||
Remove hover underline from globals.css (lines 457-459):
|
||||
```css
|
||||
/* Remove these lines */
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Standardize ClassCardsComponent Grid
|
||||
Change from 3 columns to max 2 columns:
|
||||
```jsx
|
||||
// Current
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
|
||||
// Proposed
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
```
|
||||
|
||||
### 4. Ensure ClassCard Has No Underline
|
||||
Keep the `no-underline` class on Link:
|
||||
```jsx
|
||||
<Link href={link} className="block no-underline">
|
||||
```
|
||||
|
||||
### 5. Standard Dashboard Page Pattern
|
||||
All dashboard pages should follow this structure:
|
||||
```jsx
|
||||
<MainSection>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<PageTitle title="..." subTitle="..."/>
|
||||
{/* Content */}
|
||||
</div>
|
||||
</MainSection>
|
||||
```
|
||||
|
||||
### 6. Create Shared DashboardLayout Component (Optional)
|
||||
For future consistency, create a reusable component:
|
||||
```jsx
|
||||
// src/app/(protected)/components/DashboardLayout.jsx
|
||||
export function DashboardLayout({ title, subtitle, children }) {
|
||||
return (
|
||||
<MainSection>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<PageTitle title={title} subTitle={subtitle}/>
|
||||
{children}
|
||||
</div>
|
||||
</MainSection>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. [`src/app/(protected)/components/shared/Main.jsx`](src/app/\(protected\)/components/shared/Main.jsx) - Add max-width wrapper
|
||||
2. [`src/app/globals.css`](src/app/globals.css) - Remove link hover underline
|
||||
3. [`src/app/(protected)/components/shared/ClassCardsComponent.jsx`](src/app/\(protected\)/components/shared/ClassCardsComponent.jsx) - Standardize grid
|
||||
4. [`src/app/(protected)/components/shared/ClassCard.jsx`](src/app/\(protected\)/components/shared/ClassCard.jsx) - Ensure no-underline
|
||||
5. [`src/app/(protected)/dashboard/teacher/page.jsx`](src/app/\(protected\)/dashboard/teacher/page.jsx) - Remove redundant wrapper
|
||||
6. (Optional) [`src/app/(protected)/components/DashboardLayout.jsx`](src/app/\(protected\)/components/DashboardLayout.jsx) - Create reusable component
|
||||
|
||||
## After Changes
|
||||
|
||||
All dashboards will:
|
||||
- Have consistent max-width (`max-w-6xl` ≈ 72rem / 1152px)
|
||||
- Use consistent padding (`p-6` ≈ 1.5rem)
|
||||
- Display max 2 cards per row
|
||||
- No underline on link hover
|
||||
@@ -0,0 +1,177 @@
|
||||
# Payment Registration System Architecture
|
||||
|
||||
## Overview
|
||||
A payment registration system that allows guardians/students to register payments for classes and enables admins to track payment status.
|
||||
|
||||
## Requirements
|
||||
- **Users**: Guardians/students can register payments, admins can view payment status
|
||||
- **Payment Information**: Value, date/time, optional receipt image
|
||||
- **Access Control**: Only admins can view payment details; guardians/students can only register their own payments
|
||||
|
||||
## Database Schema Design
|
||||
|
||||
### Payment Model
|
||||
```javascript
|
||||
{
|
||||
_id: ObjectId,
|
||||
classId: ObjectId (ref: Class),
|
||||
userId: ObjectId (ref: User),
|
||||
amount: Number,
|
||||
paymentDate: Date,
|
||||
paymentMethod: String (e.g., "pix", "bank_transfer", "cash"),
|
||||
status: String (enum: ["pending", "verified", "rejected"]),
|
||||
receiptUrl: String (optional, from file upload),
|
||||
notes: String (optional),
|
||||
createdAt: Date,
|
||||
updatedAt: Date
|
||||
}
|
||||
```
|
||||
|
||||
### Key Features
|
||||
- **One-to-Many**: A class can have multiple payments (partial payments)
|
||||
- **Many-to-One**: A user can make multiple payments across different classes
|
||||
- **Status Tracking**: Pending → Verified/Rejected workflow
|
||||
|
||||
## API Routes Structure
|
||||
|
||||
### 1. Payment API Routes
|
||||
```
|
||||
/api/payments
|
||||
GET - List all payments (admin only)
|
||||
POST - Create new payment (guardian/student only)
|
||||
GET/:id - Get single payment (admin only)
|
||||
PUT/:id - Update payment status (admin only)
|
||||
DELETE/:id - Delete payment (admin only)
|
||||
|
||||
/api/payments/class/:classId
|
||||
GET - Get all payments for a specific class (admin only)
|
||||
|
||||
/api/payments/user/:userId
|
||||
GET - Get all payments for a specific user (guardian/student only)
|
||||
```
|
||||
|
||||
### 2. File Upload Integration
|
||||
- Use existing `/api/files` routes
|
||||
- Set `relatedToType` = "payment"
|
||||
- Set `relatedToId` = payment ID
|
||||
- Store receipt image URL in payment record
|
||||
|
||||
## UI Components Structure
|
||||
|
||||
### Admin Dashboard
|
||||
```
|
||||
/app/(protected)/admin/dashboard/payments/
|
||||
page.jsx - Main payments dashboard
|
||||
components/
|
||||
PaymentsTable.jsx - Table showing all payments
|
||||
PaymentDetail.jsx - Modal showing payment details
|
||||
PaymentStats.jsx - Statistics cards
|
||||
VerifyPaymentButton.jsx - Button to verify/reject payments
|
||||
```
|
||||
|
||||
### Guardian Dashboard
|
||||
```
|
||||
/app/(protected)/dashboard/guardian/payments/
|
||||
page.jsx - List of registered payments
|
||||
register/
|
||||
page.jsx - Payment registration form
|
||||
components/
|
||||
PaymentForm.jsx - Form to register payment
|
||||
ReceiptUpload.jsx - File upload component
|
||||
|
||||
/app/(protected)/dashboard/guardian/class/[id]/payments/
|
||||
page.jsx - Payments for specific class
|
||||
```
|
||||
|
||||
### Student Dashboard
|
||||
```
|
||||
/app/(protected)/dashboard/student/payments/
|
||||
page.jsx - List of registered payments
|
||||
register/
|
||||
page.jsx - Payment registration form
|
||||
```
|
||||
|
||||
## Payment Flow Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant G as Guardian/Student
|
||||
participant UI as UI Component
|
||||
participant API as API Route
|
||||
participant DB as Database
|
||||
participant FS as File Storage
|
||||
|
||||
G->>UI: Submit payment form (amount, date, receipt)
|
||||
UI->>API: POST /api/payments
|
||||
API->>DB: Create payment record (status: pending)
|
||||
API->>FS: Upload receipt image
|
||||
API->>DB: Update payment with receipt URL
|
||||
API-->>UI: Return payment confirmation
|
||||
UI-->>G: Show success message
|
||||
|
||||
Note over Admin: Admin views dashboard
|
||||
Admin->>UI: View payments dashboard
|
||||
UI->>API: GET /api/payments
|
||||
API-->>UI: Return all payments
|
||||
UI-->>Admin: Display payment table with status
|
||||
```
|
||||
|
||||
## Class View Integration
|
||||
|
||||
### Guardian Class Detail Page
|
||||
- Add payment status indicator
|
||||
- Show "Register Payment" button if not paid
|
||||
- Show payment history for the class
|
||||
|
||||
### Admin Class View
|
||||
- Show payment status for all students
|
||||
- Filter by payment status
|
||||
- Quick actions to verify/reject payments
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Phase 1: Database & API
|
||||
1. Create Payment model
|
||||
2. Create API routes for payment CRUD operations
|
||||
3. Integrate with existing file upload system
|
||||
|
||||
### Phase 2: Admin Dashboard
|
||||
1. Create payments dashboard page
|
||||
2. Build payments table with filtering
|
||||
3. Add payment detail modal
|
||||
4. Implement verify/reject functionality
|
||||
|
||||
### Phase 3: Guardian/Student Interface
|
||||
1. Create payment registration form
|
||||
2. Build payment history view
|
||||
3. Integrate receipt upload
|
||||
4. Add payment status indicators
|
||||
|
||||
### Phase 4: Integration
|
||||
1. Update class detail pages
|
||||
2. Add payment status to class cards
|
||||
3. Add payment reminders (optional)
|
||||
|
||||
## Security Considerations
|
||||
- **Role-Based Access Control**: Only admins can view payment details
|
||||
- **Data Validation**: Validate payment amounts and dates
|
||||
- **File Upload Security**: Validate receipt images before storing
|
||||
- **User Authorization**: Users can only register payments for their own classes
|
||||
|
||||
## Data Validation Rules
|
||||
- Amount: Must be positive number
|
||||
- Payment Date: Must be in the past or present
|
||||
- Receipt: Max file size, allowed image formats only
|
||||
- Payment Method: Must be from predefined list
|
||||
|
||||
## Status Workflow
|
||||
```
|
||||
Pending (default) → Verified (admin approves) → Rejected (admin rejects)
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
- Payment reminders for pending payments
|
||||
- Payment history export
|
||||
- Bulk payment verification
|
||||
- Payment analytics and reports
|
||||
- Integration with payment gateways (optional)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
# Payment Registration System - Summary
|
||||
|
||||
## Overview
|
||||
A complete payment registration system for your course platform that allows guardians/students to register payments and enables admins to track payment status.
|
||||
|
||||
## What You'll Get
|
||||
|
||||
### 1. **Database Model**
|
||||
- New [`Payment`](src/app/models/Payment.js) model with fields:
|
||||
- `classId` - Reference to the class
|
||||
- `userId` - Reference to the user (guardian/student)
|
||||
- `amount` - Payment value
|
||||
- `paymentDate` - Date and time of payment
|
||||
- `paymentMethod` - Method (PIX, bank transfer, cash, etc.)
|
||||
- `status` - Pending, Verified, or Rejected
|
||||
- `receiptUrl` - Optional receipt image
|
||||
- `notes` - Optional notes
|
||||
|
||||
### 2. **API Routes**
|
||||
- [`/api/payments`](src/app/api/payments/route.js) - Create and list payments
|
||||
- [`/api/payments/[id]`](src/app/api/payments/[id]/route.js) - Get, update, delete single payment
|
||||
- [`/api/payments/class/[classId]`](src/app/api/payments/class/[classId]/route.js) - Get all payments for a class
|
||||
- [`/api/payments/user/[userId]`](src/app/api/payments/user/[userId]/route.js) - Get all payments for a user
|
||||
|
||||
### 3. **Admin Dashboard**
|
||||
- [`/admin/dashboard/payments`](src/app/(protected)/admin/dashboard/payments/page.jsx) - Main dashboard
|
||||
- **Features**:
|
||||
- View all payments in a table
|
||||
- Filter by status (all, pending, verified, rejected)
|
||||
- See payment statistics (total, verified, pending, rejected)
|
||||
- Verify or reject payments
|
||||
- View payment details
|
||||
|
||||
### 4. **Guardian/Student Interface**
|
||||
- **Payment Registration**:
|
||||
- Form to register payments with amount, date, method
|
||||
- Optional receipt image upload
|
||||
- Notes field for additional information
|
||||
- **Payment History**:
|
||||
- View all registered payments
|
||||
- See payment status for each class
|
||||
|
||||
### 5. **Payment Status Indicators**
|
||||
- Reusable [`PaymentStatusBadge`](src/app/(protected)/components/shared/PaymentStatusBadge.jsx) component
|
||||
- Shows status: Not Paid, Pending, Paid, or Rejected
|
||||
- Color-coded badges for easy identification
|
||||
|
||||
## How It Works
|
||||
|
||||
### For Guardians/Students:
|
||||
1. Navigate to a class they're enrolled in or their ward is enrolled in
|
||||
2. Click "Registrar Pagamento" (Register Payment)
|
||||
3. Fill in payment details:
|
||||
- Amount (R$)
|
||||
- Payment date
|
||||
- Payment method (PIX, bank transfer, cash, etc.)
|
||||
- Optional: Upload receipt image
|
||||
- Optional: Add notes
|
||||
4. Submit the form
|
||||
5. Payment status becomes "Pending" (waiting for admin verification)
|
||||
|
||||
### For Admins:
|
||||
1. Navigate to Admin Dashboard → Payments
|
||||
2. View all payments in a table
|
||||
3. See payment statistics at the top
|
||||
4. Filter payments by status
|
||||
5. Click on a payment to see details
|
||||
6. Verify or reject the payment
|
||||
7. Payment status updates accordingly
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/payments/ # API routes
|
||||
│ ├── (protected)/
|
||||
│ │ ├── admin/dashboard/payments/ # Admin dashboard
|
||||
│ │ ├── dashboard/guardian/payments/ # Guardian interface
|
||||
│ │ └── dashboard/student/payments/ # Student interface
|
||||
│ └── components/shared/PaymentStatusBadge.jsx # Reusable component
|
||||
├── models/Payment.js # Payment model
|
||||
└── lib/utils/payments.js # Helper functions
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
- **Role-Based Access Control**:
|
||||
- Only admins can view payment details
|
||||
- Guardians can only register payments for their wards
|
||||
- Students can only register payments for their enrolled classes
|
||||
- **Data Validation**:
|
||||
- Amount must be positive
|
||||
- Payment date must be valid
|
||||
- File uploads are validated
|
||||
- **Data Integrity**:
|
||||
- All payments are tracked with timestamps
|
||||
- Payment status workflow is enforced
|
||||
|
||||
## Integration with Existing System
|
||||
|
||||
- Uses existing file upload infrastructure
|
||||
- Integrates with existing User and Class models
|
||||
- Follows existing API patterns
|
||||
- Uses existing UI components
|
||||
- Compatible with current role-based authentication
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review the plans**:
|
||||
- [`payment-system-architecture.md`](plans/payment-system-architecture.md) - High-level architecture
|
||||
- [`payment-system-implementation.md`](plans/payment-system-implementation.md) - Detailed implementation guide
|
||||
|
||||
2. **Implementation order**:
|
||||
- Create Payment model
|
||||
- Create API routes
|
||||
- Create admin dashboard
|
||||
- Create guardian/student interface
|
||||
- Integrate with class views
|
||||
- Test the complete flow
|
||||
|
||||
3. **Testing checklist**:
|
||||
- Create payment with guardian account
|
||||
- Verify payment with admin account
|
||||
- Test file upload for receipts
|
||||
- Verify role-based access control
|
||||
- Test payment status indicators
|
||||
|
||||
## Questions?
|
||||
|
||||
The detailed implementation guide includes:
|
||||
- Complete code for all components
|
||||
- API route implementations
|
||||
- Database schema definitions
|
||||
- UI component code
|
||||
- Integration points
|
||||
- Testing checklist
|
||||
|
||||
Would you like me to proceed with implementing this system? I can start by creating the Payment model and API routes.
|
||||
@@ -0,0 +1,283 @@
|
||||
# Plano de Refatoracao por Fases
|
||||
|
||||
Este documento organiza a correcao do projeto em fases pequenas, verificaveis e sem ruptura grande.
|
||||
|
||||
Objetivo principal:
|
||||
- estabilizar build e seguranca
|
||||
- migrar gradualmente de API Routes para Server Actions
|
||||
- eliminar conflitos de CSS global (sem `!important`)
|
||||
- aproximar o frontend de um estilo tipo shadcn (componentes reutilizaveis + tokens consistentes)
|
||||
|
||||
---
|
||||
|
||||
## Visao Geral das Fases
|
||||
|
||||
1. Fase 0 - Baseline e congelamento
|
||||
2. Fase 1 - Build, auth e seguranca minima
|
||||
3. Fase 2 - Migracao API -> Server Actions
|
||||
4. Fase 3 - Reset de CSS global e fim de conflitos
|
||||
5. Fase 4 - Base de design system estilo shadcn
|
||||
6. Fase 5 - Refino visual da plataforma de ingles
|
||||
7. Fase 6 - Limpeza final e hardening
|
||||
|
||||
Cada fase tem: escopo, tarefas, definicao de pronto e risco.
|
||||
|
||||
### Status consolidado (fev/2026)
|
||||
- Fase 1: concluida.
|
||||
- Fase 2: concluida nos dominios planejados para fluxo interno (APIs legadas mantidas como fallback temporario).
|
||||
- Fase 3: concluida no objetivo principal (globals enxuto, sem `!important`, sem overrides globais agressivos).
|
||||
- Fase 4: concluida no escopo principal (base `ui` ativa + login/register + dashboards principais migrados).
|
||||
- Fase 5: pendente (refino visual mais amplo ainda nao iniciado de forma sistematica).
|
||||
- Fase 6: em andamento parcial (melhorias de feedback ao usuario e tratamento de erros em fluxos criticos).
|
||||
|
||||
---
|
||||
|
||||
## Fase 0 - Baseline e congelamento
|
||||
|
||||
### Escopo
|
||||
Criar um ponto de partida confiavel antes de alterar arquitetura.
|
||||
|
||||
### Tarefas
|
||||
- [ ] Registrar o estado atual (erros conhecidos, rotas criticas, telas criticas).
|
||||
- [ ] Salvar relatorio de build/lint/testes E2E atuais.
|
||||
- [ ] Definir branch de refatoracao (ex.: `refactor/phases`).
|
||||
- [ ] Congelar novas features ate concluir Fase 2.
|
||||
|
||||
### Definicao de pronto
|
||||
- Baseline documentado e reproduzivel.
|
||||
- Time alinhado sobre prioridades e ordem de execucao.
|
||||
|
||||
### Risco
|
||||
- Baixo. Sem mudanca de codigo de negocio.
|
||||
|
||||
---
|
||||
|
||||
## Fase 1 - Build, auth e seguranca minima
|
||||
|
||||
### Escopo
|
||||
Resolver problemas que quebram producao/deploy e vazamentos de autorizacao.
|
||||
|
||||
### Tarefas
|
||||
- [x] Corrigir middleware para nao importar auth/db de runtime Edge.
|
||||
- Opcao recomendada: middleware apenas para roteamento simples.
|
||||
- Verificacao de permissao real fica no server (Server Components/Server Actions).
|
||||
- [x] Revisar matcher do middleware para incluir rotas protegidas reais (`/admin/*`, `/dashboard/*`).
|
||||
- [x] Garantir bloqueio de acesso nas paginas sensiveis antes de carregar dados.
|
||||
- [x] Corrigir verificacao de role inconsistente (`role` vs `roles`).
|
||||
- [x] Revisar regra de arquivos legados: negar por padrao quando nao houver metadado de permissao.
|
||||
- [x] Remover/encerrar fluxo duplicado de auth (uma unica fonte de verdade do NextAuth).
|
||||
- [x] Padronizar variavel de ambiente do Mongo (`MONGODB_URI` ou `MONGO_URI`) e alinhar docs.
|
||||
|
||||
### Definicao de pronto
|
||||
- `npm run build` passa sem erro.
|
||||
- Rotas protegidas retornam 401/403 corretamente.
|
||||
- Fluxo de login/logout unico e consistente.
|
||||
|
||||
### Risco
|
||||
- Medio. Pode impactar acesso de usuarios se a regra de permissao nao for validada com cuidado.
|
||||
|
||||
---
|
||||
|
||||
## Fase 2 - Migracao API -> Server Actions
|
||||
|
||||
### Escopo
|
||||
Migrar gradualmente endpoints de API para Server Actions, priorizando fluxo interno do app.
|
||||
|
||||
### Principios
|
||||
- Evitar big-bang. Migrar por dominio funcional.
|
||||
- Manter API somente quando houver necessidade real externa (webhook, integracao de terceiros, upload streaming, callback externo).
|
||||
- Toda Action deve validar sessao e permissao no servidor.
|
||||
|
||||
### Ordem recomendada de migracao
|
||||
1. **Pagamentos**
|
||||
2. **Turmas e atribuicoes/provas**
|
||||
3. **Arquivos e categorias**
|
||||
4. **Usuarios/guardian/wards**
|
||||
|
||||
### Tarefas por dominio (template)
|
||||
- [ ] Criar `actions/` por dominio com funcoes server-only.
|
||||
- [ ] Mover validacoes de auth/autorizacao para as Actions.
|
||||
- [ ] Substituir chamadas `fetch('/api/...')` por invocacao direta de Action.
|
||||
- [ ] Padronizar retorno `{ success, data, message, fieldErrors }`.
|
||||
- [ ] Revalidar cache (`revalidatePath`/`revalidateTag`) quando necessario.
|
||||
- [ ] Manter fallback temporario para rotas antigas e remover ao final da fase.
|
||||
|
||||
### Progresso atual (dominio: Pagamentos)
|
||||
- [x] Criadas Server Actions de pagamentos e dependentes.
|
||||
- [x] Auth/autorizacao centralizadas nas Actions de pagamentos.
|
||||
- [x] Telas de pagamentos de aluno/responsavel migradas de `fetch('/api/...')` para Actions.
|
||||
- [x] Formularios de registro de pagamento migrados para Actions.
|
||||
- [x] Retorno padronizado nas novas Actions (`success`, `data`, `message`, `fieldErrors`).
|
||||
- [x] Revalidacao aplicada para dashboards de pagamento apos envio.
|
||||
- [x] Fluxo admin de pagamentos migrado para Actions (criacao de obrigacao, aprovacao/rejeicao, consulta por obrigacao).
|
||||
- [x] Fallback temporario mantido: APIs antigas continuam disponiveis por compatibilidade e remocao gradual.
|
||||
|
||||
### Progresso atual (dominio: Turmas e atribuicoes/provas)
|
||||
- [x] Criadas Server Actions para fluxo operacional de provas (listar atribuicoes por turma, iniciar tentativa, listar tentativas, detalhar tentativa, corrigir resposta).
|
||||
- [x] Componentes de aluno migrados para Actions (`AvailableExams`, `TakeExam`) sem dependencia de `/api/classes/*` e `/api/assignments/*` nesses fluxos.
|
||||
- [x] Componente de resultados para professor/admin migrado para Actions (`ExamResults`) sem dependencia de `/api/attempts/*`.
|
||||
- [x] Mantido fallback temporario: rotas API de provas continuam existentes para compatibilidade durante transicao.
|
||||
|
||||
### Progresso atual (dominio: Arquivos e categorias)
|
||||
- [x] Criada Server Action para carregamento de categorias com migracao de `colorIndex` quando necessario.
|
||||
- [x] Formulario de upload de arquivos do admin migrado para Action de categorias, removendo dependencia de `/api/categories` no cliente.
|
||||
- [x] Ajuste de navegacao pos-upload para `router.push` em componentes cliente de upload (evitando uso indevido de `redirect`).
|
||||
- [x] Eliminadas chamadas `fetch('/api/...')` no frontend protegido; fluxo interno agora opera via Server Actions.
|
||||
|
||||
### Progresso atual (dominio: Usuarios/guardian/wards)
|
||||
- [x] Criadas Server Actions de usuarios para dependentes (`getWardsAction`) e listagem admin (`getUsersForAdminAction`).
|
||||
- [x] Fluxo de pagamentos do responsavel desacoplado do dominio de pagamentos para usar Action de usuarios no carregamento de dependentes.
|
||||
- [x] Painel admin de usuarios passou a consumir Action de usuarios (sem dependencia de rota API interna).
|
||||
- [x] Harden de cadastro de estudante por responsavel: validacao de sessao/role, vinculo seguro de guardian e verificacao de unicidade (email/username).
|
||||
|
||||
### Definicao de pronto
|
||||
- Fluxos internos principais sem dependencia de API Route.
|
||||
- Reducao significativa de codigo duplicado entre page/API.
|
||||
- Permissoes centralizadas em camada server.
|
||||
|
||||
### Risco
|
||||
- Medio/alto. Erros de cache e invalidacao podem causar dados desatualizados.
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 - Reset de CSS global e fim de conflitos
|
||||
|
||||
### Escopo
|
||||
Reduzir `globals.css` ao minimo necessario e eliminar conflitos causados por regras globais.
|
||||
|
||||
### Regras obrigatorias
|
||||
- Sem `!important`.
|
||||
- Sem redefinir utilitarios Tailwind manualmente (`.bg-*`, `.text-*`, etc.).
|
||||
- Sem estilos globais agressivos para `button`, `a`, `table`, `input` que mudem componentes inteiros.
|
||||
|
||||
### Tarefas
|
||||
- [x] Criar backup do `globals.css` atual.
|
||||
- [x] Reescrever `globals.css` em blocos minimos:
|
||||
- tokens CSS (`:root`, `.dark`)
|
||||
- reset basico
|
||||
- utilitarios realmente necessarios
|
||||
- [x] Extrair classes de componentes para componentes reais (Button, Input, Badge, Card, Alert).
|
||||
- [x] Remover gradualmente classes legadas utilitarias customizadas.
|
||||
- [x] Garantir fonte definida no layout e aplicada de forma unica.
|
||||
- [x] Revisar contraste, foco e estados disabled/hover.
|
||||
|
||||
### Progresso atual (Fase 3)
|
||||
- [x] `globals.css` reduzido e sem `!important`.
|
||||
- [x] Removidos overrides manuais de utilitarios Tailwind (`.bg-*`, `.text-*`, `.border-*`).
|
||||
- [x] `Label` e `RoleCheckbox` migrados para variantes no componente (sem classes de tema globais acopladas).
|
||||
- [x] Continuar extracao para componentes de base (Button/Input/Badge/Card/Alert) e reduzir classes globais remanescentes.
|
||||
|
||||
### Definicao de pronto
|
||||
- Arquivo global enxuto e previsivel.
|
||||
- Sem conflitos visuais entre telas por efeito colateral global.
|
||||
- Nenhum `!important` no codigo do app.
|
||||
|
||||
### Risco
|
||||
- Medio. Pode quebrar aparencia de telas antigas sem componentes padronizados.
|
||||
|
||||
---
|
||||
|
||||
## Fase 4 - Base de design system estilo shadcn
|
||||
|
||||
### Escopo
|
||||
Padronizar UI com componentes reutilizaveis, variantes e tokens.
|
||||
|
||||
### Tarefas
|
||||
- [x] Criar base `components/ui` (Button, Input, Select, Textarea, Badge, Card, Dialog, Table).
|
||||
- [x] Adotar utilitario `cn` para composicao de classes.
|
||||
- [x] Adotar estrategia de variantes (ex.: cva) para reduzir classes repetidas.
|
||||
- [x] Padronizar espacamento, raio, sombras, tipografia e cores semanticas.
|
||||
- [x] Migrar telas mais usadas primeiro (login, register, dashboard admin/teacher/student).
|
||||
|
||||
### Progresso atual (Fase 4)
|
||||
- [x] Estrutura inicial criada em `src/components/ui` com componentes base (Button, Input, Select, Textarea, Badge, Card, Alert).
|
||||
- [x] Utilitario `cn` criado e aplicado na base dos componentes UI.
|
||||
- [x] Primeira migracao pratica feita em formularios criticos (registro de estudante e pagamento aluno/responsavel).
|
||||
- [x] Migracao aplicada em login/register com padrao `components/ui` (Card/Input/Button/Alert) e layout alinhado.
|
||||
- [x] Dashboards principais ajustados para consistencia de componentes e estilos-base (admin/teacher/student/guardian).
|
||||
|
||||
### Definicao de pronto
|
||||
- Componentes principais usados pela maioria das telas.
|
||||
- Queda relevante de duplicacao de classes Tailwind.
|
||||
- Visual consistente entre areas publicas e protegidas.
|
||||
|
||||
### Risco
|
||||
- Medio. Requer disciplina para evitar voltar ao estilo ad-hoc por pagina.
|
||||
|
||||
---
|
||||
|
||||
## Fase 5 - Refino visual da plataforma de ingles
|
||||
|
||||
### Escopo
|
||||
Dar identidade visual propria da escola, sem perder legibilidade e performance.
|
||||
|
||||
### Tarefas
|
||||
- [ ] Definir direcao visual unica (paleta, tipografia, acentos, ilustracoes/fotos).
|
||||
- [ ] Revisar landing page e fluxo de autenticacao com hierarquia visual clara.
|
||||
- [ ] Melhorar componentes de conteudo educacional (cards de nivel, progresso, tarefas, feedback).
|
||||
- [ ] Garantir responsividade real (mobile-first) nas paginas criticas.
|
||||
- [ ] Revisar acessibilidade (foco visivel, contraste, labels, erros de formulario).
|
||||
|
||||
### Definicao de pronto
|
||||
- Interface coerente com proposta de plataforma de ingles.
|
||||
- Melhor legibilidade e consistencia de interacao.
|
||||
|
||||
### Risco
|
||||
- Baixo/medio. Principal risco e retrabalho visual sem criterios definidos.
|
||||
|
||||
---
|
||||
|
||||
## Fase 6 - Limpeza final e hardening
|
||||
|
||||
### Escopo
|
||||
Fechamento tecnico para estabilidade de longo prazo.
|
||||
|
||||
### Tarefas
|
||||
- [ ] Remover codigo morto (rotas API legadas, helpers nao usados, estilos antigos).
|
||||
- [~] Reduzir logs de debug e padronizar logger por ambiente.
|
||||
- [~] Revisar tratamento de erros (mensagem para usuario vs log tecnico).
|
||||
- [ ] Atualizar README e documentacao de arquitetura nova (Server Actions-first).
|
||||
- [ ] Validar testes E2E por papel (admin/teacher/student/guardian).
|
||||
- [ ] Revisar seguranca de upload/acesso a arquivos.
|
||||
|
||||
### Progresso atual (Fase 6)
|
||||
- [x] Melhorado feedback de permissao no registro/edicao de aula (403 com mensagem clara no UI).
|
||||
- [x] Reduzido ruido de console no cliente para erros esperados (4xx) em `LessonForm`.
|
||||
- [x] Ajustados fluxos de navegacao que causavam 404 silencioso (links de upload/historico/resultados entre contextos admin/teacher).
|
||||
- [ ] Consolidar padrao unico de erros para todos os formularios com Server Actions e APIs legadas restantes.
|
||||
|
||||
### Definicao de pronto
|
||||
- Build estavel, testes principais passando e documentacao atualizada.
|
||||
- Arquitetura mais simples para manutencao.
|
||||
|
||||
### Risco
|
||||
- Baixo.
|
||||
|
||||
---
|
||||
|
||||
## Criterios tecnicos transversais (todas as fases)
|
||||
|
||||
- Nao quebrar fluxo de login, registro e dispatcher.
|
||||
- Toda regra de permissao deve existir no servidor.
|
||||
- Alteracoes pequenas por PR, com escopo fechado.
|
||||
- Sempre validar com build + fluxo manual das telas afetadas.
|
||||
|
||||
---
|
||||
|
||||
## Checklist de execucao por fase
|
||||
|
||||
Use este mini-checklist ao iniciar cada fase:
|
||||
|
||||
1. Definir escopo exato da fase (o que entra e o que nao entra).
|
||||
2. Criar tarefas tecnicas pequenas (max 1-2 dias cada).
|
||||
3. Implementar em branch dedicada.
|
||||
4. Rodar `npm run lint` e `npm run build`.
|
||||
5. Validar fluxo funcional manual.
|
||||
6. Atualizar este documento marcando itens concluidos.
|
||||
|
||||
---
|
||||
|
||||
## Proxima acao recomendada
|
||||
|
||||
Fechar a **Fase 4** (migracao de login/register/dashboards para componentes `ui` + padrao visual),
|
||||
e em seguida executar um passe objetivo da **Fase 6** para remover logs legados e fallbacks de API nao utilizados.
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 13'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Script para criar usuário administrador
|
||||
*
|
||||
* Uso:
|
||||
* node scripts/create-admin.js [username] [password] [email] [fullName]
|
||||
*
|
||||
* Exemplo:
|
||||
* node scripts/create-admin.js admin admin1234 [email protected] "Administrador"
|
||||
*/
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import mongoose from "mongoose";
|
||||
import dotenv from "dotenv";
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
function normalizeName(value) {
|
||||
if (!value || typeof value !== "string") return "";
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Carregar .env do diretório raiz
|
||||
dotenv.config({ path: join(__dirname, '../.env') });
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI || process.env.MONGO_URI || "mongodb://localhost:27017/course-plat";
|
||||
|
||||
// Schema do User (replicado para não depender do Next.js)
|
||||
const UserSchema = new mongoose.Schema({
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
},
|
||||
passwordHash: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
unique: true,
|
||||
sparse: true,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
},
|
||||
fullName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
dateOfBirth: {
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
roles: {
|
||||
type: [String],
|
||||
enum: ["student", "parent", "guardian", "teacher", "admin"],
|
||||
required: true,
|
||||
default: ["student"],
|
||||
},
|
||||
guardiansAccounts: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "User",
|
||||
}],
|
||||
wardAccounts: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "User",
|
||||
}],
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
modifiedAt: { type: Date, default: Date.now },
|
||||
}, {
|
||||
collection: 'users' // Garantir que use a coleção correta
|
||||
});
|
||||
|
||||
async function createAdmin(username, password, email, fullName) {
|
||||
try {
|
||||
console.log("Conectando ao MongoDB...");
|
||||
console.log(`URI: ${MONGODB_URI.replace(/:[^:@]*@/, ':****@')}`);
|
||||
await mongoose.connect(MONGODB_URI);
|
||||
console.log("✓ Conectado!");
|
||||
|
||||
const User = mongoose.model('User', UserSchema);
|
||||
|
||||
const existingUser = await User.findOne({ username });
|
||||
if (existingUser) {
|
||||
// Adiciona role admin ao usuário existente
|
||||
await User.updateOne(
|
||||
{ username },
|
||||
{ $addToSet: { roles: "admin" } }
|
||||
);
|
||||
console.log(`✓ Usuário "${username}" atualizado com role admin!`);
|
||||
} else {
|
||||
// Cria novo usuário admin
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
await User.create({
|
||||
username,
|
||||
passwordHash,
|
||||
email,
|
||||
fullName,
|
||||
dateOfBirth: new Date("1990-01-01"),
|
||||
roles: ["admin"],
|
||||
});
|
||||
console.log(`✓ Usuário admin "${username}" criado com sucesso!`);
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Senha: ${password}`);
|
||||
}
|
||||
|
||||
// Verificar
|
||||
const user = await User.findOne({ username });
|
||||
console.log(`\nRoles atuais: ${user.roles.join(", ")}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Erro ao criar admin:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Args da linha de comando ou valores padrão
|
||||
const args = process.argv.slice(2);
|
||||
const username = normalizeName(args[0] || "admin").replace(/\s+/g, "");
|
||||
const password = args[1] || "admin1234";
|
||||
const email = args[2] || "[email protected]";
|
||||
const fullName = normalizeName(args[3] || "Administrador");
|
||||
|
||||
console.log(`\nCriando admin: ${username}`);
|
||||
console.log(`Senha: ${password}`);
|
||||
console.log(`Email: ${email}\n`);
|
||||
|
||||
createAdmin(username, password, email, fullName);
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* One-time migration script to assign colorIndex to existing categories
|
||||
* Run with: node scripts/migrate-category-colors.js
|
||||
*/
|
||||
|
||||
const { MongoClient } = require('mongodb');
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/course-plat';
|
||||
const DB_NAME = 'course-plat'; // Adjust if needed
|
||||
|
||||
async function migrate() {
|
||||
const client = new MongoClient(MONGODB_URI);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log('Connected to MongoDB');
|
||||
|
||||
const db = client.db(DB_NAME);
|
||||
const categories = db.collection('categories');
|
||||
|
||||
// Find all categories without colorIndex
|
||||
const withoutColor = await categories.find({ colorIndex: { $exists: false } }).toArray();
|
||||
|
||||
if (withoutColor.length === 0) {
|
||||
console.log('All categories already have colorIndex. Nothing to migrate.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${withoutColor.length} categories without colorIndex.`);
|
||||
|
||||
// Get current max colorIndex
|
||||
const withColor = await categories.find({ colorIndex: { $exists: true } })
|
||||
.sort({ colorIndex: -1 })
|
||||
.limit(1)
|
||||
.toArray();
|
||||
|
||||
let startIndex = withColor.length > 0 ? (withColor[0].colorIndex + 1) : 0;
|
||||
|
||||
// Update each category
|
||||
for (const cat of withoutColor) {
|
||||
const colorIndex = startIndex % 10; // Cycle through 10 colors
|
||||
await categories.updateOne(
|
||||
{ _id: cat._id },
|
||||
{ $set: { colorIndex: colorIndex } }
|
||||
);
|
||||
console.log(`Updated "${cat.name}" -> colorIndex: ${colorIndex}`);
|
||||
startIndex++;
|
||||
}
|
||||
|
||||
console.log('Migration complete!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await client.close();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { useActionState } from "react";
|
||||
import saveAffiliateProductAction from "@/app/lib/affiliateProducts/saveAffiliateProductAction";
|
||||
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { XMarkIcon, PhotoIcon } from "@heroicons/react/24/outline";
|
||||
import { getAllClassItems } from "@/app/lib/helpers/getItems";
|
||||
import { MAX_FILE_SIZE } from "@/app/lib/constants";
|
||||
|
||||
function AffiliateProductForm({ product = {}, classTypes = [], categories = [], onCancel }) {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const [showMessage, setShowMessage] = useState(true);
|
||||
const [selectedClassTypes, setSelectedClassTypes] = useState(
|
||||
product.classTypes?.map((ct) => ct.toString()) || []
|
||||
);
|
||||
const [imageInputType, setImageInputType] = useState(
|
||||
product.imageUrl?.startsWith("http") ? "url" : "upload"
|
||||
);
|
||||
const [imagePreview, setImagePreview] = useState(product.imageUrl || "");
|
||||
const [uploadedFileName, setUploadedFileName] = useState("");
|
||||
|
||||
const initialState = {
|
||||
success: false,
|
||||
message: null,
|
||||
};
|
||||
|
||||
const [state, action, isPending] = useActionState(
|
||||
saveAffiliateProductAction,
|
||||
initialState
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.message) {
|
||||
setShowMessage(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowMessage(false);
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state.message]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.success && state.redirectTo) {
|
||||
const timer = setTimeout(() => {
|
||||
router.push(state.redirectTo);
|
||||
}, 1500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state?.success, state?.redirectTo, router]);
|
||||
|
||||
const handleClassTypeToggle = (classTypeId) => {
|
||||
setSelectedClassTypes((prev) => {
|
||||
if (prev.includes(classTypeId)) {
|
||||
return prev.filter((id) => id !== classTypeId);
|
||||
} else {
|
||||
return [...prev, classTypeId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||
alert(`Arquivo "${file.name}" (${sizeMB}MB) excede o limite de 500MB.`);
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
setUploadedFileName(file.name);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const clearImage = () => {
|
||||
setImagePreview("");
|
||||
setUploadedFileName("");
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
{state?.message && showMessage && (
|
||||
<FlashMessage
|
||||
message={state?.message}
|
||||
type={state.success ? "success" : "error"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<form
|
||||
action={action}
|
||||
className="bg-white dark:bg-neutral-800 p-8 rounded-lg shadow-md"
|
||||
>
|
||||
{isPending && <p className="text-center mb-4">Carregando...</p>}
|
||||
|
||||
{product._id && (
|
||||
<input type="hidden" name="_id" value={product._id} />
|
||||
)}
|
||||
|
||||
{/* Hidden inputs for selected class types */}
|
||||
{selectedClassTypes.map((classTypeId) => (
|
||||
<input
|
||||
key={classTypeId}
|
||||
type="hidden"
|
||||
name="classTypeIds"
|
||||
value={classTypeId}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
|
||||
Título <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
defaultValue={product.title || ""}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
|
||||
placeholder="Ex: Livro de Gramática Essential"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
|
||||
Descrição
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
defaultValue={product.description || ""}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
|
||||
rows="3"
|
||||
placeholder="Breve descrição do produto..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{/* Image - Upload or URL */}
|
||||
<div>
|
||||
<label className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
|
||||
Imagem do Produto
|
||||
</label>
|
||||
|
||||
{/* Toggle between upload and URL */}
|
||||
<div className="flex gap-4 mb-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="imageInputType"
|
||||
value="upload"
|
||||
checked={imageInputType === "upload"}
|
||||
onChange={() => setImageInputType("upload")}
|
||||
className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">Fazer upload</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="imageInputType"
|
||||
value="url"
|
||||
checked={imageInputType === "url"}
|
||||
onChange={() => setImageInputType("url")}
|
||||
className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">Usar URL</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Upload option */}
|
||||
{imageInputType === "upload" && (
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id="imageFile"
|
||||
name="imageFile"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
|
||||
/>
|
||||
{uploadedFileName && (
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Arquivo selecionado: {uploadedFileName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL option */}
|
||||
{imageInputType === "url" && (
|
||||
<input
|
||||
type="url"
|
||||
id="imageUrl"
|
||||
name="imageUrl"
|
||||
defaultValue={product.imageUrl || ""}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Image Preview */}
|
||||
{imagePreview && (
|
||||
<div className="mt-3 relative">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
className="w-32 h-32 object-cover rounded-lg border border-neutral-200 dark:border-neutral-700"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearImage}
|
||||
className="absolute -top-2 -right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
|
||||
title="Remover imagem"
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Affiliate URL */}
|
||||
<div>
|
||||
<label htmlFor="affiliateUrl" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
|
||||
Link de Afiliado <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="affiliateUrl"
|
||||
name="affiliateUrl"
|
||||
defaultValue={product.affiliateUrl || ""}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
|
||||
placeholder="https://amazon.com.br/..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Price and Category */}
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
|
||||
Categoria
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="category"
|
||||
name="category"
|
||||
defaultValue={product.category || ""}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-700"
|
||||
placeholder="Ex: Livros, Materiais, etc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="active"
|
||||
name="active"
|
||||
value="true"
|
||||
defaultChecked={product.active !== undefined ? product.active : true}
|
||||
className="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||||
/>
|
||||
<label htmlFor="active" className="text-neutral-700 dark:text-neutral-200 text-sm font-medium">
|
||||
Produto ativo
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Class Types */}
|
||||
{classTypes.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
|
||||
Tipos de Turma Relacionados
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto p-3 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-neutral-50 dark:bg-neutral-700">
|
||||
{classTypes.map((classType) => (
|
||||
<button
|
||||
key={classType._id}
|
||||
type="button"
|
||||
onClick={() => handleClassTypeToggle(classType._id)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
||||
selectedClassTypes.includes(classType._id)
|
||||
? "bg-indigo-600 text-white border-indigo-600"
|
||||
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600 hover:border-indigo-400"
|
||||
}`}
|
||||
>
|
||||
{classType.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
O produto será exibido para estas turmas
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-neutral-700 dark:text-neutral-300 font-medium rounded-lg border border-neutral-300 dark:border-neutral-600 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isPending ? "Salvando..." : "Salvar"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AffiliateProductForm;
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { deleteAffiliateProductAction } from "@/app/lib/affiliateProducts/deleteAffiliateProductAction";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FaEdit, FaTrash } from "react-icons/fa";
|
||||
import Label from "@/app/(protected)/components/shared/Label";
|
||||
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
|
||||
|
||||
export default function AffiliateProductsTable({ products = [] }) {
|
||||
const initialState = { success: false, message: null };
|
||||
const [state, action, isPending] = useActionState(
|
||||
deleteAffiliateProductAction,
|
||||
initialState
|
||||
);
|
||||
const [showMessage, setShowMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.message) {
|
||||
setShowMessage(true);
|
||||
const timer = setTimeout(() => setShowMessage(false), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state?.message]);
|
||||
|
||||
return (
|
||||
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
|
||||
{showMessage && state?.message && (
|
||||
<div className="px-6 pt-4">
|
||||
<FlashMessage
|
||||
message={state.message}
|
||||
type={state.success ? "success" : "error"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs align-top">
|
||||
<tr>
|
||||
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
|
||||
Produto
|
||||
</th>
|
||||
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell text-left">
|
||||
Categoria
|
||||
</th>
|
||||
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center whitespace-nowrap w-px">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800 align-top">
|
||||
{products.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="4" className="px-6 py-8 text-center text-neutral-500 dark:text-neutral-400">
|
||||
Nenhum produto cadastrado ainda.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
products.map((product) => (
|
||||
<tr
|
||||
key={product._id}
|
||||
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-3.5 text-neutral-900 dark:text-neutral-100 align-top text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
{product.imageUrl && (
|
||||
<img
|
||||
src={product.imageUrl}
|
||||
alt={product.title}
|
||||
className="w-12 h-12 rounded-lg object-cover border border-neutral-200 dark:border-neutral-700"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{product.title}</p>
|
||||
{product.description && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 line-clamp-1">
|
||||
{product.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 hidden md:table-cell text-neutral-700 dark:text-neutral-300 align-top">
|
||||
{product.category || "-"}
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-center align-top">
|
||||
{product.active ? (
|
||||
<Label color="emerald" size="sm">Ativo</Label>
|
||||
) : (
|
||||
<Label color="red" size="sm">Inativo</Label>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 align-top whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/admin/dashboard/affiliate-products/edit/${product._id.toString()}`}
|
||||
title="Editar"
|
||||
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
|
||||
>
|
||||
<FaEdit className="text-lg" />
|
||||
</Link>
|
||||
<form action={action} className="inline">
|
||||
<input
|
||||
type="hidden"
|
||||
name="_id"
|
||||
value={product._id || "nada"}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
title="Excluir"
|
||||
disabled={isPending}
|
||||
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={(e) => {
|
||||
if (!confirm('Tem certeza que deseja deletar este produto?')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<FaTrash className="text-lg" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import AffiliateProductForm from "../AffiliateProductForm";
|
||||
import { getAllClassItems } from "@/app/lib/helpers/getItems";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export default async function AddAffiliateProductPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.roles?.includes("admin")) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const classTypes = await getAllClassItems();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<PageHeader
|
||||
title="Adicionar Produto de Afiliado"
|
||||
subtitle="Cadastre um novo produto de afiliado"
|
||||
/>
|
||||
<AffiliateProductForm classTypes={classTypes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import AffiliateProductForm from "@/app/(protected)/admin/dashboard/affiliate-products/AffiliateProductForm";
|
||||
import { getAffiliateProductById } from "@/app/lib/affiliateProducts/getAffiliateProductsAction";
|
||||
import { getAllClassItems } from "@/app/lib/helpers/getItems";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
|
||||
|
||||
export default async function EditAffiliateProductPage({ params }) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.roles?.includes("admin")) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const awaitedParams = await params;
|
||||
const id = awaitedParams.id;
|
||||
|
||||
if (!isValidObjectId(id)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const productResult = await getAffiliateProductById(id);
|
||||
const product = productResult.success ? productResult.data : null;
|
||||
const classTypes = await getAllClassItems();
|
||||
|
||||
if (!product) {
|
||||
redirect("/admin/dashboard/affiliate-products");
|
||||
}
|
||||
|
||||
product._id = product._id.toString();
|
||||
|
||||
return (
|
||||
<div className="w-full mt-3">
|
||||
<PageHeader
|
||||
title="Editar Produto de Afiliado"
|
||||
subtitle="Atualize as informações do produto"
|
||||
/>
|
||||
<div className="flex justify-center">
|
||||
<AffiliateProductForm product={product} classTypes={classTypes} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Link from "next/link";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import AffiliateProductsTable from "./AffiliateProductsTable";
|
||||
import { getAllAffiliateProductsForAdmin } from "@/app/lib/affiliateProducts/getAffiliateProductsAction";
|
||||
|
||||
export default async function AffiliateProductsPage() {
|
||||
const result = await getAllAffiliateProductsForAdmin();
|
||||
const products = result.success ? result.data : [];
|
||||
|
||||
return (
|
||||
<div className="w-full mt-3">
|
||||
<PageHeader
|
||||
title="Gerenciar Produtos de Afiliados"
|
||||
subtitle="Gerencie os produtos de afiliados que serão exibidos para os estudantes"
|
||||
actions={
|
||||
<Link href="/admin/dashboard/affiliate-products/add">
|
||||
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
|
||||
+ Adicionar Produto
|
||||
</button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<AffiliateProductsTable products={products} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { getExamAssignmentById } from "@/app/lib/actions/examActions";
|
||||
import MainSection from "@/app/(protected)/components/shared/Main";
|
||||
import ExamResults from "@/app/(protected)/components/teacher/ExamResults";
|
||||
import { auth } from "@/app/lib/utils/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import NotAuthorized from "@/app/auth/components/NotAuthorized";
|
||||
import { getClassModel } from "@/app/models/Class";
|
||||
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
|
||||
|
||||
export default async function AdminAssignmentResultsPage({ params }) {
|
||||
const { id: assignmentId } = await params || {};
|
||||
|
||||
if (!isValidObjectId(assignmentId)) {
|
||||
return (
|
||||
<MainSection>
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
|
||||
</div>
|
||||
</MainSection>
|
||||
);
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userRoles = Array.isArray(session.user.roles) ? session.user.roles : [];
|
||||
const isAdmin =
|
||||
userRoles.includes("admin") ||
|
||||
userRoles.includes("superadmin") ||
|
||||
session.user.role === "admin" ||
|
||||
session.user.role === "superadmin";
|
||||
if (!isAdmin) {
|
||||
return <NotAuthorized />;
|
||||
}
|
||||
|
||||
const result = await getExamAssignmentById(assignmentId);
|
||||
if (!result.success) {
|
||||
return (
|
||||
<MainSection>
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Resultados da prova
|
||||
</h1>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-2">
|
||||
{result.error || "Não foi possível carregar os resultados desta prova."}
|
||||
</p>
|
||||
</div>
|
||||
</MainSection>
|
||||
);
|
||||
}
|
||||
const assignment = result.success ? result.data : null;
|
||||
|
||||
// Check if current user is a teacher of the class
|
||||
let canGrade = false;
|
||||
if (session?.user?.id && assignment?.classId) {
|
||||
const Class = await getClassModel();
|
||||
const classRef = assignment.classId?._id || assignment.classId;
|
||||
const classData = await Class.findById(classRef).lean();
|
||||
const teacherIds = classData?.teachers?.map((t) => t.toString()) || [];
|
||||
canGrade = teacherIds.includes(session.user.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<MainSection>
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{assignment?.title || "Prova"}
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
Resultados e correção das provas
|
||||
</p>
|
||||
</div>
|
||||
<ExamResults assignmentId={assignmentId} assignmentTitle={assignment?.title} canGrade={canGrade} />
|
||||
</div>
|
||||
</MainSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getExamAssignments, getExamTemplates, getClasses } from '@/app/lib/actions/examActions';
|
||||
import AssignmentList from '@/app/(protected)/components/teacher/AssignmentList';
|
||||
import AssignmentFormWrapper from '@/app/(protected)/components/exam-templates/AssignmentFormWrapper';
|
||||
|
||||
export default async function AssignmentsPage() {
|
||||
const [assignmentsResult, templatesResult, classesResult] = await Promise.all([
|
||||
getExamAssignments(),
|
||||
getExamTemplates(),
|
||||
getClasses()
|
||||
]);
|
||||
|
||||
const assignments = assignmentsResult.success ? assignmentsResult.data : [];
|
||||
const templates = templatesResult.success ? templatesResult.data : [];
|
||||
const classes = classesResult.success ? classesResult.data : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Exam Assignments</h1>
|
||||
</div>
|
||||
|
||||
<AssignmentList
|
||||
initialAssignments={assignments}
|
||||
basePath="/admin/dashboard"
|
||||
/>
|
||||
|
||||
<AssignmentFormWrapper templates={templates} classes={classes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { deleteCategory } from "@/app/lib/categories/deleteCategory";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FaEdit, FaTrash } from "react-icons/fa";
|
||||
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
|
||||
|
||||
export default function CategoriesTable({ categories }) {
|
||||
const initialState = { success: false, message: null };
|
||||
const [state, action, isPending] = useActionState(
|
||||
deleteCategory,
|
||||
initialState
|
||||
);
|
||||
const [showMessage, setShowMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.message) {
|
||||
setShowMessage(true);
|
||||
const timer = setTimeout(() => setShowMessage(false), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state?.message]);
|
||||
|
||||
return (
|
||||
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
|
||||
{showMessage && state?.message && (
|
||||
<div className="px-6 pt-4">
|
||||
<FlashMessage
|
||||
message={state.message}
|
||||
type={state.success ? "success" : "error"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs">
|
||||
<tr>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
Nome da Categoria
|
||||
</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell">
|
||||
Descrição
|
||||
</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-right">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{categories.map((category) => (
|
||||
<tr
|
||||
key={category._id}
|
||||
className="hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 text-neutral-900 dark:text-neutral-100 font-medium">
|
||||
{category.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300">
|
||||
{category.description || "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
<Link
|
||||
href={`/admin/dashboard/categories/edit/${category._id}`}
|
||||
title="Editar"
|
||||
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
|
||||
>
|
||||
<FaEdit className="text-lg" />
|
||||
</Link>
|
||||
<form action={action} className="inline">
|
||||
<input
|
||||
type="hidden"
|
||||
name="categoryId"
|
||||
value={category._id || "nada"}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
title="Excluir"
|
||||
disabled={isPending}
|
||||
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={(e) => {
|
||||
if (!confirm('Tem certeza que deseja deletar esta categoria?')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<FaTrash className="text-lg" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useActionState } from "react";
|
||||
import saveCategoryAction from "@/app/lib/categories/saveCategoryAction";
|
||||
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function CategoryForm({ category = {}, onCancel }) {
|
||||
const router = useRouter();
|
||||
|
||||
const [showMessage, setShowMessage] = useState(true);
|
||||
const initialState = {
|
||||
success: false,
|
||||
message: null,
|
||||
};
|
||||
|
||||
const [state, action, isPending] = useActionState(
|
||||
saveCategoryAction,
|
||||
initialState
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.message) {
|
||||
setShowMessage(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowMessage(false);
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state.message]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.success) {
|
||||
router.push("/admin/dashboard/categories");
|
||||
}
|
||||
}, [state?.success, router]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{state?.message && showMessage && (
|
||||
<div className="max-w-screen-xl mx-auto w-full">
|
||||
<FlashMessage
|
||||
message={state?.message}
|
||||
type={state.success ? "success" : "error"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
action={action}
|
||||
className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md max-w-lg mx-auto"
|
||||
>
|
||||
{isPending && <p>Carregando...</p>}
|
||||
|
||||
{category._id && (
|
||||
<input type="hidden" name="_id" value={category._id} />
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2">
|
||||
Nome da Categoria:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
defaultValue={category.name || ""}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-gray-700 dark:border-gray-600"
|
||||
placeholder="Ex: Documentos, Imagens, Vídeos"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="description" className="block text-gray-700 dark:text-gray-300 text-sm font-bold mb-2">
|
||||
Descrição:
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
defaultValue={category.description || ""}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 dark:text-gray-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-gray-700 dark:border-gray-600"
|
||||
rows="3"
|
||||
placeholder="Descrição opcional da categoria..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg focus:outline-none focus:shadow-outline disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
{isPending ? "Salvando..." : "Salvar"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="bg-gray-500 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded-lg focus:outline-none focus:shadow-outline transition-colors duration-200"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CategoryForm;
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import CategoryForm from "../CategoryForm";
|
||||
|
||||
function AddCategory() {
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<PageHeader title="Adicionar Categoria" subtitle="Crie uma nova categoria de arquivos" />
|
||||
<CategoryForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddCategory;
|
||||
@@ -0,0 +1,35 @@
|
||||
import CategoryForm from "@/app/(protected)/admin/dashboard/categories/CategoryForm";
|
||||
import { getCategoryById } from "@/app/lib/helpers/getItems";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
|
||||
|
||||
export default async function EditCategoryPage({ params }) {
|
||||
const awaitedParams = await params;
|
||||
const id = awaitedParams.id;
|
||||
|
||||
if (!isValidObjectId(id)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const category = await getCategoryById(id);
|
||||
|
||||
if (!category) {
|
||||
return <div>Categoria não encontrada</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full mt-3">
|
||||
<PageHeader
|
||||
title="Editar Categoria"
|
||||
subtitle="Atualize as informações da categoria"
|
||||
/>
|
||||
<div className="flex justify-center">
|
||||
<CategoryForm category={category} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import CategoriesTable from "./CategoriesTable";
|
||||
import { getAllCategories } from "@/app/lib/helpers/getItems";
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
const categories = await getAllCategories();
|
||||
|
||||
return (
|
||||
<div className="w-full mt-3">
|
||||
<PageHeader
|
||||
title="Gerenciar Categorias de Arquivos"
|
||||
subtitle="Organize e gerencie as categorias de arquivos do sistema"
|
||||
actions={
|
||||
<Link href={`/admin/dashboard/categories/add`}>
|
||||
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
|
||||
+ Adicionar Nova Categoria
|
||||
</button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<CategoriesTable categories={categories} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import ClassForm from "@/app/(protected)/admin/dashboard/class/components/ClassForm";
|
||||
import { getUsersByRole } from "@/app/lib/users/getUsersByRole";
|
||||
import { getClassTypes } from "@/app/lib/classes/getClassTypes";
|
||||
|
||||
async function AddClass() {
|
||||
const teachersResult = await getUsersByRole(["teacher"]);
|
||||
const teachers = teachersResult.success ? teachersResult.data : [];
|
||||
const studentsResult = await getUsersByRole(["student"]);
|
||||
const students = studentsResult.success ? studentsResult.data : [];
|
||||
const classTypesResult = await getClassTypes();
|
||||
const classTypes = classTypesResult.success ? classTypesResult.data : [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full px-4 sm:px-6 lg:px-8">
|
||||
<PageHeader title="Adicionar Turma" subtitle="Crie uma nova turma no sistema" />
|
||||
<ClassForm
|
||||
classTypes={classTypes}
|
||||
teachers={teachers}
|
||||
students={students}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddClass;
|
||||
@@ -0,0 +1,417 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useActionState } from "react";
|
||||
import saveClassAction from "@/app/lib/classes/saveClassAction";
|
||||
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
|
||||
import RoleCheckbox from "@/app/(protected)/components/shared/RoleCheckbox";
|
||||
import FormCheckbox from "./FormCheckbox";
|
||||
import { DAYS } from "@/app/lib/utils/days";
|
||||
|
||||
function ClassForm({
|
||||
classData = {},
|
||||
onCancel,
|
||||
classTypes = [],
|
||||
teachers = [],
|
||||
students = [],
|
||||
}) {
|
||||
const initialState = { success: false, message: null };
|
||||
|
||||
const [state, action, isPending] = useActionState(
|
||||
saveClassAction,
|
||||
initialState
|
||||
);
|
||||
const [showMessage, setShowMessage] = useState(false);
|
||||
|
||||
const initialData = classData
|
||||
? {
|
||||
...classData,
|
||||
classType:
|
||||
typeof classData.classType === "object"
|
||||
? String(classData.classType?._id ?? "")
|
||||
: String(classData.classType ?? ""),
|
||||
startDate: classData.startDate?.split("T")[0],
|
||||
endDate: classData.endDate?.split("T")[0],
|
||||
}
|
||||
: {};
|
||||
|
||||
const [inputs, setInputs] = useState(state?.inputs || initialData || {});
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.inputs) {
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
...state.inputs,
|
||||
classType: String(state.inputs.classType ?? ""),
|
||||
}));
|
||||
}
|
||||
}, [state?.inputs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.message) {
|
||||
setShowMessage(true);
|
||||
const timer = setTimeout(() => setShowMessage(false), 15000);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setShowMessage(false);
|
||||
}
|
||||
}, [state.message]);
|
||||
|
||||
const onClassTypeChange = (e) => {
|
||||
const value = String(e.target.value);
|
||||
const ct = classTypes.find((x) => String(x._id) === e.target.value);
|
||||
setInputs((prev) => {
|
||||
const next = { ...prev, classType: value, price: ct?.price ?? "" };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onCheckboxChange = (field, value) => (e) => {
|
||||
const currentArray = inputs[field] || [];
|
||||
if (e.target.checked) {
|
||||
setInputs({ ...inputs, [field]: [...currentArray, value] });
|
||||
} else {
|
||||
setInputs({ ...inputs, [field]: currentArray.filter((v) => v !== value) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* FlashMessage outside form for better visibility */}
|
||||
{state?.message && showMessage && (
|
||||
<div className="max-w-4xl mx-auto mb-4">
|
||||
<FlashMessage
|
||||
message={state?.message}
|
||||
type={state.success ? "success" : "error"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={action} className="max-w-4xl mx-auto">
|
||||
{isPending && (
|
||||
<div className="mb-4 p-4 bg-neutral-100 dark:bg-neutral-800 rounded-lg text-center">
|
||||
<p className="text-neutral-600 dark:text-neutral-300">Salvando...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{classData._id && <input type="hidden" name="_id" value={classData._id} />}
|
||||
|
||||
{/* Hidden inputs for form submission */}
|
||||
{(inputs.teachers || []).map((t) => (
|
||||
<input key={`teacher-${t}`} type="hidden" name="teachers" value={t} />
|
||||
))}
|
||||
{(inputs.students || []).map((s) => (
|
||||
<input key={`student-${s}`} type="hidden" name="students" value={s} />
|
||||
))}
|
||||
{(inputs.schedule?.days || []).map((d) => (
|
||||
<input key={`day-${d}`} type="hidden" name="days" value={d} />
|
||||
))}
|
||||
<input type="hidden" name="status" value="active" />
|
||||
|
||||
{/* Main Form - Grid Layout */}
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-xl border border-neutral-200 dark:border-neutral-700 shadow-sm overflow-hidden">
|
||||
|
||||
{/* Header Section */}
|
||||
<div className="bg-neutral-50 dark:bg-neutral-900/50 px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{classData._id ? "Editar Turma" : "Nova Turma"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="p-6">
|
||||
{/* 2-Column Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
{/* Tipo de Classe */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="classType" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
|
||||
Tipo de Classe <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="classType"
|
||||
name="classType"
|
||||
required
|
||||
value={inputs?.classType || ""}
|
||||
onChange={onClassTypeChange}
|
||||
className="select-contrast w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{classTypes.map((ct) => {
|
||||
const id = typeof ct._id === "string" ? ct._id : String(ct._id);
|
||||
return (
|
||||
<option key={id} value={id}>
|
||||
{ct.name || ct.title || ct.label || id}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Título */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="classTitle" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
|
||||
Título <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="classTitle"
|
||||
name="classTitle"
|
||||
value={inputs?.classTitle || ""}
|
||||
onChange={(e) => setInputs({ ...inputs, classTitle: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Ex: Starters 1 - 2025"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data de Início */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="startDate" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
|
||||
Data de Início <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
lang="pt-BR"
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
value={inputs?.startDate || ""}
|
||||
onChange={(e) => setInputs({ ...inputs, startDate: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data de Término */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="endDate" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
|
||||
Data de Término
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
lang="pt-BR"
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
value={inputs?.endDate || ""}
|
||||
onChange={(e) => setInputs({ ...inputs, endDate: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Horário */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="time" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
|
||||
Horário <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
id="time"
|
||||
name="time"
|
||||
value={inputs?.schedule?.time || ""}
|
||||
onChange={(e) => setInputs({ ...inputs, schedule: { ...inputs.schedule, time: e.target.value } })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mensalidade */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="price" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
|
||||
Mensalidade
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="price"
|
||||
name="price"
|
||||
value={inputs?.price || ""}
|
||||
onChange={(e) => setInputs({ ...inputs, price: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Ex: 50,00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Link */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label htmlFor="link" className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
|
||||
Link (Zoom, Meet, etc.)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="link"
|
||||
name="link"
|
||||
value={inputs?.link || ""}
|
||||
onChange={(e) => setInputs({ ...inputs, link: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Professores - Full Width */}
|
||||
<div className="mt-6 space-y-3">
|
||||
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
|
||||
Professores <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{teachers.length === 0 ? (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
Nenhum professor cadastrado.
|
||||
</p>
|
||||
) : (
|
||||
teachers.map((teacher) => {
|
||||
const id = typeof teacher._id === "string" ? teacher._id : String(teacher._id);
|
||||
const isChecked = (inputs.teachers || []).some((t) => (typeof t === "string" ? t : String(t)) === id);
|
||||
return (
|
||||
<RoleCheckbox
|
||||
key={id}
|
||||
value={id}
|
||||
label={teacher.fullName}
|
||||
color="indigo"
|
||||
checked={isChecked}
|
||||
onChange={onCheckboxChange("teachers", id)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alunos - Select Multi with Search (for many students) */}
|
||||
<div className="mt-6 space-y-3">
|
||||
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
|
||||
Alunos
|
||||
</label>
|
||||
|
||||
{/* Selected Students as Removable Tags */}
|
||||
{(inputs.students || []).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-700">
|
||||
{inputs.students.map((studentId) => {
|
||||
const idStr = typeof studentId === "string" ? studentId : String(studentId);
|
||||
const student = students.find((s) => (typeof s._id === "string" ? s._id : String(s._id)) === idStr);
|
||||
return (
|
||||
<span
|
||||
key={idStr}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium ${
|
||||
student ? "student-tag" : "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-300 dark:border-red-700"
|
||||
}`}
|
||||
>
|
||||
{student ? student.fullName : `ID: ${idStr.slice(-6)}… (removido)`}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setInputs({
|
||||
...inputs,
|
||||
students: (inputs.students || []).filter((s) => (typeof s === "string" ? s : String(s)) !== idStr)
|
||||
});
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Students */}
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
setInputs({
|
||||
...inputs,
|
||||
students: [...(inputs.students || []), e.target.value]
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="select-contrast flex-1 h-10 px-3 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="">Adicionar aluno...</option>
|
||||
{students
|
||||
.filter((s) => !(inputs.students || []).some((selectedId) => (typeof s._id === "string" ? s._id : String(s._id)) === (typeof selectedId === "string" ? selectedId : String(selectedId))))
|
||||
.map((student) => {
|
||||
const id = typeof student._id === "string" ? student._id : String(student._id);
|
||||
return (
|
||||
<option key={id} value={id}>
|
||||
{student.fullName}
|
||||
</option>
|
||||
);
|
||||
})
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{students.length === 0 && (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Nenhum aluno cadastrado.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dias da Semana - Full Width */}
|
||||
<div className="mt-6 space-y-3">
|
||||
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-200">
|
||||
Dias da Semana <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{DAYS.map((day) => {
|
||||
const isChecked = (inputs.schedule?.days || []).includes(day);
|
||||
return (
|
||||
<RoleCheckbox
|
||||
key={day}
|
||||
value={day}
|
||||
label={day}
|
||||
color="amber"
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
const currentDays = inputs.schedule?.days || [];
|
||||
const newDays = e.target.checked
|
||||
? [...currentDays, day]
|
||||
: currentDays.filter((d) => d !== day);
|
||||
setInputs({ ...inputs, schedule: { ...inputs.schedule, days: newDays } });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Herdar Arquivos - Only for new classes */}
|
||||
{!classData._id && (
|
||||
<div className="mt-6">
|
||||
<FormCheckbox
|
||||
name="inheritFiles"
|
||||
label="Herdar arquivos do tipo de turma"
|
||||
description="Ao criar a turma, herdam-se os arquivos daquele tipo de classe."
|
||||
checked={inputs?.inheritFiles ?? true}
|
||||
onChange={(e) => setInputs({ ...inputs, inheritFiles: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="bg-neutral-50 dark:bg-neutral-900/50 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-6 py-2.5 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-200 text-sm font-medium hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="px-6 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isPending ? "Salvando..." : "Salvar"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClassForm;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ClassRow } from "@/app/(protected)/admin/dashboard/class/components/classRow";
|
||||
|
||||
export default function ClassesTable({classes}) {
|
||||
|
||||
return (
|
||||
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs">
|
||||
<tr>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">Título</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell">Professores</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden lg:table-cell">Início</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-center">Status</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{classes.map((classItem) => (
|
||||
<ClassRow
|
||||
key={classItem._id}
|
||||
classData={classItem}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
ComboboxButton,
|
||||
} from "@headlessui/react";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/solid";
|
||||
import { DAYS } from "@/app/lib/utils/days";
|
||||
|
||||
export default function DaysMultiSelect({ defaultSelectedDays = [], onRemove }) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [selected, setSelected] = useState(
|
||||
DAYS.filter((d) => defaultSelectedDays.includes(d))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const newSelected = DAYS.filter((d) => defaultSelectedDays.includes(d));
|
||||
setSelected(newSelected);
|
||||
}, [defaultSelectedDays]);
|
||||
|
||||
const filtered =
|
||||
query === ""
|
||||
? DAYS
|
||||
: DAYS.filter((day) =>
|
||||
day.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
const handleRemove = (day) => {
|
||||
setSelected((prev) => prev.filter((d) => d !== day));
|
||||
onRemove && onRemove(day);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
|
||||
Dias da Semana <span className="text-red-500">*</span>:
|
||||
</label>
|
||||
|
||||
<Combobox
|
||||
multiple
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
onClose={() => setQuery("")}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="relative max-w-lg cursor-default overflow-hidden rounded border
|
||||
border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700
|
||||
text-left focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 p-2">
|
||||
{selected.map((d) => (
|
||||
<span
|
||||
key={d}
|
||||
className="flex items-center gap-1 rounded-full bg-indigo-600 px-2 py-0.5 text-xs
|
||||
text-white dark:bg-indigo-900/40 dark:text-indigo-300"
|
||||
>
|
||||
{d}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 hover:bg-indigo-200 dark:hover:bg-indigo-800"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleRemove(d)}
|
||||
aria-label={`Remover ${d}`}
|
||||
>
|
||||
<CheckIcon className="h-3 w-3 rotate-45" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ComboboxInput
|
||||
aria-label="days"
|
||||
className="w-full border-none py-2 pl-3 pr-8 text-neutral-900 dark:text-neutral-200 bg-transparent focus:outline-none"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Selecione os dias..."
|
||||
/>
|
||||
|
||||
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-neutral-400" />
|
||||
</ComboboxButton>
|
||||
</div>
|
||||
|
||||
<ComboboxOptions
|
||||
anchor={{ to: "bottom", gap: "0.5rem" }}
|
||||
className="border mt-1 max-h-60 overflow-auto rounded
|
||||
border-neutral-200 dark:border-neutral-600 bg-white dark:bg-neutral-800 shadow-lg"
|
||||
>
|
||||
{filtered.map((day) => (
|
||||
<ComboboxOption
|
||||
key={day}
|
||||
value={day}
|
||||
className="data-[focus]:bg-indigo-600 data-[focus]:dark:bg-indigo-600
|
||||
data-[focus]:dark:text-white cursor-pointer select-none px-3 py-2"
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{day}</span>
|
||||
{selected && <CheckIcon className="ml-1 h-4 w-4" />}
|
||||
</div>
|
||||
)}
|
||||
</ComboboxOption>
|
||||
))}
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
|
||||
{selected.map((day) => (
|
||||
<input key={day} type="hidden" name="days" value={day} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { FaTrash } from "react-icons/fa";
|
||||
import { deleteClass } from "@/app/lib/classes/deleteClass";
|
||||
|
||||
export default function DeleteClassButton({ classId, classTitle }) {
|
||||
return (
|
||||
<form action={deleteClass} className="inline">
|
||||
<input type="hidden" name="classId" value={classId} />
|
||||
<button
|
||||
type="submit"
|
||||
title="Excluir"
|
||||
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
aria-label={`Excluir turma ${classTitle}`}
|
||||
onClick={(e) => {
|
||||
if (!confirm('Tem certeza que deseja deletar esta turma?')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FaTrash className="text-lg" />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export default function FormCheckbox({
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
defaultChecked,
|
||||
required = false,
|
||||
disabled = false,
|
||||
className = "",
|
||||
}) {
|
||||
const inputId = id || name;
|
||||
|
||||
return (
|
||||
<div className={`mb-4 ${className}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
id={inputId}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
{...(checked !== undefined
|
||||
? { checked, onChange }
|
||||
: { defaultChecked })}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
aria-describedby={description ? `${inputId}-desc` : undefined}
|
||||
className="mt-1 h-5 w-5 rounded border-neutral-300 dark:border-neutral-600
|
||||
bg-white dark:bg-neutral-700
|
||||
accent-indigo-600
|
||||
focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400
|
||||
disabled:opacity-50"
|
||||
/>
|
||||
<div className="leading-tight">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<p id={`${inputId}-desc`} className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { FaPowerOff } from "react-icons/fa";
|
||||
import { toggleClassStatus } from "@/app/lib/classes/toggleClassStatus";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ToggleClassStatusButton({ classId, classTitle, isActive }) {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const handleToggle = async (e) => {
|
||||
e.preventDefault();
|
||||
const action = isActive ? "desativar" : "ativar";
|
||||
|
||||
if (!confirm(`Tem certeza que deseja ${action} a turma "${classTitle}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("classId", classId);
|
||||
const result = await toggleClassStatus(formData);
|
||||
|
||||
if (result.success) {
|
||||
// Reload the page to show updated status
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(result.message || "Erro ao alterar status da turma.");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Erro ao alterar status da turma.");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
disabled={isPending}
|
||||
title={isActive ? "Desativar" : "Ativar"}
|
||||
className={`p-2 transition-colors ${
|
||||
isActive
|
||||
? "text-emerald-500 hover:text-emerald-700 dark:hover:text-emerald-300"
|
||||
: "text-neutral-400 hover:text-emerald-600 dark:hover:text-emerald-400"
|
||||
} ${isPending ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
aria-label={`${isActive ? "Desativar" : "Ativar"} turma ${classTitle}`}
|
||||
>
|
||||
<FaPowerOff className="text-lg" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
ComboboxButton,
|
||||
} from "@headlessui/react";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export default function UsersMultiSelect({
|
||||
label = "Usuários",
|
||||
inputName = "users[]",
|
||||
users = [],
|
||||
defaultSelectedIds = [],
|
||||
onRemove,
|
||||
onSelectionChange,
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const selected = defaultSelectedIds
|
||||
.map((id) => users.find((us) => us._id === id))
|
||||
.filter(Boolean);
|
||||
|
||||
const filtered =
|
||||
query === ""
|
||||
? users
|
||||
: users.filter((u) =>
|
||||
u.fullName.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-2 block">
|
||||
{label}
|
||||
</label>
|
||||
<Combobox
|
||||
multiple
|
||||
value={selected}
|
||||
onChange={(newSelected) => onSelectionChange(newSelected.map(u => u._id))}
|
||||
onClose={() => setQuery("")}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="relative max-w-200 cursor-default overflow-hidden rounded-md border border-input bg-background text-left focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
|
||||
>
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 p-1.5">
|
||||
{selected.map((u) => (
|
||||
<span
|
||||
key={u._id}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-secondary px-2.5 py-0.5 text-xs font-semibold text-secondary-foreground"
|
||||
>
|
||||
{u.fullName}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-0.5 hover:bg-muted"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onRemove(u._id, inputName)}
|
||||
aria-label={`Remover ${u.fullName}`}
|
||||
>
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ComboboxInput
|
||||
aria-label={label}
|
||||
displayValue={(user) => user?.fullName}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="w-full border-none bg-transparent py-2 pl-3 pr-8 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</ComboboxButton>
|
||||
</div>
|
||||
<ComboboxOptions
|
||||
anchor={{ to: "bottom", gap: "0.25rem" }}
|
||||
className="border rounded-md shadow-lg max-h-60 overflow-auto bg-popover text-popover-foreground"
|
||||
>
|
||||
{filtered.map((u) => (
|
||||
<ComboboxOption
|
||||
key={u._id}
|
||||
value={u}
|
||||
className="data-[focus]:bg-accent data-[focus]:text-accent-foreground cursor-pointer select-none px-3 py-2"
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate">{u.fullName}</span>
|
||||
{selected && <CheckIcon className="h-4 w-4" />}
|
||||
</div>
|
||||
)}
|
||||
</ComboboxOption>
|
||||
))}
|
||||
{filtered.length === 0 && query !== "" && (
|
||||
<div className="py-2 px-3 text-sm text-muted-foreground">
|
||||
Nenhum resultado encontrado
|
||||
</div>
|
||||
)}
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
{selected.map((s) => (
|
||||
<input key={s._id} type="hidden" name={inputName} value={s._id} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { getUserModel } from "@/app/models/User";
|
||||
import { getFieldItemByItem } from "@/app/lib/helpers/getItems"
|
||||
import { FaEdit, FaTrash } from "react-icons/fa";
|
||||
import Link from "next/link";
|
||||
import { FaFileCirclePlus } from "react-icons/fa6";
|
||||
import DeleteClassButton from "./DeleteClassButton";
|
||||
import { deleteClass } from "@/app/lib/classes/deleteClass";
|
||||
import Label from "@/app/(protected)/components/shared/Label";
|
||||
import ToggleClassStatusButton from "./ToggleClassStatusButton";
|
||||
|
||||
export async function ClassRow({ classData }) {
|
||||
const User = await getUserModel();
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
|
||||
<td className="px-6 py-4 text-neutral-900 dark:text-neutral-100 font-medium">
|
||||
<Link
|
||||
href={`/admin/dashboard/class/files/${classData._id}`}
|
||||
className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors underline-offset-2 hover:underline"
|
||||
title="Abrir dashboard da turma"
|
||||
>
|
||||
{classData?.classTitle}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300">
|
||||
{(
|
||||
await Promise.all(
|
||||
classData.teachers.map((t) =>
|
||||
getFieldItemByItem(User, t._id, "fullName")
|
||||
)
|
||||
)
|
||||
).join(", ")}
|
||||
</td>
|
||||
<td className="px-6 py-4 hidden lg:table-cell text-neutral-700 dark:text-neutral-300">
|
||||
{new Date(classData.startDate).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<Label
|
||||
color={classData.status === 'active' ? 'emerald' : 'red'}
|
||||
size="sm"
|
||||
>
|
||||
{classData.status === 'active' ? 'Ativa' : 'Inativa'}
|
||||
</Label>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
<ToggleClassStatusButton
|
||||
classId={classData._id.toString()}
|
||||
classTitle={classData.classTitle}
|
||||
isActive={classData.status === 'active'}
|
||||
/>
|
||||
<Link
|
||||
href={`/admin/dashboard/files/class/${classData._id}/add`}
|
||||
title="Adicionar Arquivo"
|
||||
className="p-2 text-neutral-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<FaFileCirclePlus className="text-lg" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/dashboard/class/edit/${classData._id}`}
|
||||
title="Editar"
|
||||
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
|
||||
>
|
||||
<FaEdit className="text-lg" />
|
||||
</Link>
|
||||
<DeleteClassButton classId={classData._id.toString()} classTitle={classData.classTitle} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import ClassForm from "@/app/(protected)/admin/dashboard/class/components/ClassForm";
|
||||
import {getUsersByRole} from "@/app/lib/users/getUsersByRole";
|
||||
import {getClassTypes} from "@/app/lib/classes/getClassTypes";
|
||||
import {getClassModel} from "@/app/models/Class";
|
||||
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
|
||||
|
||||
async function EditClass({params}) {
|
||||
const {id} = await params;
|
||||
|
||||
if (!isValidObjectId(id)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const teachersResult = await getUsersByRole(["teacher"]);
|
||||
const teachers = teachersResult.success ? teachersResult.data : [];
|
||||
const studentsResult = await getUsersByRole(["student"]);
|
||||
const students = studentsResult.success ? studentsResult.data : [];
|
||||
const classTypesResult = await getClassTypes();
|
||||
const classTypes = classTypesResult.success ? classTypesResult.data : [];
|
||||
const Class = await getClassModel();
|
||||
const classData = await Class.findOne({_id: id}).lean();
|
||||
|
||||
if (!classData) {
|
||||
return (
|
||||
<div className="flex flex-col w-full text-center p-8">
|
||||
<PageHeader title="Turma não encontrada..." />
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
A turma que você está tentando editar não existe ou foi excluída.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
//TODO usar a função toPlain
|
||||
const plainClassData = JSON.parse(JSON.stringify(classData));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<PageHeader title="Editar Turma" subtitle="Atualize as informações da turma" />
|
||||
<ClassForm
|
||||
classData={plainClassData || null}
|
||||
classTypes={classTypes}
|
||||
teachers={teachers}
|
||||
students={students}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditClass;
|
||||
@@ -0,0 +1,164 @@
|
||||
import mongoose from "mongoose";
|
||||
import MainSection from "@/app/(protected)/components/shared/Main";
|
||||
import ClassDetail from "@/app/(protected)/components/teacher/ClassDetail";
|
||||
import { getClassModel } from "@/app/models/Class";
|
||||
import { getFileModel } from "@/app/models/FilesSchema";
|
||||
import { getLessonModel } from "@/app/models/Lesson";
|
||||
import { getExamAttemptModel } from "@/app/models/ExamAttempt";
|
||||
import { getUserModel } from "@/app/models/User";
|
||||
import { toPlain } from "@/app/lib/helpers/toPlain";
|
||||
import { retrieveFiles } from "@/app/lib/helpers/retriveFiles";
|
||||
|
||||
async function ClassFilesPage({ params }) {
|
||||
const { id: classId } = await params;
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(classId)) {
|
||||
return (
|
||||
<MainSection>
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-lg text-neutral-500 dark:text-neutral-400">Turma não encontrada.</p>
|
||||
</div>
|
||||
</MainSection>
|
||||
);
|
||||
}
|
||||
|
||||
await getFileModel();
|
||||
await getUserModel();
|
||||
await getLessonModel();
|
||||
await getExamAttemptModel();
|
||||
|
||||
const ClassModel = await getClassModel();
|
||||
|
||||
try {
|
||||
const theClass = await ClassModel
|
||||
.findById(classId)
|
||||
.populate([
|
||||
{
|
||||
path: "files",
|
||||
select: "title url size description uploadedAt mimetype category",
|
||||
populate: {
|
||||
path: "category",
|
||||
select: "name colorIndex"
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "students",
|
||||
select: "fullName"
|
||||
},
|
||||
{
|
||||
path: "teachers",
|
||||
select: "fullName"
|
||||
}
|
||||
])
|
||||
.lean();
|
||||
|
||||
if (!theClass) {
|
||||
return <div>Class not found.</div>;
|
||||
}
|
||||
|
||||
const plainClassData = toPlain(theClass);
|
||||
const rawFiles = Array.isArray(plainClassData?.files) ? plainClassData.files : [];
|
||||
const filesData = retrieveFiles(rawFiles);
|
||||
|
||||
const totalStudents = new Set((plainClassData?.students || []).map((s) => s._id)).size;
|
||||
|
||||
const LessonModel = await getLessonModel();
|
||||
const lessons = await LessonModel.find({ classId }).sort({ date: -1 }).lean();
|
||||
const plainLessons = toPlain(lessons);
|
||||
|
||||
const classStudentIds = new Set((plainClassData?.students || []).map((s) => s._id));
|
||||
let totalPresent = 0;
|
||||
let totalLate = 0;
|
||||
let totalAttendanceRecords = 0;
|
||||
const studentAttendance = {};
|
||||
|
||||
plainLessons.forEach((lesson) => {
|
||||
lesson.attendance?.forEach((a) => {
|
||||
const attendanceStudentId = a?.studentId?._id || a?.studentId;
|
||||
if (classStudentIds.has(attendanceStudentId)) {
|
||||
totalAttendanceRecords += 1;
|
||||
if (a.status === "present") totalPresent += 1;
|
||||
if (a.status === "late") totalLate += 1;
|
||||
|
||||
if (!studentAttendance[attendanceStudentId]) {
|
||||
studentAttendance[attendanceStudentId] = { present: 0, late: 0, total: 0 };
|
||||
}
|
||||
studentAttendance[attendanceStudentId].total += 1;
|
||||
if (a.status === "present") studentAttendance[attendanceStudentId].present += 1;
|
||||
if (a.status === "late") studentAttendance[attendanceStudentId].late += 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const attendanceRate = totalAttendanceRecords > 0
|
||||
? Math.round(((totalPresent + totalLate) / totalAttendanceRecords) * 100)
|
||||
: 0;
|
||||
|
||||
const ExamAttempt = await getExamAttemptModel();
|
||||
const attempts = await ExamAttempt.find({ classId }).lean();
|
||||
const plainAttempts = toPlain(attempts);
|
||||
const studentExamStats = {};
|
||||
|
||||
plainAttempts.forEach((attempt) => {
|
||||
const studentId = attempt.studentId?._id || attempt.studentId;
|
||||
if (!studentExamStats[studentId]) {
|
||||
studentExamStats[studentId] = { totalScore: 0, totalPoints: 0 };
|
||||
}
|
||||
if (attempt.score !== undefined && attempt.totalPoints) {
|
||||
studentExamStats[studentId].totalScore += attempt.score;
|
||||
studentExamStats[studentId].totalPoints += attempt.totalPoints;
|
||||
}
|
||||
});
|
||||
|
||||
const studentsWithData = (plainClassData?.students || []).map((student) => {
|
||||
const att = studentAttendance[student._id] || { present: 0, late: 0, total: 0 };
|
||||
const studentAttRate = att.total > 0 ? Math.round(((att.present + att.late) / att.total) * 100) : 0;
|
||||
|
||||
const examStats = studentExamStats[student._id] || { totalScore: 0, totalPoints: 0 };
|
||||
const avgScore = examStats.totalPoints > 0
|
||||
? (examStats.totalScore / examStats.totalPoints * 10).toFixed(1)
|
||||
: "-";
|
||||
|
||||
return {
|
||||
...student,
|
||||
attendanceRate: studentAttRate,
|
||||
avgScore,
|
||||
};
|
||||
});
|
||||
|
||||
const cls = {
|
||||
id: classId,
|
||||
classTitle: plainClassData?.classTitle || "",
|
||||
teachers: plainClassData?.teachers?.map(t => t.fullName) || [],
|
||||
status: plainClassData?.status,
|
||||
schedule: { days: plainClassData?.schedule?.days || [], time: plainClassData?.schedule?.time || [] },
|
||||
stats: {
|
||||
students: totalStudents,
|
||||
attendanceRate,
|
||||
avgScore: "-",
|
||||
pendingSubmissions: plainClassData?.pendingSubmissions || 0,
|
||||
},
|
||||
materials: filesData.files || {}, // Pass only the grouped files
|
||||
};
|
||||
|
||||
return (
|
||||
<MainSection>
|
||||
<ClassDetail
|
||||
clsData={cls}
|
||||
filesData={filesData.files || {}}
|
||||
categories={filesData.categories || {}}
|
||||
students={studentsWithData}
|
||||
classId={classId}
|
||||
uploadAddPath={`/admin/dashboard/files/class/${classId}/add`}
|
||||
historyPath={`/admin/dashboard/class/history/${classId}`}
|
||||
assignmentResultsBasePath="/admin/dashboard"
|
||||
/>
|
||||
</MainSection>
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error fetching class files:", err);
|
||||
return <div>Error loading files.</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export default ClassFilesPage;
|
||||
@@ -0,0 +1,51 @@
|
||||
import MainSection from "@/app/(protected)/components/shared/Main";
|
||||
import ClassHistoryList from "@/app/(protected)/components/teacher/ClassHistoryList";
|
||||
import { getClassModel } from "@/app/models/Class";
|
||||
import { getLessonModel } from "@/app/models/Lesson";
|
||||
import { toPlain } from "@/app/lib/helpers/toPlain";
|
||||
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
|
||||
import { getUserModel } from "@/app/models/User";
|
||||
|
||||
export default async function AdminClassHistoryPage({ params }) {
|
||||
const { id } = await params;
|
||||
|
||||
if (!isValidObjectId(id)) {
|
||||
return (
|
||||
<MainSection>
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
|
||||
</div>
|
||||
</MainSection>
|
||||
);
|
||||
}
|
||||
|
||||
const ClassModel = await getClassModel();
|
||||
const LessonModel = await getLessonModel();
|
||||
await getUserModel();
|
||||
|
||||
const classData = await ClassModel.findById(id)
|
||||
.populate("students", "fullName _id")
|
||||
.lean();
|
||||
const plainClassData = toPlain(classData);
|
||||
|
||||
const lessons = await LessonModel.find({ classId: id })
|
||||
.sort({ date: -1 })
|
||||
.populate("teacherId", "fullName")
|
||||
.populate("attendance.studentId", "fullName")
|
||||
.lean();
|
||||
|
||||
const plainLessons = toPlain(lessons);
|
||||
|
||||
return (
|
||||
<MainSection>
|
||||
<ClassHistoryList
|
||||
classId={id}
|
||||
classTitle={plainClassData?.classTitle || ""}
|
||||
lessons={plainLessons}
|
||||
totalStudents={plainClassData?.students?.length || 0}
|
||||
students={plainClassData?.students || []}
|
||||
backHref={`/admin/dashboard/class/files/${id}`}
|
||||
/>
|
||||
</MainSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import { getClassModel } from "@/app/models/Class";
|
||||
import ClassesTable from "./components/ClassesTable";
|
||||
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
|
||||
|
||||
export default async function ClassesPage({ searchParams }) {
|
||||
const params = await searchParams;
|
||||
const Classes = await getClassModel();
|
||||
const classes = await Classes.find({}).lean();
|
||||
|
||||
return (
|
||||
<div className="w-full mt-3">
|
||||
<PageHeader
|
||||
title="Gerenciar Turmas"
|
||||
subtitle="Gerencie as turmas ativas atualmente, bem como as arquivadas"
|
||||
actions={
|
||||
<Link href="/admin/dashboard/class/add">
|
||||
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
|
||||
+ Adicionar Nova Turma
|
||||
</button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
{params?.warn && (
|
||||
<div className="mb-4">
|
||||
<FlashMessage message={decodeURIComponent(params.warn)} type="warning" />
|
||||
</div>
|
||||
)}
|
||||
<ClassesTable classes={classes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useActionState } from "react";
|
||||
import saveClassTypeAction from "@/app/lib/classes/saveClassTypeAction";
|
||||
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function ClassTypeForm({ classType = {}, onCancel }) {
|
||||
const router = useRouter();
|
||||
|
||||
const [showMessage, setShowMessage] = useState(true);
|
||||
const initialState = {
|
||||
success: false,
|
||||
message: null,
|
||||
};
|
||||
|
||||
const [state, action, isPending] = useActionState(
|
||||
saveClassTypeAction,
|
||||
initialState
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.message) {
|
||||
setShowMessage(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowMessage(false);
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state.message]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.success && state.redirectTo) {
|
||||
router.push(state.redirectTo);
|
||||
}
|
||||
}, [state?.success, state?.redirectTo, router]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{state?.message && showMessage && (
|
||||
<div className="max-w-screen-xl mx-auto w-full">
|
||||
<FlashMessage
|
||||
message={state?.message}
|
||||
type={state.success ? "success" : "error"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
action={action}
|
||||
className="bg-white dark:bg-neutral-800 p-8 rounded-lg shadow-md max-w-lg mx-auto"
|
||||
>
|
||||
{isPending && <p>Carregando...</p>}
|
||||
|
||||
{classType._id && (
|
||||
<input type="hidden" name="_id" value={classType._id} />
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="title" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
|
||||
Título:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
defaultValue={classType.title || ""}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-700 dark:border-neutral-600"
|
||||
placeholder="Ex: Aula de Gramática Avançada"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="description" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
|
||||
Descrição:
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
defaultValue={classType.description || ""}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-700 dark:border-neutral-600"
|
||||
rows="3"
|
||||
placeholder="Detalhes sobre o conteúdo da aula..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="ageRange" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
|
||||
Faixa Etária:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ageRange"
|
||||
name="ageRange"
|
||||
defaultValue={classType.ageRange || ""}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-700 dark:border-neutral-600"
|
||||
placeholder="Ex: 8-12 anos, Adultos, etc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="price" className="block text-neutral-700 dark:text-neutral-200 text-sm font-bold mb-2">
|
||||
Preço:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="price"
|
||||
name="price"
|
||||
step="0.01"
|
||||
defaultValue={classType.price || ""}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-neutral-700 dark:text-neutral-200 leading-tight focus:outline-none focus:shadow-outline dark:bg-neutral-700 dark:border-neutral-600"
|
||||
placeholder="Ex: 50.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg focus:outline-none focus:shadow-outline disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
{isPending ? "Salvando..." : "Salvar"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="bg-neutral-500 hover:bg-neutral-600 text-white font-bold py-2 px-4 rounded-lg focus:outline-none focus:shadow-outline transition-colors duration-200"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClassTypeForm;
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { deleteClassTypeAction } from "@/app/lib/classes/deleteClassType";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FaEdit, FaTrash } from "react-icons/fa";
|
||||
import { FaFileCirclePlus } from "react-icons/fa6";
|
||||
import { LuFileStack } from "react-icons/lu";
|
||||
import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils";
|
||||
import Label from "@/app/(protected)/components/shared/Label";
|
||||
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
|
||||
|
||||
export default function ClassTypesTable({ classTypes }) {
|
||||
const initialState = { success: false, message: null };
|
||||
const [state, action, isPending] = useActionState(
|
||||
deleteClassTypeAction,
|
||||
initialState
|
||||
);
|
||||
const [showMessage, setShowMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.message) {
|
||||
setShowMessage(true);
|
||||
const timer = setTimeout(() => setShowMessage(false), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state?.message]);
|
||||
|
||||
return (
|
||||
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
|
||||
{showMessage && state?.message && (
|
||||
<div className="px-6 pt-4">
|
||||
<FlashMessage
|
||||
message={state.message}
|
||||
type={state.success ? "success" : "error"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs align-top">
|
||||
<tr>
|
||||
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
|
||||
Tipo de Turma
|
||||
</th>
|
||||
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell text-left">
|
||||
Faixa Etária
|
||||
</th>
|
||||
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell text-left min-w-[120px]">
|
||||
Preço
|
||||
</th>
|
||||
<th className="px-4 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center whitespace-nowrap w-px">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800 align-top">
|
||||
{classTypes.map((type) => (
|
||||
<tr
|
||||
key={type._id}
|
||||
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-3.5 text-neutral-900 dark:text-neutral-100 font-medium align-top text-left">
|
||||
{type.title}
|
||||
</td>
|
||||
<td className="px-6 py-3.5 hidden md:table-cell text-neutral-700 dark:text-neutral-300 align-top">
|
||||
{type.ageRange || "-"}
|
||||
</td>
|
||||
<td className="px-6 py-3.5 hidden md:table-cell text-neutral-700 dark:text-neutral-300 align-top whitespace-nowrap">
|
||||
<Label color="emerald" size="md" className="gap-1">
|
||||
<span className="text-xs">R$</span>
|
||||
{type.price?.toFixed(2) || "-"}
|
||||
</Label>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 align-top whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/admin/dashboard/files/${relatedToTitleUrl(
|
||||
"classTypes"
|
||||
)}/${type._id}/add`}
|
||||
title="Adicionar Arquivo"
|
||||
className="p-2 text-neutral-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<FaFileCirclePlus className="text-lg" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/dashboard/${relatedToTitleUrl(
|
||||
"classTypes"
|
||||
)}/files/${type._id}`}
|
||||
title="Ver Arquivos"
|
||||
className="p-2 text-neutral-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
<LuFileStack className="text-lg" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/dashboard/${relatedToTitleUrl(
|
||||
"classTypes"
|
||||
)}/edit/${type._id.toString()}`}
|
||||
title="Editar"
|
||||
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
|
||||
>
|
||||
<FaEdit className="text-lg" />
|
||||
</Link>
|
||||
<form action={action} className="inline">
|
||||
<input
|
||||
type="hidden"
|
||||
name="_id"
|
||||
value={type._id || "nada"}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
title="Excluir"
|
||||
disabled={isPending}
|
||||
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={(e) => {
|
||||
if (!confirm('Tem certeza que deseja deletar este tipo de turma?')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<FaTrash className="text-lg" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ClassTypeForm from "../ClassTypeForm";
|
||||
|
||||
export default function AddClassTypeForm() {
|
||||
return <ClassTypeForm />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import AddClassTypeForm from "./AddClassTypeForm";
|
||||
|
||||
function AddClassType() {
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<PageHeader title="Adicionar Tipo de Turma" subtitle="Crie um novo tipo de turma" />
|
||||
<AddClassTypeForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddClassType;
|
||||
@@ -0,0 +1,41 @@
|
||||
import ClassTypeForm from "@/app/(protected)/admin/dashboard/classTypes/ClassTypeForm";
|
||||
import { getTypeClassAsPlainObject } from "@/app/lib/helpers/getItems";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
|
||||
|
||||
export default async function EditClassTypePage({ params }) {
|
||||
const awaitedParams = await params;
|
||||
const id = awaitedParams.id;
|
||||
|
||||
if (!isValidObjectId(id)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const classType = await getTypeClassAsPlainObject(id);
|
||||
|
||||
if (!classType) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
classType._id = classType._id.toString();
|
||||
|
||||
return (
|
||||
<div className="w-full mt-3">
|
||||
<PageHeader
|
||||
title="Editar Tipo de Turma"
|
||||
subtitle="Atualize as informações do tipo de turma"
|
||||
/>
|
||||
<div className="flex justify-center">
|
||||
<ClassTypeForm classType={classType} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { getClassTypeModel } from "@/app/models/ClassType";
|
||||
import React from "react";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import Link from "next/link";
|
||||
import FilesTable from "@/app/(protected)/admin/dashboard/components/FilesTable";
|
||||
import { getFileModel } from "@/app/models/FilesSchema";
|
||||
import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils";
|
||||
import { getFileUrl } from "@/app/lib/utils/storage/fileUrl";
|
||||
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
|
||||
|
||||
async function ClassTypeFilesPage({ params }) {
|
||||
const { id: classTypeId } = await params;
|
||||
|
||||
if (!isValidObjectId(classTypeId)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
await getFileModel();
|
||||
|
||||
const classTypeModel = await getClassTypeModel();
|
||||
|
||||
try {
|
||||
const classType = await classTypeModel
|
||||
.findById(classTypeId)
|
||||
.populate("files")
|
||||
.lean();
|
||||
|
||||
if (!classType) {
|
||||
return <div>Class Type not found.</div>;
|
||||
}
|
||||
|
||||
const simplifiedFiles = classType.files.map((file) => ({
|
||||
...file,
|
||||
_id: file._id.toString(),
|
||||
uploadedBy: file.uploadedBy.toString(),
|
||||
modifiedAt: file.modifiedAt.toString(),
|
||||
relatedToId: file.relatedToId ? file.relatedToId.toString() : null,
|
||||
category: file.category ? file.category.toString() : null,
|
||||
url: getFileUrl(file.url), // Converte URL para proxy
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-full mt-3">
|
||||
<PageHeader
|
||||
title={`Arquivos da Classe ${classType.title}`}
|
||||
subtitle="Gerencie os arquivos associados a este tipo de turma"
|
||||
actions={
|
||||
<Link
|
||||
href={`/admin/dashboard/files/${relatedToTitleUrl(
|
||||
"classTypes"
|
||||
)}/${classType._id}/add`}
|
||||
>
|
||||
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
|
||||
Adicionar Arquivo
|
||||
</button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<FilesTable files={simplifiedFiles}></FilesTable>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error fetching class type and files:", err);
|
||||
return <div>Error loading files.</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export default ClassTypeFilesPage;
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from "next/link";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils";
|
||||
|
||||
|
||||
export default async function ClassTypesPage() {
|
||||
return (
|
||||
<div className="w-full mt-3">
|
||||
<PageHeader
|
||||
title="Arquivos de Tipos de Turmas"
|
||||
subtitle="Gerencie os arquivos associados aos tipos de turmas"
|
||||
actions={
|
||||
<Link href={`/admin/dashboard/${relatedToTitleUrl("classTypes")}/add`}>
|
||||
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
|
||||
+ Adicionar Novo Tipo
|
||||
</button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Link from "next/link";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import ClassTypesTable from "./ClassTypesTable";
|
||||
import { getAllClassItems } from "@/app/lib/helpers/getItems";
|
||||
import { relatedToTitleUrl } from "@/app/lib/helpers/generalUtils";
|
||||
|
||||
export default async function ClassTypesPage() {
|
||||
const classTypes = await getAllClassItems();
|
||||
|
||||
return (
|
||||
<div className="w-full mt-3">
|
||||
<PageHeader
|
||||
title="Gerenciar Tipos de Turmas"
|
||||
subtitle="Gerencie os tipos de turmas, como 'Starters' etc."
|
||||
actions={
|
||||
<Link href={`/admin/dashboard/${relatedToTitleUrl("classTypes")}/add`}>
|
||||
<button className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300 cursor-pointer">
|
||||
+ Adicionar Novo Tipo
|
||||
</button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<ClassTypesTable classTypes={classTypes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useTransition } from "react";
|
||||
import { FaEdit, FaTrash, FaDownload } from "react-icons/fa";
|
||||
import { IoCalendarOutline } from "react-icons/io5";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { deleteFile } from "@/app/lib/generalActions/deleteFile";
|
||||
|
||||
export default function FilesTable({ files, relatedTo, relatedToId }) {
|
||||
const [clientFiles, setClientFiles] = useState(files);
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const pathname = usePathname();
|
||||
const [confirmationFile, setConfirmationFile] = useState(null);
|
||||
const [errorMessage, setErrorMessage] = useState(null);
|
||||
|
||||
const onEditFile = (id) => {
|
||||
router.push(`/admin/dashboard/files/edit/${id.toString()}`);
|
||||
};
|
||||
|
||||
const handleDeleteFile = (file) => {
|
||||
// Always show confirmation dialog first
|
||||
setConfirmationFile(file);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (!confirmationFile) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await deleteFile({
|
||||
_id: confirmationFile._id,
|
||||
redirectTo: pathname,
|
||||
relatedTo,
|
||||
relatedToId,
|
||||
force: true,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setClientFiles((prev) => prev.filter((file) => file._id !== confirmationFile._id));
|
||||
setConfirmationFile(null);
|
||||
setErrorMessage(null);
|
||||
} else {
|
||||
setErrorMessage(result.message || "Erro ao deletar arquivo");
|
||||
setConfirmationFile(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setConfirmationFile(null);
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs">
|
||||
<tr>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
Título do Arquivo
|
||||
</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell">
|
||||
Tipo
|
||||
</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden md:table-cell text-center">
|
||||
Tamanho
|
||||
</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 hidden lg:table-cell text-center">
|
||||
Enviado
|
||||
</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-right">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{clientFiles &&
|
||||
clientFiles.map((file) => (
|
||||
<tr
|
||||
key={file._id}
|
||||
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 text-neutral-900 dark:text-neutral-100 font-medium">
|
||||
{file.title}
|
||||
</td>
|
||||
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300">
|
||||
{file.mimetype || "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4 hidden md:table-cell text-neutral-700 dark:text-neutral-300 text-center">
|
||||
{file.size ? `${(file.size / 1024).toFixed(2)} KB` : "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4 hidden lg:table-cell text-neutral-700 dark:text-neutral-300 text-center">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<IoCalendarOutline className="text-neutral-400" />
|
||||
{new Date(file.uploadedAt).toLocaleDateString("pt-BR")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
{file.url && (
|
||||
<a
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Download"
|
||||
className="p-2 text-neutral-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<FaDownload className="text-lg" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title="Editar"
|
||||
onClick={() => onEditFile(file._id)}
|
||||
className="p-2 text-neutral-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
|
||||
>
|
||||
<FaEdit className="text-lg" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Excluir"
|
||||
disabled={isPending}
|
||||
onClick={() => handleDeleteFile(file)}
|
||||
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<FaTrash className="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<div className="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{confirmationFile && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-xl p-6 max-w-md mx-4 shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
Confirmar Exclusão
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
Tem certeza que deseja excluir o arquivo <span className="font-semibold">"{confirmationFile.title}"</span>? Esta ação não pode ser desfeita.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={handleCancelDelete}
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-white dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Excluindo..." : "Confirmar Exclusão"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Error({ error, reset }) {
|
||||
useEffect(() => {
|
||||
console.error("Erro no painel administrativo:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] p-8">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<svg className="h-8 w-8 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
Erro no painel
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-6">
|
||||
Ocorreu um erro ao carregar os dados do painel administrativo. Tente novamente.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="px-5 py-2.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
|
||||
>
|
||||
Tentar novamente
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.href = "/admin/dashboard"}
|
||||
className="px-5 py-2.5 text-sm font-medium text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
Painel admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import TemplateList from "@/app/(protected)/components/exam-templates/TemplateList";
|
||||
import TemplateForm from "@/app/(protected)/components/exam-templates/TemplateForm";
|
||||
|
||||
export default function ExamTemplatesPage({ initialTemplates }) {
|
||||
const [templates, setTemplates] = useState(initialTemplates);
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState(null);
|
||||
|
||||
// Callback to update templates from child components
|
||||
const updateTemplates = useCallback((updater) => {
|
||||
setTemplates(updater);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenForm = (e) => {
|
||||
setEditingTemplate(e.detail);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setIsFormOpen(false);
|
||||
setEditingTemplate(null);
|
||||
};
|
||||
|
||||
const handleTemplatesUpdate = (e) => {
|
||||
if (e.detail) {
|
||||
setTemplates(e.detail);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('open-template-form', handleOpenForm);
|
||||
window.addEventListener('closeTemplateModal', handleCloseForm);
|
||||
window.addEventListener('templates-updated', handleTemplatesUpdate);
|
||||
return () => {
|
||||
window.removeEventListener('open-template-form', handleOpenForm);
|
||||
window.removeEventListener('closeTemplateModal', handleCloseForm);
|
||||
window.removeEventListener('templates-updated', handleTemplatesUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Templates de Provas</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div>
|
||||
<TemplateList
|
||||
templates={templates}
|
||||
setTemplates={updateTemplates}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TemplateForm
|
||||
isOpen={isFormOpen}
|
||||
template={editingTemplate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getExamTemplates } from '@/app/lib/actions/examActions';
|
||||
import ExamTemplatesPage from './ExamTemplatesPage';
|
||||
|
||||
export default async function Page() {
|
||||
const result = await getExamTemplates();
|
||||
const templates = result.success ? result.data : [];
|
||||
|
||||
return <ExamTemplatesPage initialTemplates={templates} />;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Link from "next/link";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
DocumentDuplicateIcon,
|
||||
ChartBarIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
export default function ExamsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Gerenciamento de Provas" subtitle="Gerencie as provas, modelos, atribuições e estatísticas" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Link
|
||||
href="/admin/dashboard/exam-templates"
|
||||
className="p-6 border border-neutral-200 dark:border-neutral-800 rounded-lg hover-card-light"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<AcademicCapIcon className="w-8 h-8 text-indigo-600 dark:text-indigo-400" />
|
||||
<h2 className="text-lg font-semibold">Modelos de Prova</h2>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Crie e gerencie modelos de prova reutilizáveis com questões
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/dashboard/assignments"
|
||||
className="p-6 border border-neutral-200 dark:border-neutral-800 rounded-lg hover-card-light"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<DocumentDuplicateIcon className="w-8 h-8 text-emerald-600 dark:text-emerald-400" />
|
||||
<h2 className="text-lg font-semibold">Atribuições de Provas</h2>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Atribua modelos de prova às turmas com cronogramas e configurações
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/dashboard/statistics/exams"
|
||||
className="p-6 border border-neutral-200 dark:border-neutral-800 rounded-lg hover-card-light"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<ChartBarIcon className="w-8 h-8 text-amber-600 dark:text-amber-400" />
|
||||
<h2 className="text-lg font-semibold">Estatísticas de Provas</h2>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Visualize estatísticas agregadas e métricas de desempenho
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import FileUploadComponent from "@/app/(protected)/admin/dashboard/files/components/FileUploadForm";
|
||||
|
||||
async function AddFileClass({ params }) {
|
||||
const { relatedToType, relatedToId } = await params;
|
||||
console.log("Rel type: ", relatedToType)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<PageHeader title="Adicionar Arquivo" subtitle="Faça upload de um novo arquivo" />
|
||||
<FileUploadComponent relType={relatedToType} relId={relatedToId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddFileClass;
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import FileUploadComponent from "@/app/(protected)/admin/dashboard/files/components/FileUploadForm";
|
||||
|
||||
function AddFileClass() {
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<PageHeader title="Adicionar Arquivo" subtitle="Faça upload de um novo arquivo" />
|
||||
<FileUploadComponent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddFileClass;
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { useActionState } from "react";
|
||||
import { saveFileAction } from "@/app/lib/generalActions/saveFileAction";
|
||||
import { relatedToTitleUrl, relatedToTitle } from "@/app/lib/helpers/generalUtils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getCategoriesAction } from "@/app/lib/categories/getCategoriesAction";
|
||||
|
||||
const initialState = {
|
||||
success: false,
|
||||
message: null,
|
||||
inputs: {},
|
||||
};
|
||||
|
||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
|
||||
export default function FileUploadComponent({
|
||||
relType = null,
|
||||
relId = null,
|
||||
file = null,
|
||||
redirectUrl = null,
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
saveFileAction,
|
||||
initialState
|
||||
);
|
||||
const [showMessage, setShowMessage] = useState(false);
|
||||
|
||||
const [type] = useState(relType);
|
||||
const [catId] = useState(relId);
|
||||
const [editing] = useState(file ? true : false);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState(editing ? file?.category || "" : "");
|
||||
|
||||
// File validation states
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [fileError, setFileError] = useState("");
|
||||
const [fileSize, setFileSize] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.message) {
|
||||
setShowMessage(true);
|
||||
// Only redirect/clear on success, keep error message visible
|
||||
if (state.success) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowMessage(false);
|
||||
if (file) {
|
||||
const relatedToType = relatedToTitleUrl(file.relatedToType);
|
||||
router.push(`/admin/dashboard/${relatedToType}/files/${file.relatedToId}`);
|
||||
} else {
|
||||
initialState.inputs = {};
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
setSelectedFile(null);
|
||||
setFileSize("");
|
||||
setFileError("");
|
||||
}
|
||||
}
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
// On error, keep message visible until user submits again
|
||||
}
|
||||
}, [state?.message, file, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCategories = async () => {
|
||||
const result = await getCategoriesAction();
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCategories(result.data || []);
|
||||
if (editing && file?.category) {
|
||||
setSelectedCategory(file.category);
|
||||
}
|
||||
};
|
||||
fetchCategories();
|
||||
}, [editing, file?.category]);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
setFileError("");
|
||||
setSelectedFile(null);
|
||||
setFileSize("");
|
||||
|
||||
if (!file) return;
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setFileError(`Arquivo muito grande! Máximo permitido: ${formatFileSize(MAX_FILE_SIZE)}`);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
setFileSize(formatFileSize(file.size));
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
className="bg-white dark:bg-gray-800 p-6 rounded shadow-md max-w-lg mx-auto"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-100">
|
||||
Enviar Arquivo{" "}
|
||||
{relType && relId && <span>para {relatedToTitle(relType)}</span>}
|
||||
</h2>
|
||||
{type && catId && (
|
||||
<>
|
||||
<input type="hidden" name="relatedToType" value={type} />
|
||||
<input type="hidden" name="relatedToId" value={catId} />
|
||||
</>
|
||||
)}
|
||||
{editing && (
|
||||
<>
|
||||
<input type="hidden" name="id" value={file.id} />
|
||||
</>
|
||||
)}
|
||||
{redirectUrl && (
|
||||
<input type="hidden" name="redirectUrl" value={redirectUrl} />
|
||||
)}
|
||||
|
||||
{/* Server response message */}
|
||||
{showMessage && state?.message && (
|
||||
<div
|
||||
className={`mb-4 p-3 rounded-lg text-sm font-medium ${
|
||||
state.success
|
||||
? "bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-700"
|
||||
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-700"
|
||||
}`}
|
||||
>
|
||||
<p className="flex items-center gap-2">
|
||||
{state.success ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
{state.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && file && (
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Arquivo atual: {file.title} (
|
||||
<Link
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
Ver
|
||||
</Link>
|
||||
)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* File input with validation */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 label-dark font-semibold mb-2">
|
||||
Arquivo <span className="text-red-500">*</span>
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-2">
|
||||
(Máximo: {formatFileSize(MAX_FILE_SIZE)})
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
name="file"
|
||||
required={!editing}
|
||||
onChange={handleFileChange}
|
||||
disabled={isPending}
|
||||
className={`w-full text-sm file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold ${
|
||||
fileError
|
||||
? "file:bg-red-50 file:text-red-700 dark:file:bg-red-900/30 dark:file:text-red-300"
|
||||
: "file:bg-indigo-50 file:text-indigo-700 dark:file:bg-indigo-900/30 dark:file:text-indigo-300 hover:file:bg-indigo-100"
|
||||
} ${isPending ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
/>
|
||||
{/* File info / error */}
|
||||
{fileError && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{fileError}
|
||||
</p>
|
||||
)}
|
||||
{selectedFile && !fileError && (
|
||||
<p className="mt-2 text-sm text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{selectedFile.name} ({fileSize})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 label-dark font-semibold mb-2">
|
||||
Título <span className="text-red-500">*</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
required
|
||||
disabled={isPending}
|
||||
className="w-full border rounded px-3 py-2 text-gray-800 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="Título do arquivo"
|
||||
defaultValue={editing ? file.title : state?.inputs?.file}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 label-dark font-semibold mb-2">
|
||||
Descrição:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="description"
|
||||
disabled={isPending}
|
||||
className="w-full border rounded px-3 py-2 text-gray-800 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="Descrição opcional..."
|
||||
defaultValue={editing ? file.description : state?.inputs?.description}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 dark:text-gray-200 label-dark font-semibold mb-2">
|
||||
Categoria:
|
||||
</label>
|
||||
<select
|
||||
name="category"
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
disabled={isPending}
|
||||
className="w-full border rounded px-3 py-2 text-gray-800 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 mb-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">Selecione uma categoria (opcional)</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat._id} value={cat._id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
name="newCategoryName"
|
||||
disabled={isPending}
|
||||
className="w-full border rounded px-3 py-2 text-gray-800 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="Ou digite uma nova categoria (opcional)"
|
||||
defaultValue={state?.inputs?.newCategoryName || ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit button with loading state */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || !!fileError}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{editing ? "Atualizando..." : "Enviando..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{editing ? "Atualizar" : "Enviar Arquivo"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import FileUploadComponent from "@/app/(protected)/admin/dashboard/files/components/FileUploadForm";
|
||||
import { getItemById } from "@/app/lib/helpers/getItems";
|
||||
import { getFileModel } from "@/app/models/FilesSchema";
|
||||
import { isValidObjectId } from "@/app/lib/helpers/validObjectId";
|
||||
|
||||
async function EditFile({ params }) {
|
||||
const {id} = await params;
|
||||
|
||||
if (!isValidObjectId(id)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-lg text-neutral-500 dark:text-neutral-400">Registro não encontrado.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileModel = await getFileModel();
|
||||
const file = await fileModel.findById(id);
|
||||
const shapedFile = file
|
||||
? JSON.parse(JSON.stringify({
|
||||
id: file._id.toString() || id,
|
||||
title: file.title,
|
||||
description: file.description,
|
||||
relatedToType: file.relatedToType,
|
||||
relatedToId: file.relatedToId ? file.relatedToId.toString() : null,
|
||||
url: file.url,
|
||||
category: file.category ? file.category.toString() : null
|
||||
}))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<PageHeader title="Editar Arquivo" subtitle="Atualize as informações do arquivo" />
|
||||
<FileUploadComponent file={shapedFile}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditFile;
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import FileUploadComponent from "@/app/(protected)/admin/dashboard/files/components/FileUploadForm";
|
||||
|
||||
function AddFileClass() {
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<PageHeader title="Adicionar Arquivo" />
|
||||
{/* <FileUploadComponent /> */}
|
||||
SÓ PÁGINA
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddFileClass;
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
XMarkIcon,
|
||||
CheckCircleIcon,
|
||||
EyeIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { EnvelopeIcon, PhoneIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
function WhatsAppIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
new: "Nova",
|
||||
read: "Lida",
|
||||
replied: "Respondida",
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
new: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
read: "bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-400",
|
||||
replied:
|
||||
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
};
|
||||
|
||||
const contactLabels = {
|
||||
email: "Email",
|
||||
whatsapp: "WhatsApp",
|
||||
phone: "Telefone",
|
||||
};
|
||||
|
||||
export default function MessageDetailModal({ message, onClose, onStatusChange, onDelete }) {
|
||||
const [adminNotes, setAdminNotes] = useState(message.adminNotes || "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return "—";
|
||||
return new Date(dateStr).toLocaleString("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function buildWhatsAppLink() {
|
||||
let phone = (message.phone || "").replace(/\D/g, "");
|
||||
if (phone && !phone.startsWith("55")) {
|
||||
phone = "55" + phone;
|
||||
}
|
||||
const text = encodeURIComponent(
|
||||
`Olá ${message.name}! Recebemos sua mensagem sobre "${message.subject}". `
|
||||
);
|
||||
return phone ? `https://wa.me/${phone}?text=${text}` : null;
|
||||
}
|
||||
|
||||
async function handleSaveNotes() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/contact-messages/${message._id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ adminNotes }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && onStatusChange) {
|
||||
onStatusChange();
|
||||
} else if (!data.success) {
|
||||
setError(data.error || "Erro ao salvar notas.");
|
||||
}
|
||||
} catch {
|
||||
setError("Erro ao salvar notas. Verifique sua conexão e tente novamente.");
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleUpdateStatus(newStatus) {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/contact-messages/${message._id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && onStatusChange) {
|
||||
onStatusChange();
|
||||
onClose();
|
||||
} else if (!data.success) {
|
||||
setError(data.error || "Erro ao atualizar status.");
|
||||
}
|
||||
} catch {
|
||||
setError("Erro ao atualizar status. Verifique sua conexão e tente novamente.");
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm("Deseja realmente excluir esta mensagem?")) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/contact-messages/${message._id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && onDelete) {
|
||||
onDelete();
|
||||
onClose();
|
||||
} else if (!data.success) {
|
||||
setError(data.error || "Erro ao excluir mensagem.");
|
||||
}
|
||||
} catch {
|
||||
setError("Erro ao excluir mensagem. Verifique sua conexão e tente novamente.");
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
const whatsappLink = buildWhatsAppLink();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-neutral-200 dark:border-neutral-700">
|
||||
<div className="sticky top-0 bg-white dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-700 p-5 flex items-center justify-between rounded-t-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold text-neutral-900 dark:text-white">
|
||||
Mensagem
|
||||
</h2>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[message.status]}`}
|
||||
>
|
||||
{statusLabels[message.status]}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-5">
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
Nome
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
{message.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
Contato preferido
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
{contactLabels[message.preferredContact] || message.preferredContact}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
Email
|
||||
</p>
|
||||
<a
|
||||
href={`mailto:${message.email}`}
|
||||
className="text-sm text-indigo-600 dark:text-indigo-400 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<EnvelopeIcon className="w-3.5 h-3.5" />
|
||||
{message.email}
|
||||
</a>
|
||||
</div>
|
||||
{message.phone && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
Telefone / WhatsApp
|
||||
</p>
|
||||
<a
|
||||
href={`tel:${message.phone}`}
|
||||
className="text-sm text-neutral-700 dark:text-neutral-300 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<PhoneIcon className="w-3.5 h-3.5" />
|
||||
{message.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
Assunto
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||
{message.subject}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
Mensagem
|
||||
</p>
|
||||
<div className="text-sm text-neutral-700 dark:text-neutral-300 whitespace-pre-wrap bg-neutral-50 dark:bg-neutral-800 rounded-xl p-4 border border-neutral-200 dark:border-neutral-700 leading-relaxed">
|
||||
{message.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
Recebida em
|
||||
</p>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{formatDate(message.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
Notas internas
|
||||
</label>
|
||||
<textarea
|
||||
value={adminNotes}
|
||||
onChange={(e) => setAdminNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-2.5 text-sm text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="Adicione notas sobre o atendimento..."
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveNotes}
|
||||
disabled={saving}
|
||||
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<PencilSquareIcon className="w-3.5 h-3.5" />
|
||||
Salvar notas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 bg-white dark:bg-neutral-900 border-t border-neutral-200 dark:border-neutral-700 p-5 flex flex-col gap-3 rounded-b-2xl">
|
||||
{error && (
|
||||
<div className="rounded-xl p-3 text-sm font-medium bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{whatsappLink && (
|
||||
<a
|
||||
href={whatsappLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-green-600 hover:bg-green-700 text-white transition-colors"
|
||||
>
|
||||
<WhatsAppIcon className="w-4 h-4" />
|
||||
Abrir WhatsApp
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={`mailto:${message.email}`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
|
||||
>
|
||||
<EnvelopeIcon className="w-4 h-4" />
|
||||
Enviar Email
|
||||
</a>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{message.status === "new" && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus("read")}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
Marcar como lida
|
||||
</button>
|
||||
)}
|
||||
{message.status !== "replied" && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus("replied")}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<CheckCircleIcon className="w-4 h-4" />
|
||||
Marcar respondida
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
Excluir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useTransition } from "react";
|
||||
import {
|
||||
EyeIcon,
|
||||
TrashIcon,
|
||||
MagnifyingGlassIcon,
|
||||
FunnelIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import MessageDetailModal from "./MessageDetailModal";
|
||||
|
||||
const statusLabels = {
|
||||
new: "Nova",
|
||||
read: "Lida",
|
||||
replied: "Respondida",
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
new: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
read: "bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-400",
|
||||
replied:
|
||||
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
};
|
||||
|
||||
const contactIcons = {
|
||||
email: "📧",
|
||||
whatsapp: "💬",
|
||||
phone: "📞",
|
||||
};
|
||||
|
||||
export default function MessagesTable({ messages: initialMessages }) {
|
||||
const [messages, setMessages] = useState(initialMessages);
|
||||
const [selectedMessage, setSelectedMessage] = useState(null);
|
||||
const [filterStatus, setFilterStatus] = useState("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [refreshError, setRefreshError] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return "—";
|
||||
return new Date(dateStr).toLocaleString("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function refreshMessages() {
|
||||
setRefreshError(false);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/contact-messages");
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setMessages(
|
||||
data.data.map((m) => ({
|
||||
...m,
|
||||
_id: m._id.toString(),
|
||||
createdAt: m.createdAt,
|
||||
repliedAt: m.repliedAt,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setRefreshError(true);
|
||||
}
|
||||
} catch {
|
||||
console.error("Erro ao atualizar lista de mensagens.");
|
||||
setRefreshError(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
refreshMessages();
|
||||
}
|
||||
|
||||
const filtered = messages.filter((m) => {
|
||||
const matchesStatus =
|
||||
filterStatus === "all" || m.status === filterStatus;
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
m.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
m.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||
m.subject.toLowerCase().includes(search.toLowerCase());
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
|
||||
const counts = {
|
||||
all: messages.length,
|
||||
new: messages.filter((m) => m.status === "new").length,
|
||||
read: messages.filter((m) => m.status === "read").length,
|
||||
replied: messages.filter((m) => m.status === "replied").length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{refreshError && (
|
||||
<div className="rounded-xl p-3 text-sm font-medium bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-800">
|
||||
Não foi possível atualizar a lista de mensagens. Recarregue a página.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nome, email ou assunto..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 rounded-xl border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-sm text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FunnelIcon className="w-4 h-4 text-neutral-400" />
|
||||
{["all", "new", "read", "replied"].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilterStatus(status)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
filterStatus === status
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700"
|
||||
}`}
|
||||
>
|
||||
{status === "all" ? "Todas" : statusLabels[status]}
|
||||
<span
|
||||
className={`inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-bold ${
|
||||
filterStatus === status
|
||||
? "bg-white/20 text-white"
|
||||
: "bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{counts[status]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Data
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Nome
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Contato
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Assunto
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Status
|
||||
</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-4 py-8 text-center text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
Nenhuma mensagem encontrada.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((msg) => (
|
||||
<tr
|
||||
key={msg._id}
|
||||
className={`hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${
|
||||
msg.status === "new"
|
||||
? "bg-blue-50/50 dark:bg-blue-900/5"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-400 whitespace-nowrap">
|
||||
{formatDate(msg.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium text-neutral-900 dark:text-white">
|
||||
{msg.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">
|
||||
{contactIcons[msg.preferredContact] || "📧"}
|
||||
</span>
|
||||
<span className="text-neutral-600 dark:text-neutral-400 text-xs max-w-[180px] truncate">
|
||||
{msg.preferredContact === "whatsapp" || msg.preferredContact === "phone"
|
||||
? msg.phone || msg.email
|
||||
: msg.email}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-neutral-700 dark:text-neutral-300 max-w-[200px] truncate block">
|
||||
{msg.subject}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[msg.status]}`}
|
||||
>
|
||||
{statusLabels[msg.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setSelectedMessage(msg)}
|
||||
className="p-1.5 rounded-lg text-neutral-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors"
|
||||
title="Ver detalhes"
|
||||
>
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedMessage && (
|
||||
<MessageDetailModal
|
||||
message={selectedMessage}
|
||||
onClose={() => setSelectedMessage(null)}
|
||||
onStatusChange={refreshMessages}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import MessagesTable from "./components/MessagesTable";
|
||||
import { getContactMessageModel } from "@/app/models/ContactMessage";
|
||||
import { EnvelopeIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function MessagesAdminPage() {
|
||||
const ContactMessage = await getContactMessageModel();
|
||||
const messages = await ContactMessage.find({})
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
const serialized = messages.map((m) => ({
|
||||
...m,
|
||||
_id: m._id.toString(),
|
||||
createdAt: m.createdAt?.toISOString() || null,
|
||||
repliedAt: m.repliedAt?.toISOString() || null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-full mt-3">
|
||||
<PageHeader
|
||||
title="Mensagens de Contato"
|
||||
subtitle="Gerencie as mensagens recebidas pelo formulário de contato."
|
||||
icon={<EnvelopeIcon className="w-6 h-6" />}
|
||||
/>
|
||||
<MessagesTable messages={serialized} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MessagesAdminPage;
|
||||
@@ -0,0 +1,24 @@
|
||||
import AdminDashboard from "@/app/(protected)/components/AdminDashboard";
|
||||
import NotAuthorized from "@/app/auth/components/NotAuthorized";
|
||||
import { auth } from "@/app/lib/utils/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
async function AdminDasBoard() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/auth/login");
|
||||
const roles = Array.isArray(session.user.roles) ? session.user.roles : [];
|
||||
const role = session.user.role;
|
||||
const isAdmin = roles.includes("admin") || roles.includes("superadmin") || role === "admin" || role === "superadmin";
|
||||
|
||||
if (isAdmin) {
|
||||
return <AdminDashboard />;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<NotAuthorized />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AdminDasBoard;
|
||||
@@ -0,0 +1,86 @@
|
||||
import MainSection from "@/app/(protected)/components/shared/Main";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import { getPaymentModel } from "@/app/models/Payment";
|
||||
import { getUserModel } from "@/app/models/User";
|
||||
import { getClassModel } from "@/app/models/Class";
|
||||
import { auth } from "@/app/lib/utils/auth";
|
||||
import NotAuthorized from "@/app/auth/components/NotAuthorized";
|
||||
import { redirect } from "next/navigation";
|
||||
import PaymentsByClass from "../components/PaymentsByClass";
|
||||
|
||||
function serializePayment(payment) {
|
||||
return {
|
||||
_id: payment._id?.toString(),
|
||||
classId: payment.classId
|
||||
? {
|
||||
_id: payment.classId._id?.toString(),
|
||||
classTitle: payment.classId.classTitle,
|
||||
}
|
||||
: null,
|
||||
userId: payment.userId
|
||||
? {
|
||||
_id: payment.userId._id?.toString(),
|
||||
fullName: payment.userId.fullName,
|
||||
email: payment.userId.email,
|
||||
}
|
||||
: null,
|
||||
type: payment.type,
|
||||
amount: payment.amount,
|
||||
status: payment.status,
|
||||
paymentDate: payment.paymentDate,
|
||||
dueDate: payment.dueDate,
|
||||
description: payment.description,
|
||||
paymentMethod: payment.paymentMethod,
|
||||
receiptUrl: payment.receiptUrl,
|
||||
notes: payment.notes,
|
||||
createdBy: payment.createdBy?.toString(),
|
||||
createdAt: payment.createdAt?.toISOString(),
|
||||
updatedAt: payment.updatedAt?.toISOString(),
|
||||
relatedObligationId: payment.relatedObligationId?.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AdminPaymentsByClassPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/auth/login");
|
||||
|
||||
const UserModel = await getUserModel();
|
||||
const currentUser = await UserModel.findOne({ _id: session.user.id });
|
||||
|
||||
// Only admins can access this page
|
||||
if (!currentUser.roles.includes("admin")) {
|
||||
return <NotAuthorized />;
|
||||
}
|
||||
|
||||
const Payment = await getPaymentModel();
|
||||
const Class = await getClassModel();
|
||||
|
||||
// Fetch all payments and obligations
|
||||
const payments = await Payment.find({})
|
||||
.populate("classId", "classTitle")
|
||||
.populate("userId", "fullName email")
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
// Serialize data for client components
|
||||
const serializedPayments = payments.map(serializePayment);
|
||||
|
||||
return (
|
||||
<MainSection>
|
||||
<PageHeader
|
||||
title="Pagamentos por Turma"
|
||||
subtitle="Visualização agrupada de obrigações e movimentações financeiras"
|
||||
actions={
|
||||
<a
|
||||
href="/admin/dashboard/payments"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 text-sm font-medium label-dark hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
|
||||
>
|
||||
← Voltar para Pagamentos
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<PaymentsByClass payments={serializedPayments} />
|
||||
</MainSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createObligationAction } from "@/app/lib/payments/actions";
|
||||
|
||||
export default function CreateObligationForm({ classes, users }) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [classStudents, setClassStudents] = useState([]);
|
||||
const [formData, setFormData] = useState({
|
||||
classId: "",
|
||||
userId: "",
|
||||
amount: "",
|
||||
dueDate: "",
|
||||
description: "",
|
||||
createForAll: false,
|
||||
});
|
||||
|
||||
// Effect to update students when class changes
|
||||
useEffect(() => {
|
||||
const selectedClass = classes.find(cls => cls._id === formData.classId);
|
||||
if (selectedClass && selectedClass.students) {
|
||||
// Filter users that are in the selected class
|
||||
const students = users.filter(user =>
|
||||
selectedClass.students.includes(user._id)
|
||||
);
|
||||
setClassStudents(students);
|
||||
} else {
|
||||
setClassStudents([]);
|
||||
}
|
||||
|
||||
// Reset userId when class changes
|
||||
setFormData(prev => ({ ...prev, userId: "" }));
|
||||
}, [formData.classId, classes, users]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
try {
|
||||
const formDataToSend = new FormData();
|
||||
formDataToSend.append("classId", formData.classId);
|
||||
formDataToSend.append("amount", parseFloat(formData.amount));
|
||||
|
||||
if (!formData.createForAll) {
|
||||
formDataToSend.append("userId", formData.userId);
|
||||
}
|
||||
|
||||
if (formData.dueDate) {
|
||||
formDataToSend.append("dueDate", formData.dueDate);
|
||||
}
|
||||
|
||||
if (formData.description) {
|
||||
formDataToSend.append("description", formData.description);
|
||||
}
|
||||
|
||||
const result = await createObligationAction(formDataToSend);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "Erro ao criar obrigação");
|
||||
}
|
||||
|
||||
setSuccess(result.message);
|
||||
setFormData({
|
||||
classId: "",
|
||||
userId: "",
|
||||
amount: "",
|
||||
dueDate: "",
|
||||
description: "",
|
||||
createForAll: false,
|
||||
});
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-neutral-100 mb-4">
|
||||
Criar Obrigação de Pagamento
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-600 dark:bg-red-900/50 border border-red-600 dark:border-red-600 text-white dark:text-red-200 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-emerald-600 dark:bg-emerald-900/50 border border-emerald-600 dark:border-emerald-600 text-white dark:text-emerald-200 px-4 py-3 rounded mb-4">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
|
||||
Classe *
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={formData.classId}
|
||||
onChange={(e) => setFormData({ ...formData, classId: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="">Selecione uma classe</option>
|
||||
{classes.map((cls) => (
|
||||
<option key={cls._id} value={cls._id}>
|
||||
{cls.classTitle}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
|
||||
Aluno (deixe vazio para todos)
|
||||
</label>
|
||||
<select
|
||||
value={formData.userId}
|
||||
onChange={(e) => setFormData({ ...formData, userId: e.target.value })}
|
||||
disabled={formData.createForAll || !formData.classId}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 disabled:opacity-50"
|
||||
>
|
||||
<option value="">Selecione um aluno (opcional)</option>
|
||||
{classStudents.map((user) => (
|
||||
<option key={user._id} value={user._id}>
|
||||
{user.fullName}
|
||||
</option>
|
||||
))}
|
||||
{formData.classId && classStudents.length === 0 && (
|
||||
<option value="" disabled>
|
||||
Nenhum aluno encontrado nesta turma
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
|
||||
Valor (R$) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
value={formData.amount}
|
||||
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
|
||||
Data de Vencimento
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
lang="pt-BR"
|
||||
value={formData.dueDate}
|
||||
onChange={(e) => setFormData({ ...formData, dueDate: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
|
||||
Descrição (ex: "Mensalidade Janeiro 2025")
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength="200"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
|
||||
placeholder="Descrição da obrigação"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="createForAll"
|
||||
checked={formData.createForAll}
|
||||
onChange={(e) => setFormData({ ...formData, createForAll: e.target.checked, userId: "" })}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="createForAll" className="ml-2 block text-sm text-gray-700 dark:text-gray-200 label-dark">
|
||||
Criar para todos os alunos da turma
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? "Criando..." : "Criar Obrigação"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { updateObligation, deleteObligation } from "@/app/lib/actions/paymentActions";
|
||||
import { FaEdit, FaTrash, FaSave, FaTimes } from "react-icons/fa";
|
||||
|
||||
export default function EditObligationModal({ obligation, isOpen, onClose, onSuccess }) {
|
||||
const [formData, setFormData] = useState({
|
||||
amount: "",
|
||||
description: "",
|
||||
dueDate: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
// Update form data when obligation changes
|
||||
useEffect(() => {
|
||||
if (obligation) {
|
||||
setFormData({
|
||||
amount: obligation.amount?.toString() || "",
|
||||
description: obligation.description || "",
|
||||
dueDate: obligation.dueDate
|
||||
? new Date(obligation.dueDate).toISOString().split("T")[0]
|
||||
: "",
|
||||
});
|
||||
}
|
||||
}, [obligation]);
|
||||
|
||||
if (!isOpen || !obligation) return null;
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const result = await updateObligation(obligation._id, {
|
||||
amount: parseFloat(formData.amount),
|
||||
description: formData.description,
|
||||
dueDate: formData.dueDate || null,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error || "Erro ao atualizar obrigação");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || "Erro ao atualizar obrigação");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const result = await deleteObligation(obligation._id);
|
||||
|
||||
if (result.success) {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error || "Erro ao excluir obrigação");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || "Erro ao excluir obrigação");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-xl shadow-xl w-full max-w-md border border-gray-200 dark:border-neutral-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-neutral-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-neutral-100">
|
||||
Editar Obrigação
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<FaTimes className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-600 dark:bg-red-900/50 border border-red-600 dark:border-red-600 text-white dark:text-red-200 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Amount */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
|
||||
Valor (R$)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.amount}
|
||||
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
|
||||
className="w-full border border-gray-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Due Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
|
||||
Data de Vencimento
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
lang="pt-BR"
|
||||
value={formData.dueDate}
|
||||
onChange={(e) => setFormData({ ...formData, dueDate: e.target.value })}
|
||||
className="w-full border border-gray-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 label-dark mb-1">
|
||||
Descrição
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full border border-gray-300 dark:border-neutral-600 rounded-lg px-3 py-2 bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
placeholder="Descrição da obrigação..."
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-200 label-dark mt-1">
|
||||
{formData.description.length}/200 caracteres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-white bg-red-600 border border-red-600 rounded-lg hover:bg-red-700 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800 dark:hover:bg-red-900/30 disabled:opacity-50"
|
||||
>
|
||||
<FaTrash className="w-4 h-4" />
|
||||
Excluir
|
||||
</button>
|
||||
<div className="flex-1"></div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-200 label-dark bg-white dark:bg-neutral-700 border border-gray-300 dark:border-neutral-600 rounded-lg hover:bg-gray-50 dark:hover:bg-neutral-600 disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
<FaSave className="w-4 h-4" />
|
||||
{loading ? "Salvando..." : "Salvar"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center z-10 rounded-xl">
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg p-6 m-4 max-w-sm border border-gray-200 dark:border-neutral-700 shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-neutral-100 mb-2">
|
||||
Confirmar Exclusão
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-200 label-dark mb-4">
|
||||
Tem certeza que deseja excluir esta obrigação? Esta ação não pode ser desfeita.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-200 label-dark bg-white dark:bg-neutral-700 border border-gray-300 dark:border-neutral-600 rounded-lg hover:bg-gray-50 dark:hover:bg-neutral-600 disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
<FaTrash className="w-4 h-4" />
|
||||
{loading ? "Excluindo..." : "Excluir"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import Label from "@/app/(protected)/components/shared/Label";
|
||||
import { getPaymentsByObligationAction } from "@/app/lib/payments/actions";
|
||||
|
||||
export default function ObligationDetails({ obligations }) {
|
||||
const [expandedObligation, setExpandedObligation] = useState(null);
|
||||
const [obligationPayments, setObligationPayments] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Group obligations by their reference (description + dueDate)
|
||||
const groupedObligations = obligations.reduce((acc, obligation) => {
|
||||
const key = `${obligation.description || "Pagamento"}_${obligation.dueDate || obligation.createdAt}`;
|
||||
if (!acc[key]) {
|
||||
acc[key] = {
|
||||
...obligation,
|
||||
users: [],
|
||||
};
|
||||
}
|
||||
acc[key].users.push(obligation);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const groupedList = Object.values(groupedObligations);
|
||||
|
||||
const fetchPaymentsForObligation = async (obligation) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getPaymentsByObligationAction(obligation._id);
|
||||
if (result.success) {
|
||||
setObligationPayments(prev => ({
|
||||
...prev,
|
||||
[obligation._id]: result.data || [],
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching payments:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleExpand = (obligation) => {
|
||||
if (expandedObligation === obligation._id) {
|
||||
setExpandedObligation(null);
|
||||
} else {
|
||||
setExpandedObligation(obligation._id);
|
||||
if (!obligationPayments[obligation._id]) {
|
||||
fetchPaymentsForObligation(obligation);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (userObligation) => {
|
||||
const payments = obligationPayments[userObligation._id] || [];
|
||||
const hasVerified = payments.some(p => p.status === "verified");
|
||||
const hasPending = payments.some(p => p.status === "pending_verification");
|
||||
|
||||
if (hasVerified) {
|
||||
return <Label color="emerald" size="sm">✓ Pago</Label>;
|
||||
}
|
||||
if (hasPending) {
|
||||
return <Label color="blue" size="sm">Aguardando Verificação</Label>;
|
||||
}
|
||||
return <Label color="amber" size="sm">Aguardando Pagamento</Label>;
|
||||
};
|
||||
|
||||
if (obligations.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-6 text-center text-gray-500 dark:text-gray-200 label-dark">
|
||||
Nenhuma obrigação de pagamento criada ainda.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{groupedList.map((group) => (
|
||||
<div
|
||||
key={group._id}
|
||||
className="bg-white dark:bg-neutral-800 rounded-lg shadow overflow-hidden"
|
||||
>
|
||||
{/* Group Header */}
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-neutral-700/50 transition-colors"
|
||||
onClick={() => handleToggleExpand(group)}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-neutral-100">
|
||||
{group.description || "Pagamento"}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">
|
||||
Vencimento: {format(new Date(group.dueDate || group.createdAt), "dd/MM/yyyy", { locale: ptBR })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">
|
||||
{group.users.length} {group.users.length === 1 ? "aluno" : "alunos"}
|
||||
</p>
|
||||
<p className="text-lg font-bold text-gray-900 dark:text-neutral-100">
|
||||
R$ {group.amount?.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-gray-400 dark:text-neutral-500">
|
||||
{expandedObligation === group._id ? "▼" : "▶"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedObligation === group._id && (
|
||||
<div className="border-t border-gray-200 dark:border-neutral-700">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
|
||||
<thead className="bg-gray-50 dark:bg-neutral-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase tracking-wider">
|
||||
Aluno
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase tracking-wider">
|
||||
Valor
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-gray-200 dark:divide-neutral-700">
|
||||
{group.users.map((userObligation) => (
|
||||
<tr
|
||||
key={userObligation._id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-neutral-700/50"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
|
||||
{userObligation.userId?.fullName || "N/A"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-200 label-dark">
|
||||
{userObligation.userId?.email || "N/A"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100 font-medium">
|
||||
R$ {userObligation.amount?.toFixed(2) || "0.00"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(userObligation)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export default function PaymentStats({ stats }) {
|
||||
const statsCards = [
|
||||
{
|
||||
title: "Total de Obrigações",
|
||||
value: stats.totalObligations || 0,
|
||||
color: "yellow",
|
||||
icon: "📋",
|
||||
},
|
||||
{
|
||||
title: "Aguardando Pagamento",
|
||||
value: stats.pendingObligations || 0,
|
||||
color: "yellow",
|
||||
icon: "⏳",
|
||||
},
|
||||
{
|
||||
title: "Pagamentos Enviados",
|
||||
value: stats.pendingPayments || 0,
|
||||
color: "blue",
|
||||
icon: "📤",
|
||||
},
|
||||
{
|
||||
title: "Pagamentos Verificados",
|
||||
value: stats.verifiedPayments || 0,
|
||||
color: "green",
|
||||
icon: "✅",
|
||||
},
|
||||
{
|
||||
title: "Pagamentos Rejeitados",
|
||||
value: stats.rejectedPayments || 0,
|
||||
color: "red",
|
||||
icon: "❌",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||
{statsCards.map((card) => (
|
||||
<div
|
||||
key={card.title}
|
||||
className={`bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 ${
|
||||
card.color === "yellow" ? "border-amber-500 dark:border-amber-400" :
|
||||
card.color === "blue" ? "border-blue-500 dark:border-blue-400" :
|
||||
card.color === "green" ? "border-emerald-500 dark:border-emerald-400" :
|
||||
"border-red-500 dark:border-red-400"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-200 label-dark">{card.title}</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-neutral-100 mt-1">
|
||||
{card.value}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-2xl">{card.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { FaCheck, FaTimes, FaEye, FaEdit, FaList, FaFileInvoiceDollar } from "react-icons/fa";
|
||||
import EditObligationModal from "./EditObligationModal";
|
||||
import Label from "@/app/(protected)/components/shared/Label";
|
||||
import Button from "@/app/(protected)/components/shared/Button";
|
||||
import { updatePaymentStatusAction } from "@/app/lib/payments/actions";
|
||||
|
||||
function PaymentStatusBadge({ status, type }) {
|
||||
const getStatusConfig = (status, type) => {
|
||||
if (type === "obligation") {
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return { color: "emerald", label: "Paga" };
|
||||
case "partially_paid":
|
||||
return { color: "blue", label: "Parcialmente Paga" };
|
||||
default:
|
||||
return { color: "amber", label: "Pendente" };
|
||||
}
|
||||
}
|
||||
switch (status) {
|
||||
case "verified":
|
||||
return { color: "emerald", label: "Confirmado" };
|
||||
case "pending_verification":
|
||||
return { color: "blue", label: "Aguardando Verificação" };
|
||||
case "rejected":
|
||||
return { color: "red", label: "Rejeitado" };
|
||||
default:
|
||||
return { color: "gray", label: "Pendente" };
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(status, type);
|
||||
return (
|
||||
<Label color={config.color} size="sm">
|
||||
{config.label}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ OBRIGAÇÕES VIEW ============
|
||||
function ObligationsCard({ classData, isExpanded, onToggle, obligations, statusFilter, onEditObligation }) {
|
||||
// Filter obligations based on status
|
||||
const filteredObligations = useMemo(() => {
|
||||
if (statusFilter === "all") return obligations;
|
||||
return obligations.filter(o => {
|
||||
if (statusFilter === "pending") return o.status === "pending";
|
||||
if (statusFilter === "partially_paid") return o.status === "partially_paid";
|
||||
if (statusFilter === "paid") return o.status === "paid";
|
||||
return true;
|
||||
});
|
||||
}, [obligations, statusFilter]);
|
||||
|
||||
const pendingCount = obligations.filter(o => o.status === "pending").length;
|
||||
const partiallyPaidCount = obligations.filter(o => o.status === "partially_paid").length;
|
||||
const paidCount = obligations.filter(o => o.status === "paid").length;
|
||||
const totalPendingAmount = obligations
|
||||
.filter(o => o.status === "pending")
|
||||
.reduce((sum, o) => sum + (o.remainingAmount || o.amount || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-neutral-700">
|
||||
<div className="p-4 cursor-pointer hover-card-light" onClick={onToggle}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-neutral-100">
|
||||
{classData.classTitle}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">
|
||||
{filteredObligations.length} obrigação(ões)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right text-sm">
|
||||
<span className="block text-amber-600 dark:text-amber-400">
|
||||
{pendingCount} pendente{totalPendingAmount > 0 && ` (R$ ${totalPendingAmount.toFixed(2)})`}
|
||||
</span>
|
||||
<span className="block text-blue-600 dark:text-blue-400">
|
||||
{partiallyPaidCount} parcial
|
||||
</span>
|
||||
<span className="block text-emerald-600 dark:text-emerald-400">
|
||||
{paidCount} paga
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-400 dark:text-neutral-500 text-lg">
|
||||
{isExpanded ? "▼" : "▶"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-neutral-700">
|
||||
{filteredObligations.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-gray-200 label-dark">
|
||||
Nenhuma obrigação encontrada com o filtro aplicado.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200 dark:divide-neutral-700">
|
||||
{filteredObligations.map((obligation) => (
|
||||
<ObligationRow
|
||||
key={obligation._id}
|
||||
obligation={obligation}
|
||||
onEdit={onEditObligation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ObligationRow({ obligation, onEdit }) {
|
||||
const [showPayments, setShowPayments] = useState(false);
|
||||
const paidAmount = obligation.payments
|
||||
?.filter(p => p.status === "verified")
|
||||
.reduce((sum, p) => sum + (p.amount || 0), 0) || 0;
|
||||
const remainingAmount = (obligation.amount || 0) - paidAmount;
|
||||
const percentage = obligation.amount > 0 ? (paidAmount / obligation.amount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="font-medium text-gray-900 dark:text-neutral-100">
|
||||
{obligation.userId?.fullName || "N/A"}
|
||||
</h4>
|
||||
<PaymentStatusBadge status={obligation.status} type="obligation" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark mb-2">
|
||||
{obligation.description || "Mensalidade"} • Vencimento: {obligation.dueDate ? format(new Date(obligation.dueDate), "dd/MM/yyyy", { locale: ptBR }) : "N/A"}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-gray-900 dark:text-neutral-100">
|
||||
Total: <strong>R$ {obligation.amount?.toFixed(2) || "0.00"}</strong>
|
||||
</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
Pago: R$ {paidAmount.toFixed(2)}
|
||||
</span>
|
||||
{remainingAmount > 0 && (
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
Restante: R$ {remainingAmount.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-2 w-full bg-gray-200 dark:bg-neutral-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-emerald-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{obligation.payments && obligation.payments.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowPayments(!showPayments)}
|
||||
className="px-3 py-1 text-sm border border-gray-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
|
||||
>
|
||||
{showPayments ? "Ocultar" : "Ver"} {obligation.payments.length} { obligation.payments.length > 1 ? "movimentações " : "movimentação"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onEdit(obligation)}
|
||||
title="Editar obrigação"
|
||||
className="p-2 text-neutral-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
<FaEdit className="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment History */}
|
||||
{showPayments && obligation.payments && obligation.payments.length > 0 && (
|
||||
<div className="mt-4 pl-4 border-l-4 border-emerald-500">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase mb-2">Histórico do Pagamento</p>
|
||||
<div className="space-y-2">
|
||||
{obligation.payments.map((payment) => (
|
||||
<div key={payment._id} className="flex items-center justify-between text-sm p-2 bg-gray-50 dark:bg-neutral-700 rounded">
|
||||
<div className="flex items-center gap-3">
|
||||
<PaymentStatusBadge status={payment.status} type="payment" />
|
||||
<span className="text-gray-900 dark:text-neutral-100">
|
||||
{payment.paymentDate ? format(new Date(payment.paymentDate), "dd/MM/yyyy", { locale: ptBR }) : "N/A"}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-200 label-dark">
|
||||
{payment.paymentMethod === "pix" ? "PIX" :
|
||||
payment.paymentMethod === "bank_transfer" ? "Transferência" :
|
||||
payment.paymentMethod === "cash" ? "Dinheiro" :
|
||||
payment.paymentMethod}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-neutral-100">
|
||||
R$ {payment.amount?.toFixed(2) || "0.00"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ HISTÓRICO VIEW ============
|
||||
function TransactionsCard({ classData, isExpanded, onToggle, transactions, statusFilter, onVerify, onRejectClick, processingId, onViewReceipt }) {
|
||||
// Filter transactions based on status
|
||||
const filteredTransactions = useMemo(() => {
|
||||
if (statusFilter === "all") return transactions;
|
||||
return transactions.filter(t => {
|
||||
if (statusFilter === "pending_verification") return t.status === "pending_verification";
|
||||
if (statusFilter === "verified") return t.status === "verified";
|
||||
if (statusFilter === "rejected") return t.status === "rejected";
|
||||
return true;
|
||||
});
|
||||
}, [transactions, statusFilter]);
|
||||
|
||||
const pendingCount = transactions.filter(t => t.status === "pending_verification").length;
|
||||
const verifiedCount = transactions.filter(t => t.status === "verified").length;
|
||||
const rejectedCount = transactions.filter(t => t.status === "rejected").length;
|
||||
const totalVerified = transactions
|
||||
.filter(t => t.status === "verified")
|
||||
.reduce((sum, t) => sum + (t.amount || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-neutral-700">
|
||||
<div className="p-4 cursor-pointer hover-card-light" onClick={onToggle}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-neutral-100">
|
||||
{classData.classTitle}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">
|
||||
{filteredTransactions.length} movimentação(ões)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right text-sm">
|
||||
<span className="block text-blue-600 dark:text-blue-400">
|
||||
{pendingCount} em verificação
|
||||
</span>
|
||||
<span className="block text-emerald-600 dark:text-emerald-400">
|
||||
{verifiedCount} confirmado
|
||||
</span>
|
||||
<span className="block text-red-600 dark:text-red-400">
|
||||
{rejectedCount} rejeitado
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-400 dark:text-neutral-500 text-lg">
|
||||
{isExpanded ? "▼" : "▶"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-neutral-700">
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-gray-200 label-dark">
|
||||
Nenhuma movimentação encontrada com o filtro aplicado.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-neutral-700">
|
||||
<thead className="bg-gray-50 dark:bg-neutral-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Data</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Aluno</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Método</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Valor</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-200 label-dark uppercase">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-gray-200 dark:divide-neutral-700">
|
||||
{filteredTransactions.map((transaction) => (
|
||||
<tr key={transaction._id} className="hover-card-light">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-200 label-dark">
|
||||
{transaction.paymentDate ? format(new Date(transaction.paymentDate), "dd/MM/yyyy", { locale: ptBR }) : "N/A"}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
|
||||
{transaction.userId?.fullName || "N/A"}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-200 label-dark">
|
||||
{transaction.paymentMethod === "pix" ? "PIX" :
|
||||
transaction.paymentMethod === "bank_transfer" ? "Transferência" :
|
||||
transaction.paymentMethod === "cash" ? "Dinheiro" :
|
||||
transaction.paymentMethod === "credit_card" ? "Cartão Crédito" :
|
||||
transaction.paymentMethod === "debit_card" ? "Cartão Débito" :
|
||||
transaction.paymentMethod || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100 font-medium">
|
||||
R$ {transaction.amount?.toFixed(2) || "0.00"}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<PaymentStatusBadge status={transaction.status} type="payment" />
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-right">
|
||||
<div className="flex justify-end gap-2 items-center">
|
||||
{transaction.receiptUrl ? (
|
||||
<button
|
||||
onClick={() => onViewReceipt(transaction.receiptUrl)}
|
||||
title="Ver comprovante"
|
||||
className="p-2 text-blue-500 hover:text-blue-700 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<FaEye className="text-lg" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-neutral-400 dark:text-neutral-500 text-xs italic">Sem comp.</span>
|
||||
)}
|
||||
{transaction.status === "pending_verification" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onVerify(transaction._id, "verified")}
|
||||
disabled={processingId === transaction._id}
|
||||
title="Aprovar"
|
||||
className="p-2 text-neutral-500 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<FaCheck className="text-lg" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRejectClick(transaction._id)}
|
||||
disabled={processingId === transaction._id}
|
||||
title="Rejeitar"
|
||||
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<FaTimes className="text-lg" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaymentsByClass({ payments, classes }) {
|
||||
const router = useRouter();
|
||||
const [viewMode, setViewMode] = useState("obligations"); // "obligations" or "transactions"
|
||||
const [expandedClasses, setExpandedClasses] = useState({});
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [processingId, setProcessingId] = useState(null);
|
||||
const [rejectModal, setRejectModal] = useState({ open: false, paymentId: null, notes: "" });
|
||||
const [receiptModal, setReceiptModal] = useState({ open: false, url: null });
|
||||
const [editModal, setEditModal] = useState({ open: false, obligation: null });
|
||||
|
||||
// Separate obligations and payments, and link them
|
||||
const { obligationsByClass, transactionsByClass } = useMemo(() => {
|
||||
const obligations = {};
|
||||
const transactions = {};
|
||||
|
||||
payments.forEach((payment) => {
|
||||
const classId = payment.classId?._id || "unknown";
|
||||
|
||||
if (payment.type === "obligation") {
|
||||
// Initialize obligation entry if needed
|
||||
if (!obligations[classId]) {
|
||||
obligations[classId] = {
|
||||
classId: payment.classId,
|
||||
classTitle: payment.classId?.classTitle || "Turma Desconhecida",
|
||||
obligations: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate paid amount and status
|
||||
const relatedPayments = payments.filter(
|
||||
p => p.type === "payment" && p.relatedObligationId?.toString() === payment._id?.toString()
|
||||
);
|
||||
const paidAmount = relatedPayments
|
||||
.filter(p => p.status === "verified")
|
||||
.reduce((sum, p) => sum + (p.amount || 0), 0);
|
||||
const remainingAmount = (payment.amount || 0) - paidAmount;
|
||||
|
||||
let status = "pending";
|
||||
if (paidAmount >= payment.amount) {
|
||||
status = "paid";
|
||||
} else if (paidAmount > 0) {
|
||||
status = "partially_paid";
|
||||
}
|
||||
|
||||
obligations[classId].obligations.push({
|
||||
...payment,
|
||||
payments: relatedPayments,
|
||||
paidAmount,
|
||||
remainingAmount,
|
||||
status,
|
||||
});
|
||||
} else if (payment.type === "payment") {
|
||||
// Group transactions by class
|
||||
if (!transactions[classId]) {
|
||||
transactions[classId] = {
|
||||
classId: payment.classId,
|
||||
classTitle: payment.classId?.classTitle || "Turma Desconhecida",
|
||||
transactions: [],
|
||||
};
|
||||
}
|
||||
transactions[classId].transactions.push(payment);
|
||||
}
|
||||
});
|
||||
|
||||
return { obligationsByClass: obligations, transactionsByClass: transactions };
|
||||
}, [payments]);
|
||||
|
||||
// Get current data based on view mode
|
||||
const currentData = viewMode === "obligations" ? obligationsByClass : transactionsByClass;
|
||||
|
||||
// Filter classes based on search and status
|
||||
const filteredClasses = useMemo(() => {
|
||||
const classesList = Object.values(currentData);
|
||||
if (statusFilter === "all") {
|
||||
return classesList.filter(cls =>
|
||||
cls.classTitle.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
return classesList.filter(cls => {
|
||||
const items = viewMode === "obligations" ? cls.obligations : cls.transactions;
|
||||
const hasMatchingStatus = items.some(item => {
|
||||
if (viewMode === "obligations") {
|
||||
if (statusFilter === "pending") return item.status === "pending";
|
||||
if (statusFilter === "partially_paid") return item.status === "partially_paid";
|
||||
if (statusFilter === "paid") return item.status === "paid";
|
||||
} else {
|
||||
if (statusFilter === "pending_verification") return item.status === "pending_verification";
|
||||
if (statusFilter === "verified") return item.status === "verified";
|
||||
if (statusFilter === "rejected") return item.status === "rejected";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return hasMatchingStatus && cls.classTitle.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
});
|
||||
}, [currentData, searchTerm, statusFilter, viewMode]);
|
||||
|
||||
// Get filter options based on view mode
|
||||
const filterOptions = viewMode === "obligations"
|
||||
? [
|
||||
{ value: "all", label: "Todos os status" },
|
||||
{ value: "pending", label: "Pendentes" },
|
||||
{ value: "partially_paid", label: "Parcialmente Pagas" },
|
||||
{ value: "paid", label: "Pagas" },
|
||||
]
|
||||
: [
|
||||
{ value: "all", label: "Todos os status" },
|
||||
{ value: "pending_verification", label: "Aguardando Verificação" },
|
||||
{ value: "verified", label: "Confirmados" },
|
||||
{ value: "rejected", label: "Rejeitados" },
|
||||
];
|
||||
|
||||
const toggleClass = (classId) => {
|
||||
setExpandedClasses(prev => ({
|
||||
...prev,
|
||||
[classId]: !prev[classId],
|
||||
}));
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
const allExpanded = {};
|
||||
filteredClasses.forEach(cls => {
|
||||
allExpanded[cls.classId?._id || "unknown"] = true;
|
||||
});
|
||||
setExpandedClasses(allExpanded);
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedClasses({});
|
||||
};
|
||||
|
||||
const handleVerify = async (id, status, notes = "") => {
|
||||
setProcessingId(id);
|
||||
try {
|
||||
const result = await updatePaymentStatusAction(id, { status, notes });
|
||||
|
||||
if (result.success) {
|
||||
router.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating payment:", error);
|
||||
} finally {
|
||||
setProcessingId(null);
|
||||
setRejectModal({ open: false, paymentId: null, notes: "" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectClick = (paymentId) => {
|
||||
setRejectModal({ open: true, paymentId, notes: "" });
|
||||
};
|
||||
|
||||
const confirmReject = () => {
|
||||
if (rejectModal.paymentId) {
|
||||
handleVerify(rejectModal.paymentId, "rejected", rejectModal.notes);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditObligation = (obligation) => {
|
||||
setEditModal({ open: true, obligation });
|
||||
};
|
||||
|
||||
const handleCloseEditModal = () => {
|
||||
setEditModal({ open: false, obligation: null });
|
||||
};
|
||||
|
||||
const handleEditSuccess = () => {
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
if (payments.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-6 text-center text-gray-500 dark:text-gray-200 label-dark">
|
||||
Nenhum pagamento encontrado.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate summary stats
|
||||
const obligationStats = {
|
||||
pending: Object.values(obligationsByClass).flatMap(c => c.obligations).filter(o => o.status === "pending").length,
|
||||
partiallyPaid: Object.values(obligationsByClass).flatMap(c => c.obligations).filter(o => o.status === "partially_paid").length,
|
||||
paid: Object.values(obligationsByClass).flatMap(c => c.obligations).filter(o => o.status === "paid").length,
|
||||
};
|
||||
const transactionStats = {
|
||||
pendingVerification: Object.values(transactionsByClass).flatMap(c => c.transactions).filter(t => t.status === "pending_verification").length,
|
||||
verified: Object.values(transactionsByClass).flatMap(c => c.transactions).filter(t => t.status === "verified").length,
|
||||
rejected: Object.values(transactionsByClass).flatMap(c => c.transactions).filter(t => t.status === "rejected").length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex flex-wrap gap-4 mb-6">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar turma..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-neutral-600">
|
||||
<button
|
||||
onClick={() => { setViewMode("obligations"); setStatusFilter("all"); }}
|
||||
className={`px-4 py-2 flex items-center gap-2 text-sm font-medium transition-colors ${
|
||||
viewMode === "obligations"
|
||||
? "bg-indigo-600 dark:bg-indigo-900/50 text-white dark:text-indigo-200"
|
||||
: "bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-neutral-600"
|
||||
}`}
|
||||
>
|
||||
<FaFileInvoiceDollar />
|
||||
Obrigações
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setViewMode("transactions"); setStatusFilter("all"); }}
|
||||
className={`px-4 py-2 flex items-center gap-2 text-sm font-medium transition-colors ${
|
||||
viewMode === "transactions"
|
||||
? "bg-indigo-600 dark:bg-indigo-900/50 text-white dark:text-indigo-200"
|
||||
: "bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-neutral-600"
|
||||
}`}
|
||||
>
|
||||
<FaList />
|
||||
Movimentações
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100"
|
||||
>
|
||||
{filterOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 dark:bg-blue-900/50 text-white dark:text-blue-200 text-sm font-medium hover:bg-blue-700 dark:hover:bg-blue-900/70 transition-colors"
|
||||
>
|
||||
Expandir Todos
|
||||
</button>
|
||||
<button
|
||||
onClick={collapseAll}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 text-sm font-medium hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
|
||||
>
|
||||
Recolher Todos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
{viewMode === "obligations" ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-amber-500">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Pendentes</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{obligationStats.pending}</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-blue-500">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Parcialmente Pagas</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{obligationStats.partiallyPaid}</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-emerald-500">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Pagas</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{obligationStats.paid}</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-gray-500">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Total de Obrigações</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">
|
||||
{obligationStats.pending + obligationStats.partiallyPaid + obligationStats.paid}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-blue-500">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Aguardando Verificação</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{transactionStats.pendingVerification}</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-emerald-500">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Confirmadas</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{transactionStats.verified}</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-red-500">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Rejeitadas</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">{transactionStats.rejected}</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-4 border-l-4 border-gray-500">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-200 label-dark">Total de Movimentações</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-neutral-100">
|
||||
{transactionStats.pendingVerification + transactionStats.verified + transactionStats.rejected}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Classes List */}
|
||||
<div className="space-y-4">
|
||||
{filteredClasses.length === 0 ? (
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow p-6 text-center text-gray-500 dark:text-gray-200 label-dark">
|
||||
Nenhuma turma encontrada com os filtros aplicados.
|
||||
</div>
|
||||
) : (
|
||||
filteredClasses.map((classData) => (
|
||||
viewMode === "obligations" ? (
|
||||
<ObligationsCard
|
||||
key={classData.classId?._id || "unknown"}
|
||||
classData={classData}
|
||||
isExpanded={!!expandedClasses[classData.classId?._id || "unknown"]}
|
||||
onToggle={() => toggleClass(classData.classId?._id || "unknown")}
|
||||
obligations={classData.obligations}
|
||||
statusFilter={statusFilter}
|
||||
onEditObligation={handleEditObligation}
|
||||
/>
|
||||
) : (
|
||||
<TransactionsCard
|
||||
key={classData.classId?._id || "unknown"}
|
||||
classData={classData}
|
||||
isExpanded={!!expandedClasses[classData.classId?._id || "unknown"]}
|
||||
onToggle={() => toggleClass(classData.classId?._id || "unknown")}
|
||||
transactions={classData.transactions}
|
||||
statusFilter={statusFilter}
|
||||
onVerify={handleVerify}
|
||||
onRejectClick={handleRejectClick}
|
||||
processingId={processingId}
|
||||
onViewReceipt={(url) => setReceiptModal({ open: true, url })}
|
||||
/>
|
||||
)
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reject Modal */}
|
||||
{rejectModal.open && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-neutral-100">
|
||||
Motivo da Rejeição
|
||||
</h3>
|
||||
<textarea
|
||||
value={rejectModal.notes}
|
||||
onChange={(e) => setRejectModal({ ...rejectModal, notes: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 text-sm mb-4"
|
||||
rows={3}
|
||||
placeholder="Informe o motivo da rejeição (opcional)"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setRejectModal({ open: false, paymentId: null, notes: "" })}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={confirmReject}
|
||||
disabled={processingId !== null}
|
||||
>
|
||||
Confirmar Rejeição
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Receipt Modal */}
|
||||
{receiptModal.open && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={() => setReceiptModal({ open: false, url: null })}>
|
||||
<div className="relative max-w-4xl max-h-[90vh] w-full" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setReceiptModal({ open: false, url: null })}
|
||||
className="absolute -top-10 right-0 text-white hover:text-gray-300 transition-colors text-sm"
|
||||
>
|
||||
✕ Fechar
|
||||
</button>
|
||||
{receiptModal.url?.includes("application/pdf") || receiptModal.url?.endsWith(".pdf") || receiptModal.url?.includes("data:application/pdf") ? (
|
||||
<iframe
|
||||
src={receiptModal.url}
|
||||
className="w-full h-[85vh] rounded-lg shadow-xl"
|
||||
title="Comprovante de pagamento"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={receiptModal.url}
|
||||
alt="Comprovante de pagamento"
|
||||
className="w-full h-auto max-h-[85vh] object-contain rounded-lg shadow-xl"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 flex justify-center gap-3">
|
||||
<a
|
||||
href={receiptModal.url}
|
||||
download="comprovante"
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 dark:bg-blue-900/50 text-white dark:text-blue-200 text-sm font-medium hover:bg-blue-700 dark:hover:bg-blue-900/70 transition-colors"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setReceiptModal({ open: false, url: null })}
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Obligation Modal */}
|
||||
<EditObligationModal
|
||||
obligation={editModal.obligation}
|
||||
isOpen={editModal.open}
|
||||
onClose={handleCloseEditModal}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { FaEye } from "react-icons/fa";
|
||||
import Label from "@/app/(protected)/components/shared/Label";
|
||||
import { updatePaymentStatusAction } from "@/app/lib/payments/actions";
|
||||
|
||||
export default function PaymentsTable({ payments }) {
|
||||
const router = useRouter();
|
||||
// Debug: log payments with receipt info
|
||||
payments.forEach(p => {
|
||||
if (p.type === "payment") {
|
||||
console.log(`Payment ${p._id}:`, {
|
||||
type: p.type,
|
||||
status: p.status,
|
||||
receiptUrl: p.receiptUrl,
|
||||
hasReceipt: !!p.receiptUrl,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [processingId, setProcessingId] = useState(null);
|
||||
const [rejectModal, setRejectModal] = useState({ open: false, paymentId: null, notes: "" });
|
||||
const [receiptModal, setReceiptModal] = useState({ open: false, url: null });
|
||||
|
||||
const filteredPayments = payments.filter((payment) => {
|
||||
if (filter !== "all" && payment.type !== filter) return false;
|
||||
if (statusFilter !== "all" && payment.status !== statusFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const getStatusBadge = (payment) => {
|
||||
if (payment.type === "obligation") {
|
||||
return <Label color="amber" size="sm">Aguardando Pagamento</Label>;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
pending_verification: { color: "blue", label: "Aguardando Verificação" },
|
||||
verified: { color: "emerald", label: "Pago" },
|
||||
rejected: { color: "red", label: "Rejeitado" },
|
||||
};
|
||||
|
||||
const config = statusConfig[payment.status];
|
||||
if (!config) return <Label color="gray" size="sm">{payment.status}</Label>;
|
||||
|
||||
return <Label color={config.color} size="sm">{config.label}</Label>;
|
||||
};
|
||||
|
||||
const handleVerify = async (id, status, notes = "") => {
|
||||
setProcessingId(id);
|
||||
try {
|
||||
const result = await updatePaymentStatusAction(id, { status, notes });
|
||||
|
||||
if (result.success) {
|
||||
router.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating payment:", error);
|
||||
} finally {
|
||||
setProcessingId(null);
|
||||
setRejectModal({ open: false, paymentId: null, notes: "" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectClick = (paymentId) => {
|
||||
setRejectModal({ open: true, paymentId, notes: "" });
|
||||
};
|
||||
|
||||
const confirmReject = () => {
|
||||
if (rejectModal.paymentId) {
|
||||
handleVerify(rejectModal.paymentId, "rejected", rejectModal.notes);
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type) => {
|
||||
return type === "obligation" ? "Obrigação" : "Pagamento";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mt-6 overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800 flex gap-2 flex-wrap bg-neutral-50 dark:bg-neutral-800/50">
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilter("all");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
filter === "all" && statusFilter === "all"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
}`}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilter("obligation");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
filter === "obligation"
|
||||
? "bg-amber-500 text-white"
|
||||
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
}`}
|
||||
>
|
||||
Obrigações
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilter("payment");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
filter === "payment"
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
}`}
|
||||
>
|
||||
Pagamentos
|
||||
</button>
|
||||
|
||||
{filter === "payment" && (
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button
|
||||
onClick={() => setStatusFilter("all")}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === "all"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
}`}
|
||||
>
|
||||
Todos Status
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter("pending_verification")}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === "pending_verification"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
}`}
|
||||
>
|
||||
Pendentes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter("verified")}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === "verified"
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
}`}
|
||||
>
|
||||
Verificados
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter("rejected")}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === "rejected"
|
||||
? "bg-red-600 text-white"
|
||||
: "bg-white dark:bg-neutral-800 text-neutral-700 dark:text-gray-200 label-dark border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
}`}
|
||||
>
|
||||
Rejeitados
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-gray-200 label-dark font-semibold uppercase text-xs">
|
||||
<tr>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
Tipo
|
||||
</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
Aluno
|
||||
</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
Grupo
|
||||
</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
Pagador
|
||||
</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
Valor
|
||||
</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
Data
|
||||
</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-800 text-right">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{filteredPayments.map((payment) => (
|
||||
<tr
|
||||
key={payment._id}
|
||||
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<Label
|
||||
color={
|
||||
payment.type === "obligation"
|
||||
? "amber"
|
||||
: payment.status === "rejected"
|
||||
? "red"
|
||||
: "emerald"
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{payment.type === "obligation" ? "Obrigação" : payment.status === "rejected" ? "Pagamento Rejeitado" : "Pagamento"}
|
||||
</Label>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
|
||||
{payment.userId?.fullName || "N/A"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
|
||||
{payment.classId?.classTitle || "N/A"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100">
|
||||
{payment.payerName || "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-neutral-100 font-medium">
|
||||
R$ {payment.amount?.toFixed(2) || "0.00"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-200 label-dark">
|
||||
{payment.paymentDate
|
||||
? format(new Date(payment.paymentDate), "dd/MM/yyyy", { locale: ptBR })
|
||||
: payment.dueDate
|
||||
? `Venc: ${format(new Date(payment.dueDate), "dd/MM/yyyy", { locale: ptBR })}`
|
||||
: "N/A"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(payment)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-right">
|
||||
{payment.type === "payment" ? (
|
||||
<div className="flex justify-end gap-2 items-center">
|
||||
{payment.receiptUrl ? (
|
||||
<button
|
||||
onClick={() => setReceiptModal({ open: true, url: payment.receiptUrl })}
|
||||
title="Ver comprovante"
|
||||
className="p-2 text-blue-500 hover:text-blue-700 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<FaEye className="text-lg" />
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
title="Sem comprovante anexado"
|
||||
className="text-neutral-400 dark:text-neutral-500 text-xs italic"
|
||||
>
|
||||
Sem comp.
|
||||
</span>
|
||||
)}
|
||||
{payment.status === "pending_verification" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleVerify(payment._id, "verified")}
|
||||
disabled={processingId === payment._id}
|
||||
title="Aprovar"
|
||||
className="p-2 text-neutral-500 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="text-lg font-bold">✓</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRejectClick(payment._id)}
|
||||
disabled={processingId === payment._id}
|
||||
title="Rejeitar"
|
||||
className="p-2 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="text-lg font-bold">✗</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{payment.status === "verified" && (
|
||||
<Label color="emerald" size="sm">✓ Verificado</Label>
|
||||
)}
|
||||
{payment.status === "rejected" && (
|
||||
<Label color="red" size="sm">✗ Rejeitado</Label>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-neutral-700 dark:text-gray-200 label-dark text-xs italic">
|
||||
Aguardando pagamento
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredPayments.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-700 dark:text-gray-200 label-dark">
|
||||
Nenhum registro encontrado
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rejectModal.open && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-neutral-100">
|
||||
Motivo da Rejeição
|
||||
</h3>
|
||||
<textarea
|
||||
value={rejectModal.notes}
|
||||
onChange={(e) => setRejectModal({ ...rejectModal, notes: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700 text-gray-900 dark:text-neutral-100 text-sm mb-4"
|
||||
rows={3}
|
||||
placeholder="Informe o motivo da rejeição (opcional)"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setRejectModal({ open: false, paymentId: null, notes: "" })}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 text-sm font-medium hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmReject}
|
||||
disabled={processingId !== null}
|
||||
className="px-4 py-2 rounded-lg bg-red-600 dark:bg-red-900/50 text-white dark:text-red-200 text-sm font-medium hover:bg-red-700 dark:hover:bg-red-900/70 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Confirmar Rejeição
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Receipt Modal */}
|
||||
{receiptModal.open && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={() => setReceiptModal({ open: false, url: null })}>
|
||||
<div className="relative max-w-4xl max-h-[90vh] w-full" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setReceiptModal({ open: false, url: null })}
|
||||
className="absolute -top-10 right-0 text-white hover:text-gray-300 transition-colors text-sm"
|
||||
>
|
||||
✕ Fechar
|
||||
</button>
|
||||
{receiptModal.url?.includes("application/pdf") || receiptModal.url?.endsWith(".pdf") || receiptModal.url?.includes("data:application/pdf") ? (
|
||||
<iframe
|
||||
src={receiptModal.url}
|
||||
className="w-full h-[85vh] rounded-lg shadow-xl"
|
||||
title="Comprovante de pagamento"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={receiptModal.url}
|
||||
alt="Comprovante de pagamento"
|
||||
className="w-full h-auto max-h-[85vh] object-contain rounded-lg shadow-xl"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 flex justify-center gap-3">
|
||||
<a
|
||||
href={receiptModal.url}
|
||||
download="comprovante"
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 dark:bg-blue-900/50 text-white dark:text-blue-200 text-sm font-medium hover:bg-blue-700 dark:hover:bg-blue-900/70 transition-colors"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setReceiptModal({ open: false, url: null })}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-gray-700 dark:text-gray-200 text-sm font-medium hover:bg-gray-50 dark:hover:bg-neutral-600 transition-colors"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import MainSection from "@/app/(protected)/components/shared/Main";
|
||||
import PageHeader from "@/app/(protected)/components/shared/PageHeader";
|
||||
import { getPaymentModel } from "@/app/models/Payment";
|
||||
import { getUserModel } from "@/app/models/User";
|
||||
import { getClassModel } from "@/app/models/Class";
|
||||
import { auth } from "@/app/lib/utils/auth";
|
||||
import NotAuthorized from "@/app/auth/components/NotAuthorized";
|
||||
import { redirect } from "next/navigation";
|
||||
import PaymentStats from "./components/PaymentStats";
|
||||
import CreateObligationForm from "./components/CreateObligationForm";
|
||||
|
||||
function serializePayment(payment) {
|
||||
return {
|
||||
_id: payment._id?.toString(),
|
||||
classId: payment.classId
|
||||
? {
|
||||
_id: payment.classId._id?.toString(),
|
||||
classTitle: payment.classId.classTitle,
|
||||
}
|
||||
: null,
|
||||
userId: payment.userId
|
||||
? {
|
||||
_id: payment.userId._id?.toString(),
|
||||
fullName: payment.userId.fullName,
|
||||
email: payment.userId.email,
|
||||
}
|
||||
: null,
|
||||
type: payment.type,
|
||||
amount: payment.amount,
|
||||
status: payment.status,
|
||||
paymentDate: payment.paymentDate,
|
||||
dueDate: payment.dueDate,
|
||||
description: payment.description,
|
||||
paymentMethod: payment.paymentMethod,
|
||||
receiptUrl: payment.receiptUrl,
|
||||
notes: payment.notes,
|
||||
createdBy: payment.createdBy?.toString(),
|
||||
createdAt: payment.createdAt?.toISOString(),
|
||||
updatedAt: payment.updatedAt?.toISOString(),
|
||||
relatedObligationId: payment.relatedObligationId?.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeClass(cls) {
|
||||
return {
|
||||
_id: cls._id?.toString(),
|
||||
classTitle: cls.classTitle,
|
||||
students: cls.students?.map(student => student._id?.toString()) || [],
|
||||
};
|
||||
}
|
||||
|
||||
function serializeUser(user) {
|
||||
return {
|
||||
_id: user._id?.toString(),
|
||||
fullName: user.fullName,
|
||||
email: user.email,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AdminPaymentsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/auth/login");
|
||||
|
||||
const UserModel = await getUserModel();
|
||||
const currentUser = await UserModel.findOne({ _id: session.user.id });
|
||||
|
||||
// Only admins can access this page
|
||||
if (!currentUser.roles.includes("admin")) {
|
||||
return <NotAuthorized />;
|
||||
}
|
||||
|
||||
const Payment = await getPaymentModel();
|
||||
const Class = await getClassModel();
|
||||
|
||||
// Fetch all payments and obligations
|
||||
const payments = await Payment.find({})
|
||||
.populate("classId", "classTitle")
|
||||
.populate("userId", "fullName email")
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
// Fetch classes with their students for the obligation creation form
|
||||
const classes = await Class.find({})
|
||||
.populate("students", "fullName email _id")
|
||||
.lean();
|
||||
|
||||
// Get all students for the user selection dropdown
|
||||
const allUsers = await UserModel.find({
|
||||
$or: [
|
||||
{ roles: "student" },
|
||||
{ roles: "guardian" }
|
||||
]
|
||||
}).select("fullName email _id").lean();
|
||||
|
||||
// Serialize data for client components
|
||||
const serializedPayments = payments.map(serializePayment);
|
||||
const serializedClasses = classes.map(serializeClass);
|
||||
const serializedUsers = allUsers.map(serializeUser);
|
||||
|
||||
// Separate obligations and payments
|
||||
const obligations = serializedPayments.filter(p => p.type === "obligation");
|
||||
const userPayments = serializedPayments.filter(p => p.type === "payment");
|
||||
|
||||
// Calculate stats
|
||||
const totalObligations = obligations.length;
|
||||
const pendingObligations = obligations.filter(o => o.status === "pending").length;
|
||||
const pendingPayments = userPayments.filter(p => p.status === "pending_verification").length;
|
||||
const verifiedPayments = userPayments.filter(p => p.status === "verified").length;
|
||||
const rejectedPayments = userPayments.filter(p => p.status === "rejected").length;
|
||||
|
||||
const stats = {
|
||||
totalObligations,
|
||||
pendingObligations,
|
||||
pendingPayments,
|
||||
verifiedPayments,
|
||||
rejectedPayments,
|
||||
};
|
||||
|
||||
return (
|
||||
<MainSection>
|
||||
<PageHeader
|
||||
title="Pagamentos"
|
||||
subtitle="Gerenciamento de obrigações e pagamentos"
|
||||
actions={
|
||||
<a
|
||||
href="/admin/dashboard/payments/by-class"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 3v18h18"/>
|
||||
<path d="m19 9-5 5-4-4-3 3"/>
|
||||
</svg>
|
||||
Ver Pagamentos por Turma
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<PaymentStats stats={stats} />
|
||||
|
||||
<div className="mb-8">
|
||||
<CreateObligationForm
|
||||
classes={serializedClasses}
|
||||
users={serializedUsers}
|
||||
/>
|
||||
</div>
|
||||
</MainSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { updateOrderStatusAction } from "@/app/lib/orders/actions";
|
||||
import Label from "@/app/(protected)/components/shared/Label";
|
||||
import FlashMessage from "@/app/(protected)/components/shared/FlashMessage";
|
||||
import { getFileUrl } from "@/app/lib/utils/storage/fileUrl";
|
||||
import ProductImage from "@/app/(protected)/components/shared/ProductImage";
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
pending: { label: "Pendente", color: "gray" },
|
||||
pending_verification: { label: "Aguardando Verificação", color: "amber" },
|
||||
approved: { label: "Aprovado", color: "emerald" },
|
||||
rejected: { label: "Rejeitado", color: "red" },
|
||||
cancelled: { label: "Cancelado", color: "red" },
|
||||
};
|
||||
|
||||
const formatPrice = (price) =>
|
||||
new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(price);
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return "-";
|
||||
return new Intl.DateTimeFormat("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
export default function OrdersTable({ orders = [], stats = {} }) {
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [productFilter, setProductFilter] = useState("all");
|
||||
const [actionState, setActionState] = useState({ loading: false, message: null, type: null });
|
||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||
const [rejectionReason, setRejectionReason] = useState("");
|
||||
|
||||
const productIds = [...new Set(orders.map((o) => o.productId?._id).filter(Boolean))];
|
||||
const productNames = {};
|
||||
orders.forEach((o) => {
|
||||
if (o.productId?._id && o.productId?.title) {
|
||||
productNames[o.productId._id] = o.productId.title;
|
||||
}
|
||||
});
|
||||
|
||||
let filteredOrders = orders;
|
||||
if (statusFilter !== "all") {
|
||||
filteredOrders = filteredOrders.filter((o) => o.status === statusFilter);
|
||||
}
|
||||
if (productFilter !== "all") {
|
||||
filteredOrders = filteredOrders.filter((o) => o.productId?._id === productFilter);
|
||||
}
|
||||
|
||||
const handleAction = async (orderId, status) => {
|
||||
setActionState({ loading: true, message: null, type: null });
|
||||
const result = await updateOrderStatusAction(orderId, {
|
||||
status,
|
||||
rejectionReason: status === "rejected" ? rejectionReason : undefined,
|
||||
});
|
||||
setActionState({
|
||||
loading: false,
|
||||
message: result.message,
|
||||
type: result.success ? "success" : "error",
|
||||
});
|
||||
if (result.success) {
|
||||
setSelectedOrder(null);
|
||||
setRejectionReason("");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (actionState.message) {
|
||||
const timer = setTimeout(() => setActionState((s) => ({ ...s, message: null })), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [actionState.message]);
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
{actionState.message && (
|
||||
<FlashMessage message={actionState.message} type={actionState.type} />
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<button
|
||||
onClick={() => setStatusFilter(statusFilter === "all" ? null : "all")}
|
||||
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
|
||||
statusFilter === "all"
|
||||
? "bg-indigo-50 dark:bg-indigo-900/20 border-2 border-indigo-400"
|
||||
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{stats.total || 0}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Total</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter(statusFilter === "pending" ? "all" : "pending")}
|
||||
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
|
||||
statusFilter === "pending"
|
||||
? "bg-gray-50 dark:bg-gray-900/20 border-2 border-gray-400"
|
||||
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<p className="text-2xl font-bold text-gray-600 dark:text-gray-400">{stats.pendingNoProof || 0}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Sem Comprovante</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter(statusFilter === "pending_verification" ? "all" : "pending_verification")}
|
||||
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
|
||||
statusFilter === "pending_verification"
|
||||
? "bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400"
|
||||
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<p className="text-2xl font-bold text-amber-600 dark:text-amber-400">{stats.pending || 0}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Pendentes</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter(statusFilter === "approved" ? "all" : "approved")}
|
||||
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
|
||||
statusFilter === "approved"
|
||||
? "bg-emerald-50 dark:bg-emerald-900/20 border-2 border-emerald-400"
|
||||
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">{stats.approved || 0}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Aprovados</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter(statusFilter === "rejected" ? "all" : "rejected")}
|
||||
className={`rounded-xl p-4 text-center transition-colors cursor-pointer ${
|
||||
statusFilter === "rejected"
|
||||
? "bg-red-50 dark:bg-red-900/20 border-2 border-red-400"
|
||||
: "bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<p className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.rejected || 0}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Rejeitados</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-neutral-500 dark:text-neutral-400">Filtrar por produto:</label>
|
||||
<select
|
||||
value={productFilter}
|
||||
onChange={(e) => setProductFilter(e.target.value)}
|
||||
className="px-3 py-1.5 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-700 dark:text-neutral-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-800"
|
||||
>
|
||||
<option value="all">Todos os produtos</option>
|
||||
{productIds.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{productNames[id] || "Produto removido"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(statusFilter !== "all" || productFilter !== "all") && (
|
||||
<button
|
||||
onClick={() => { setStatusFilter("all"); setProductFilter("all"); }}
|
||||
className="text-xs text-indigo-600 dark:text-indigo-400 hover:underline cursor-pointer"
|
||||
>
|
||||
Limpar filtros
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-100 dark:bg-neutral-800/70 text-neutral-800 dark:text-neutral-200 font-semibold uppercase text-xs">
|
||||
<tr>
|
||||
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
|
||||
Produto
|
||||
</th>
|
||||
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-left">
|
||||
Aluno
|
||||
</th>
|
||||
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-right">
|
||||
Valor
|
||||
</th>
|
||||
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center">
|
||||
Data
|
||||
</th>
|
||||
<th className="px-4 py-3.5 border-b border-neutral-200 dark:border-neutral-800 text-center w-px">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{filteredOrders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" className="px-6 py-8 text-center text-neutral-500 dark:text-neutral-400">
|
||||
Nenhum pedido encontrado.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredOrders.map((order) => {
|
||||
const statusCfg = STATUS_CONFIG[order.status] || { label: order.status, color: "gray" };
|
||||
return (
|
||||
<tr
|
||||
key={order._id}
|
||||
className="hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-3.5 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProductImage
|
||||
src={getFileUrl(order.productId.imageUrl)}
|
||||
alt={order.productId?.title}
|
||||
className="w-10 h-10 rounded-lg object-cover border border-neutral-200 dark:border-neutral-700"
|
||||
size="sm"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{order.productId?.title || "Produto removido"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-neutral-700 dark:text-neutral-300 text-left">
|
||||
<p className="font-medium">{order.userId?.fullName || "-"}</p>
|
||||
<p className="text-xs text-neutral-500">{order.userId?.email || ""}</p>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-right font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{formatPrice(order.amount)}
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-center">
|
||||
<Label color={statusCfg.color} size="sm">
|
||||
{statusCfg.label}
|
||||
</Label>
|
||||
</td>
|
||||
<td className="px-6 py-3.5 text-center text-neutral-500 dark:text-neutral-400 text-xs">
|
||||
{formatDate(order.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-center whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => setSelectedOrder(order)}
|
||||
className="text-xs px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Ver
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedOrder && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Detalhes do Pedido
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => { setSelectedOrder(null); setRejectionReason(""); }}
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<ProductImage
|
||||
src={getFileUrl(selectedOrder.productId.imageUrl)}
|
||||
alt={selectedOrder.productId.title}
|
||||
className="w-16 h-16 rounded-lg object-cover border border-neutral-200 dark:border-neutral-700"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{selectedOrder.productId?.title || "Produto removido"}
|
||||
</p>
|
||||
<p className="text-lg font-bold text-indigo-600 dark:text-indigo-400">
|
||||
{formatPrice(selectedOrder.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">Aluno</p>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{selectedOrder.userId?.fullName}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">Status</p>
|
||||
<Label color={(STATUS_CONFIG[selectedOrder.status] || { color: "gray" }).color} size="sm">
|
||||
{(STATUS_CONFIG[selectedOrder.status] || { label: selectedOrder.status }).label}
|
||||
</Label>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">Método</p>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{selectedOrder.paymentMethod || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">Data do Pagamento</p>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{formatDate(selectedOrder.paymentDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">Data do Pedido</p>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{formatDate(selectedOrder.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
{selectedOrder.payerName && (
|
||||
<div>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">Quem pagou</p>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{selectedOrder.payerName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedOrder.receiptUrl && (
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-2">Comprovante</p>
|
||||
<img
|
||||
src={selectedOrder.receiptUrl}
|
||||
alt="Comprovante"
|
||||
className="max-w-full rounded-lg border border-neutral-200 dark:border-neutral-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOrder.rejectionReason && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">
|
||||
<strong>Motivo da rejeição:</strong> {selectedOrder.rejectionReason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOrder.status === "pending_verification" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
Motivo da rejeição (se aplicável)
|
||||
</label>
|
||||
<textarea
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-700 dark:text-neutral-200 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-neutral-800"
|
||||
rows="2"
|
||||
placeholder="Informe o motivo caso vá rejeitar..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<button
|
||||
onClick={() => handleAction(selectedOrder._id, "approved")}
|
||||
disabled={actionState.loading}
|
||||
className="flex-1 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-lg disabled:opacity-50 transition-colors cursor-pointer"
|
||||
>
|
||||
Aprovar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(selectedOrder._id, "rejected")}
|
||||
disabled={actionState.loading}
|
||||
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg disabled:opacity-50 transition-colors cursor-pointer"
|
||||
>
|
||||
Rejeitar
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
onClick={() => { setSelectedOrder(null); setRejectionReason(""); }}
|
||||
className="px-4 py-2 border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 font-medium rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors cursor-pointer"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user