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