Initial commit from Antigravity IDE

This commit is contained in:
Sidney 2026-07-16 23:27:40 -03:00
commit 3b80d3156e
205 changed files with 62931 additions and 0 deletions

View File

@ -0,0 +1,28 @@
name: Build and Deploy (Gitea)
on:
push:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build Manager
run: |
cd manager
docker build --no-cache -t edumanager:local .
- name: Build Portal
run: |
cd portal
docker build --no-cache -t portalaluno:local .
- name: Deploy to Portainer
run: |
docker service update --force --image edumanager:local edumanager-v2_edumanager
docker service update --force --image portalaluno:local edumanager-v2_portalaluno

58
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,58 @@
name: Build and Deploy
on:
push:
branches: [ main ]
workflow_dispatch: {}
jobs:
build-and-push:
runs-on: self-hosted
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Manager
uses: docker/build-push-action@v5
with:
context: ./manager
push: true
# Constrói nativamente apenas para ARM64 na Oracle
platforms: linux/arm64
tags: ghcr.io/${{ github.repository }}/edumanager:latest
- name: Build and push Portal
uses: docker/build-push-action@v5
with:
context: ./portal
push: true
# Constrói nativamente apenas para ARM64 na Oracle
platforms: linux/arm64
tags: ghcr.io/${{ github.repository }}/portalaluno:latest
- name: Atualizar Containers em Produção (Watchtower)
run: |
# Cria um arquivo de autenticacao do Docker manual (A prova de bloqueios de permissão)
AUTH=$(echo -n "${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" | base64 -w 0)
echo "{\"auths\": {\"ghcr.io\": {\"auth\": \"$AUTH\"}}}" > $(pwd)/ghcr-config.json
docker run --rm \
-e DOCKER_API_VERSION=1.44 \
-v /var/run/docker.sock:/var/run/docker.sock \
-v $(pwd)/ghcr-config.json:/config.json \
containrrr/watchtower \
--run-once --cleanup --debug

47
.github/workflows/deploy_backup.yml vendored Normal file
View File

@ -0,0 +1,47 @@
name: Build and Deploy
on:
workflow_dispatch: # Transformado em acionamento manual para não rodar duplicado
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
# Adiciona suporte a QEMU para conseguir buildar ARM64 em runners AMD64
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Manager
uses: docker/build-push-action@v5
with:
context: ./manager
push: true
# Constrói para as duas arquiteturas
platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ github.repository }}/edumanager:latest
- name: Build and push Portal
uses: docker/build-push-action@v5
with:
context: ./portal
push: true
# Constrói para as duas arquiteturas
platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ github.repository }}/portalaluno:latest

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
node_modules/
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
dist/
build/
*.local

86
GEMINI.md Normal file
View File

@ -0,0 +1,86 @@
# EduManager - Regras Globais e Escopo
## 🚀 Escopo do Projeto
**EduManager** é um Sistema de Gestão Escolar completo, focado em alta performance, usabilidade premium e automação de processos administrativos e acadêmicos.
## ðŸ ï¸<C3AF> Stack Tecnológica
- **Frontend/Backend:** Remix (React)
- **Banco de Dados:** PostgreSQL (100% Local/Self-Hosted)
- **Storage Architecture**: 100% Self-Hosted (MinIO) - Decoupled from Supabase Cloud.
- **Image Serving**: All images are served via a backend proxy route (`/storage/:bucket/:key`) to ensure cross-origin compatibility and security.
- **Synchronization**: High-performance local API for bank reconciliation (Asaas).
- **Orquestração e CI/CD:** Portainer (Docker) com GitHub Actions via Self-Hosted Runner (Oracle ARM64 nativo) e Watchtower.
- **Production Entry Point**: All production deployments MUST use `server.selfhosted.js` renamed/copied as `server.js` in the Docker containers to ensure full local feature availability.
## âš ï¸<C3AF> Regras de Negócio Críticas (MANDATÓRIO)
> [!IMPORTANT]
> **Migração de Dados (Legado 'schoodat'):**
> Ao realizar a migração completa dos dados do sistema legado 'schoodat' para o nosso banco de dados local Postgres, **é terminantemente proibido alterar, resetar ou re-hashear as senhas existentes.**
> As credenciais devem ser mantidas exatamente como estão para garantir que o acesso dos usuários não seja interrompido.
## 🚨 Regras de Fluxo de Trabalho (CRÃ<52>TICO)
> [!CAUTION]
> **Git Push Proibido Sem Demanda Explícita:**
> NUNCA execute `git add`, `git commit` ou `git push` sem que o USUÃ<55>RIO solicite explicitamente. Alterações devem ser feitas nos arquivos, mas o envio ao repositório remoto é uma ação exclusiva do usuário. Aguarde sempre o comando direto do usuário para realizar qualquer operação de versionamento.
> **ESTA REGRA É INVIOLÃ<4C>VEL E O ASSISTENTE JÃ<4A> FALHOU NELA EM 01/05/2026. NÃO REPITA O ERRO.**
## 📜 Padrões de Desenvolvimento
1. **Design System:** Estética Premium, Dark Mode por padrão (ou glassmorphism), micro-animações e ausência de placeholders.
2. **Segurança:** Todas as rotas sensíveis devem validar o token JWT local (via secrets do ambiente). Proibido usar Supabase SDK para lógica de autenticação ou sincronização no frontend.
3. **Resiliência:** Tratamento rigoroso de erros em chamadas de API de terceiros (Asaas, Evolution API).
4. **Upload de Arquivos:** Proibido o uso de Base64 para envio de novos arquivos ao servidor. Use obrigatoriamente `FormData` e envie o objeto `File/Blob` para as rotas de API que integram com o MinIO.
5. **Build & Deploy Stability:** O pipeline de deploy deve obrigatoriamente utilizar `runs-on: self-hosted` e compilar apenas a plataforma `linux/arm64` (sem emulação QEMU). A atualização da stack em produção deve ser automatizada via container transiente do Watchtower.
6. **Express Compatibility**: Avoid using raw `/*` wildcards in Express 5 routes; use Regex paths (`/^\/route\/(.+)$/`) for compatibility with `path-to-regexp` v8.
7. **Frontend Independence**: NEVER import files from `services/` or `server.js` directly into React components to prevent Node.js/SDK leakage (causes White Screen). Physical isolation is enforced: backend-only services (like MinIO/S3 storage) MUST stay outside the `src/` directory in Vite/React projects. Use `helpers.ts` for UI logic and standard `fetch` for API calls.
8. **Login Persistence**: Administrative sessions are persisted via `localStorage` ('edumanager_session'). The main entry point MUST validate the session on mount to ensure UX continuity.
9. **Real-time & Sync**: In self-hosted environments, use **Intelligent Polling (30s)** to synchronize notifications and critical data between Portal and Manager, as standard Supabase Realtime is disabled.
10. **Justification Logic**: Attendance justifications MUST include `fromStudentId` in notification metadata and support both `arquivo` and `arquivo_base64` keys for attachment compatibility. Notifications SHOULD include the motive text in the `message` field (JSON format: `{text, motivo}`) to allow previews in the manager bell.
11. **Modal Standards**: System modals should utilize `bg-transparent` (no background darkening or blur) with `shadow-2xl` for depth, ensuring a clean and float-like aesthetic.
12. **Messages Automation**: Preventive reminders and overdue settings MUST be managed within their respective template modals (contextual logic), with independent manual triggers for each phase (Overdue vs. Upcoming).
13. **Grading & Evaluation Standards**: Assessments are categorized as `exam` (violet/violet labels) or `activity` (sky/blue labels). The report card (Boletim) MUST support multiple evaluations per period, showing the individual breakdown (name and value). The module MUST be named **"Atividades e Provas"** for clarity.
14. **Asaas Safety**: All financial generation forms MUST implement a loading state (`isCreating`) to disable submit buttons and prevent duplicate charges.
15. **Retake Policy**: Students ARE allowed to retake activities and exams. The system MUST implement a "Lock" (Cadeado) logic: if locked, the retake button is hidden; if unlocked, the button is visible and the new submission overwrites the previous grade.
16. **Financial Categories**: The system supports categories: `monthly` (Mensalidade), `registration` (Matrícula), `handout` (Apostila), and `others` (Outros). Automated forms must map references (Cursos -> monthly, Registration -> registration, Handout -> handout).
17. **Modal Floating Principle**: All system modals must avoid backdrop-blur and background overlays. Use `bg-transparent` for the fixed container and `bg-white` (solid) for the modal box, ensuring contrast via large soft shadows (`shadow-2xl` or equivalent).
18. **Automated Messaging (Cron Jobs)**: The system uses `node-cron` for independent message scheduling (Preventive vs. Overdue). Overdue logic MUST implement safety checks using `overdue_warnings_count` and `last_overdue_warning_at` to avoid spamming the student. Immediate webhook triggers for `PAYMENT_OVERDUE` are disabled in favor of scheduled routines.
19. **Numerical Data Integrity**: When retrieving data from PostgreSQL `NUMERIC` or `DECIMAL` columns, values MUST be explicitly cast to `Number()` in the backend before being sent to the frontend to prevent crashes when using `.toFixed()` in React.
20. **Exam Duplication**: The system supports cloning evaluations. Duplicated exams MUST default to `draft` status and include `(Cópia)` in the title to allow verification before deployment to new classes.
21. **Automatic Attendance Closure**: The system implements an automatic closure routine (`processAutoAbsences`) that generates physical "Absence" records in the PostgreSQL database for any past lesson where a student has no presence or justification. This routine is triggered during data save operations to ensure retroactive consistency between lessons and records.
22. **Biometric Data Preservation**: In PostgreSQL sync operations (`syncJsonToRelationalTables`), the `face_descriptor` column MUST ALWAYS use `COALESCE(EXCLUDED.face_descriptor, alunos.face_descriptor)` during updates. This prevents facial biometrics and photos from being overwritten or nullified by legacy JSON sync operations.
22. **Unified Notification System (SQL)**: The system uses the `notificacoes` PostgreSQL table as the single source of truth for both Portal and Manager. JSON-based notifications in `school_data` are deprecated. New features (exams, justifications) MUST use SQL INSERT/SELECT and implement **Intelligent Polling (30s)** in the UI to ensure synchronization.
23. **Soft Delete Policy (Lixeira)**: Evaluations (exams/activities) MUST NOT be hard-deleted to preserve grade history. Use the `isDeleted` flag to hide them from students and active management views, keeping them accessible in the "Lixeira" for restoration and ensuring reference integrity in the Report Card.
24. **Grading Arithmetic Logic**: All grade averages (Period, Subject, and General) MUST be calculated as arithmetic means (Average of Means). Summing grades for a final period result is prohibited to ensure consistency between Portal and Manager.
25. **SQL Date Handling**: When fetching timestamps from PostgreSQL to be matched with JSON dates/ISO strings in the frontend, ALWAYS use `TO_CHAR(column, 'YYYY-MM-DD"T"HH24:MI:SS')` in the SQL query to prevent Node.js timezone shifts from corrupting string-based matching logic.
26. **SQL-First Architecture Pattern**: New features or module refactors SHOULD prioritize 100% SQL persistence (using `notas_boletim` as a template). The legacy JSON `school_data` should be treated as secondary or deprecated for these modules, ensuring real-time consistency between Manager and Portal.
27. **Safe Rendering Principle (Portal)**: All property accesses on dynamic objects (like `data` or `student`) MUST use optional chaining (`?.`). Avoid using undeclared variables copied from other modules; always perform a `tsc` check in the portal before committing to prevent "White Screen of Death" crashes.
28. **Frequency Parity Rule**: The Portal MUST mirror the Manager's attendance matching logic exactly, including time windows (30m before) and end-of-day fallbacks (23:59), to ensure data consistency between Admin and Student views.
29. **Hybrid Identity Lookup**: Automated messaging services MUST implement a hybrid lookup for students: first check the legacy `school_data` JSON, then fallback to the PostgreSQL `alunos` table. This ensures support for both migrated and newly created students.
30. **Financial Data Integrity**: Automation routines MUST prioritize `school_data.payments` (JSON) as the trigger source while using `alunos_cobrancas` (SQL) for tracking notification counters (spam control), ensuring 100% parity with the management UI.
31. **Real-Time Bidirectional Sync**: Asaas webhooks MUST update both the PostgreSQL `alunos_cobrancas` table and the legacy `school_data.json` file in real-time to ensure immediate UI feedback in the administrative panel.
32. **Numerical Type Enforcement**: When serving data from PostgreSQL `NUMERIC` columns, the backend MUST map the result to ensure amounts are sent as `Number` (not String) to prevent string concatenation bugs in the frontend.
33. **Reverse Sync Requirement (SQL -> JSON) e (JSON -> SQL)**: Para módulos em fase de transição híbrida (como Aulas e Contratos), o sistema implementa a sincronização de Mão Dupla no `syncJsonToRelationalTables`. O frontend Manager invoca a API SQL mas também atualiza o contexto. O backend detecta a gravação e roda INSERTs ON CONFLICT para popular o PostgreSQL.
34. **Deletion & Notification Order (Async)**: When processing deletions that trigger notifications (e.g., WhatsApp via Asaas Webhook), local database deletion MUST happen only AFTER the notification dispatch. Manual deletion routes should only trigger the external API delete and wait for the webhook to finalize local cleanup, ensuring data availability for message variables.
35. **Mass Send Standard (V3)**: The mass send feature MUST use the student's or guardian's first name for the {nome} variable (via `.split(' ')[0]`). It MUST support dual dispatch (sending to both student and guardian if phones are distinct) and allow attachments (Image/PDF) handled via `multipart/form-data` and the Evolution API `sendMedia` endpoint.
36. **Frequency Visibility & Justification Tracking**: All attendance records of type `absence` MUST be excluded from the "Presence Time" calculation in the Portal, showing a dash (`—`). Justifications MUST store the `submittedAt` timestamp in both SQL and JSON formats to allow auditing and clear display of submission time in the UI.
37. **Server Entry Point Safety**: The original `server.js` files in both Manager and Portal are OBSOLETE and kept only for historical context. You MUST NEVER modify or edit `server.js`. All backend changes must be applied exclusively to `server.selfhosted.js`.
38. **Database Connection & MCP Integration**: Direct PostgreSQL access has been successfully configured via the `@modelcontextprotocol/server-postgres` MCP server to connect directly to the production database on the VPS (`150.230.87.131`). Database telemetry and operations can be executed via MCP SQL tools.
39. **Portal SQL-First Migration**: O Portal do Aluno consome dados majoritariamente de tabelas PostgreSQL (`alunos`, `aulas`, `frequencias`, `contratos`) usando `SELECT` e implementa Fallbacks legados lendo do JSON apenas se o BD retornar vazio.
40. **CamelCase Backend Mapping**: Funções do backend que buscam dados do PostgreSQL (`getAlunos`, `getProvas`, `getFuncionarios`) DEVEM mapear as chaves de `snake_case` (ex: `turma_id`, `is_deleted`) para `camelCase` (ex: `classId`, `isDeleted`) antes de enviar para o Frontend. O Frontend deve usar fallbacks (`p.classId || p.turma_id`) durante a fase de transição para evitar quebras em views legadas (White Screen) e perdas de filtro de contagem (ex: "0 alunos").
41. **Database Schema Sync**: Sempre garanta que as tabelas de destino possuam colunas como `is_deleted` e demais modificadores antes de habilitar rotas PUT/DELETE, pois a ausência do schema falhará silenciosamente no Express 5.
42. **Cronological Display Standard**: Consultas a cadastros secundários (como Disciplinas e Categorias) DEVEM utilizar `ORDER BY created_at ASC` no SQL para manter a mesma ordem de listagem original do sistema (evitando reordenação alfabética não-intencional).
43. **Mass Contracts Update**: A edição de um "Modelo de Contrato" dispara atualizações em massa (PUT `/api/contratos/:id`) recalcularizando as variáveis de todos os contratos já emitidos para os alunos vinculados àquele modelo, garantindo atualização em tempo real no Portal do Aluno via SQL.
44. **Biometria SQL-First Parcial**: A biometria (faceDescriptor) já está preparada para persistência nativa na coluna jsonb 'face_descriptor' do PostgreSQL (com mapeamento camelCase na API). Contudo, o cadastro de alunos via Manager ainda não utiliza a API SQL em sua plenitude no handleSave. Até a refatoração do Students.tsx, os alunos novos continuarão dependendo do fallback do school_data.json.
45. **Forçar Fuso Horário Brasileiro na Origem**: Ao gerar datas ou horários locais no frontend para envio ao banco PostgreSQL (como registros de biometria ou ponto), é OBRIGATÓRIO forçar o cálculo no fuso 'America/Sao_Paulo' (ex: \
ew Date().toLocaleString('en-US', { timeZone: 'America/Sao_Paulo' })\). Isso evita que instâncias rodando em UTC no Windows Server ou celulares com fuso errado enviem horários defasados, o que causaria quebra em lógicas de restrição de janela de tempo (ex: horário de aula).
46. **Geração de Contratos Automáticos (Fase SQL-First)**: A geração de contratos no cadastro de alunos deve sincronizar diretamente no banco de dados com `POST /api/contratos` e manter fallbacks no `updateData`. Além disso, para evitar contratos duplicados acidentais, o checkbox `generateContract` deve ser desmarcado por padrão ao editar um aluno, mas marcado por padrão ao cadastrar um novo aluno. O checkbox de "Gerar Taxa" foi removido.
47. **Ficha de Frequência Geral (Roster de Turma) em PDF**: O botão de exportação de PDF na lista de alunos da turma no módulo Registro de Frequência do Manager deve exportar um relatório de frequência geral de todos os alunos ativos da turma, com a contagem total acumulada de presenças (P), faltas (F), justificadas (J) e porcentagem de frequência (%) de forma a bater perfeitamente com os dados exibidos na tela, evitando PDFs vazios ou em branco para datas sem aulas.
48. **Pré-Matrícula - Folder Promocional Dinâmico (Banner), Rascunho e Duplicatas**: O banner de promoção (flyer) deve ser gerenciado de forma 100% dinâmica como um campo personalizado na lista de campos do formulário (tipo `banner`). Isso permite que o administrador altere a ordem de exibição do flyer em relação aos demais campos e o exclua/adicione livremente. Se o formulário estiver em modo rascunho (`draft`), a página pública oculta as turmas e exibe uma mensagem elegante de vagas esgotadas. Duplicatas são bloqueadas no backend.
49. **Pré-Matrícula - Soft Delete de Campos e Preservação de Histórico**: É estritamente proibido realizar exclusões permanentes (hard-delete) de campos personalizados do formulário de pré-matrícula para evitar a exibição de códigos UUID orfãos na lista de inscrições e na exportação CSV de matrículas antigas. A exclusão de campos deve utilizar Soft Delete (`ativo = false`), e o frontend deve utilizar um mapeador inteligente de fallbacks para recuperar rótulos legados e campos inativos.
50. **Pré-Matrícula - Máscaras de Entrada e Single Event Binding**: Para evitar comportamentos instáveis em teclados móveis (como Gboard no Android ou Autoresumos no iOS), as máscaras de campos estruturados (telefone, CPF, CEP) devem ser gerenciadas por um único escopo de eventos global (`document.addEventListener`). É estritamente proibido realizar vinculações duplas (Double Binding) com ouvintes locais diretos concorrentes nos elementos dinâmicos, para evitar a perda de foco, cursor saltando, ou substituição indesejada de caracteres digitados. A máscara de telefone deve diferenciar e suportar dinamicamente tanto telefones fixos (10 dígitos) quanto celulares (11 dígitos).
51. **Encargos Financeiros e Simulação Asaas**: Se nenhuma taxa de juro ou multa estiver cadastrada no curso do aluno, o fallback padrão é obrigatoriamente 0% (sem encargos). Em simulações de atraso, o desconto perde a validade automaticamente, calculando multa/juros sobre o valor bruto original. Se o vencimento cair em finais de semana, a contagem de encargos é postergada em um dia útil (considerando segunda-feira em dia, aplicando juros apenas a partir de terça-feira), refletido em paridade absoluta entre Manager e Portal.
52. **Simulação de Encargos no Dashboard do Portal**: O dashboard principal do Portal do Aluno (`portal/src/pages/Dashboard.tsx`) deve calcular e refletir em tempo real os juros, multas e perda de desconto para parcelas em atraso, mantendo paridade absoluta com os valores exibidos na aba Financeiro e prevenindo divergências visuais.

101
MEMORY.md Normal file
View File

@ -0,0 +1,101 @@
# Log da Migração Massiva SQL-First (Sessão Completa)
## Visão Geral
Nesta sessão de trabalho, realizamos o "core" da transição do sistema EduManager, saindo do modelo puramente JSON (`school_data.json`) e consolidando 4 módulos centrais diretamente no Banco de Dados Relacional PostgreSQL.
## Módulos Migrados
### Fase 4: Gestão de Alunos e Autenticação
- **Backend Manager**: CRUD no `database.js` para tabela `alunos`, com rotas `/api/alunos` e `/api/alunos/:id/rematricular` no `server.selfhosted.js` que executam operações no PostgreSQL e realizam sincronização reversa em tempo real no legado `school_data.json` para total compatibilidade legacy.
- **Frontend Manager (`Students.tsx`)**: Completamente refatorado para ler e gravar diretamente nas rotas relacionais `/api/alunos`, eliminando 100% das chamadas `dbService.saveData` nesta tela, mantendo apenas o estado em memória sincronizado para continuidade UX.
- **Portal do Aluno**: Login (`/api/portal/login`) e perfil (`/api/portal/me`) reescritos para consultar a tabela `alunos` no PostgreSQL.
### Fase 5: Avaliações e Provas
- Backend e Frontend conectados às tabelas `provas` e `questoes_provas`.
### Fase 6: Cronograma e Aulas
- **Backend Manager**: Queries em lote (`insertAulas`, `getAulasByTurma`, `getAllAulas`, `deleteAulas`).
- **Frontend Manager (`LessonSchedule.tsx`)**: Consome `fetch('/api/aulas')` em estado próprio (`dbLessons`).
- **Frontend Manager (`Classes.tsx`)**: Ao criar/editar turma e gerar cronograma, agora envia aulas via `POST /api/aulas/lote` para o PostgreSQL.
- **Portal do Aluno**: Rota `/api/portal/aulas` faz `SELECT FROM aulas WHERE turma_id = ANY(...)`.
### Fase 7: Contratos e Modelos
- **Backend Manager**: CRUD para `contratos` e `modelos_contrato`.
- **Frontend Manager (`Contracts.tsx`)**: Consome `/api/contratos` e `/api/modelos-contrato`.
- **Portal do Aluno**: Rota `/api/portal/contratos` faz `SELECT FROM contratos WHERE aluno_id = $1`.
## Bugs Encontrados e Corrigidos
### 🐛 Bug 1: Tela Branca na Aba Alunos (`Students.tsx`)
- **Causa**: Referência circular no `useState``useState<any[]>(dbClasses || [])` referenciava a si mesma.
- **Fix**: Alterado para `useState<any[]>(data?.classes || [])`.
### 🐛 Bug 2: Dados Não Migrados para PostgreSQL
- **Causa**: A rotina `syncJsonToRelationalTables` só roda quando o Manager salva o JSON via `PUT /api/school-data`. Como a migração era nova, os dados nunca foram sincronizados.
- **Fix**: Criado script `migrate_aulas_contratos.cjs` que conecta diretamente ao PostgreSQL de produção e roda os INSERTs.
- **Resultado**: 109 aulas, 9 contratos, 1 modelo e 89 frequências migrados com sucesso.
### 🐛 Bug 3: Aulas Órfãs (FK Constraint)
- **Causa**: 57 aulas no JSON pertenciam a uma turma deletada (`d48b268c-...`), causando violação de FK.
- **Fix**: O script filtra aulas cujo `classId` não existe na tabela `turmas`.
### 🐛 Bug 4: Contratos Não Apareciam no Manager
- **Causa**: A função `getContratos()` retornava o campo como `date`, mas o frontend (`Contracts.tsx`) esperava `createdAt`.
- **Fix**: Corrigido o mapeamento em `database.js` para retornar `createdAt: r.created_at_fmt`.
### 🐛 Bug 5: Classes.tsx Não Salvava Aulas no PostgreSQL
- **Causa**: Ao criar/editar turma com cronograma, as aulas geradas eram salvas **apenas no JSON** (`data.lessons`), nunca no banco.
- **Fix**: Adicionado `fetch('/api/aulas/lote')` no `Classes.tsx` para enviar as aulas geradas ao PostgreSQL.
## Estado Final do Banco de Dados
| Tabela | Registros |
|---|---|
| alunos | 9 |
| aulas | 109 |
| contratos | 9 |
| frequencias | 89 |
| modelos_contrato | 1 |
| provas | 3 |
| turmas | 3 |
## Padrões de Arquitetura
1. **Reverse Sync (Mão Dupla)**: Frontend salva no SQL via API e mantém backup no JSON.
2. **Fallback Gradual**: Portal prioriza SQL, invoca JSON apenas se banco retornar vazio.
3. **Migração de Dados**: Executada via script Node.js conectando diretamente ao PostgreSQL de produção (host: `150.230.87.131`, user: `edumanager`).
### Fase 8: Consolidação de Frequência, Registro de Matrículas e Contratos Relacionais
- **Justificativa Única Rígida (Portal)**: Implementação de regra de validação rígida impedindo que o aluno envie mais de uma justificativa por aula. O Portal do Aluno agora exibe um aviso dinâmico informando que a aula já possui justificativa e desativa o envio. O backend valida a existência e retorna erro HTTP 400.
- **Sincronização Direta de Contratos (Manager)**: O fluxo de salvamento de matrículas foi aprimorado para disparar uma chamada de `POST /api/contratos` em tempo real ao salvar o aluno, escrevendo a emissão do contrato diretamente no PostgreSQL na VPS.
- **Refinamento do Modal de Alunos (Manager)**: O checkbox "Gerar Taxa" foi inteiramente removido do modal de matrícula. O checkbox "Gerar Contrato Automático" agora vem selecionado (`true`) por padrão em novos cadastros e desmarcado (`false`) ao editar matrículas para prevenir duplicações acidentais.
- **Relatório de Frequência Geral em PDF (Manager)**: Refatoração do exportador de PDF no módulo Registro de Frequência para gerar uma Ficha de Frequência Geral (Roster de todos os alunos ativos da turma, com contagem acumulada de presenças, faltas, justificativas e porcentagem de frequência de cada um) de modo a corresponder perfeitamente ao que o administrador visualiza na modal, eliminando downloads vazios.
### Fase 9: Endurecimento da Pré-Matrícula, Upload de Panfleto Promocional e Proteção contra Duplicatas
- **Folder Promocional Dinâmico e Reordenável (Banner)**: Refatorado o folder promocional (flyer) para ser tratado de forma 100% dinâmica como um campo personalizado especial (`tipo: 'banner'`) dentro do construtor de formulários. O administrador pode fazer o upload da imagem promocional diretamente no construtor (no modal de novo campo ou de edição), excluir e reordenar sua posição de exibição livremente em relação aos outros campos.
- **Renderização Pública Inline Premium**: A página pública de pré-matrícula renderiza a imagem do banner inline de forma premium, responsiva e exatamente na posição sequencial configurada na listagem de campos do banco.
- **Prevenção de Duplicatas e Spam**: Bloqueio de envios duplicados no backend comparando e-mail e telefone limpos de caracteres não-numéricos (para total equivalência).
- **Tratamento de Estado Rascunho (`draft`)**: Se a configuração estiver em rascunho, exibe automaticamente uma página informativa de "Vagas Lotadas - Novas vagas em breve" para proteger a integridade operacional antes do lançamento oficial.
- **Gestão de Campos Personalizados com Arquivos**: Implementação de tipo `file` para campos customizados no formulário do aluno, com upload assíncrono transparente via `FormData` e exibição em formato de hiperlink nos detalhes da inscrição para o administrador.
- **Preservação de Histórico de Campos Personalizados (Soft Delete)**: Correção de bug onde a exclusão de um campo (ex: Sexo) fazia com que o rótulo do campo sumisse e exibisse o código UUID bruto na listagem de inscritos e na exportação de CSV. Implementado Soft Delete (`ativo = false`) na exclusão e criado um mapeador inteligente com dicionário de fallbacks (`getFriendlyLabel`) que recupera os rótulos de campos excluídos ou legados em tempo real.
- **Correção de Máscaras de Entrada (Telefone/CPF/CEP)**: Correção de bug em teclados móveis (Android Gboard/iOS Safari) onde caracteres digitados desapareciam ou geravam loops repetitivos (ex: `(( () ()- 0-`). O problema era causado por double event binding (dois ouvintes `input` concorrentes ativos no mesmo elemento). Centralizada a máscara no ouvinte global de formulário e aprimorada a máscara de telefone para suportar nativamente tanto números fixos (10 dígitos: `(XX) XXXX-XXXX`) quanto móveis (11 dígitos: `(XX) XXXXX-XXXX`).
- **Exclusão Automática Pós-Conversão**: Implementada a exclusão automática da inscrição de pré-matrícula quando a mesma é concluída com sucesso e convertida em matrícula ativa de aluno no sistema. Captura o ID da pré-matrícula original de forma resiliente e dispara uma requisição `DELETE` contra `/api/prematricula/inscricoes/:id`, limpando instantaneamente a lista de pendências da aba de Pré-Matrícula sem requerer intervenção manual.
- **Favicon Dinâmico e Logos Arredondados**: Implementação de crop dinâmico em tempo de execução usando canvas HTML5 para gerar favicons perfeitamente circulares (`rounded-full`) a partir do logotipo cadastrado nas configurações da escola. O favicon circular dinâmico é atualizado automaticamente na aba do navegador tanto no painel administrativo do gestor quanto na página pública de pré-matrícula. Adicionalmente, todos os logotipos exibidos no painel do administrador (Sidebar) e na página pública foram estilizados com bordas elegantes, sombras sutis e arredondamento perfeito (`rounded-full w-8 h-8 object-cover`), elevando o alinhamento visual e a identidade estética premium da plataforma.
- **Favicon Dinâmico e Suporte PWA no Portal do Aluno**: Estendi a geração de favicons perfeitamente circulares (`rounded-full`) via canvas HTML5 para o Portal do Aluno, assegurando correspondência e marca unificada em toda a experiência do estudante. O logotipo da escola também foi estilizado como um círculo perfeito (`rounded-full object-cover`) na barra lateral (`Sidebar.tsx`) e na tela de Login do Portal (`Login.tsx`). Além disso, implementei suporte nativo e dinâmico a PWA (Progressive Web App) no Portal do Aluno: a API agora gera dinamicamente o arquivo `manifest.json` com o nome personalizado e logotipo da escola, registra o Service Worker (`sw.js`) em segundo plano para atender aos critérios de instalação de navegadores e renderiza um botão premium de glassmorphism verde pulsante e micro-animado ("Instalar App") na Sidebar no momento em que o aplicativo se torna instalável, permitindo aos alunos instalar o EduManager em seus celulares/computadores como um aplicativo nativo.
- **Correção da Persistência do Valor (Pontuação Máxima) das Avaliações**: Corrigido bug crítico no módulo de avaliações (Provas/Atividades) onde alterações na pontuação máxima ("Valor" ou `maxScore`) não eram salvas no banco de dados e resetavam de volta para 10.0 ao recarregar a página. A falha ocorria devido à ausência da coluna `max_score` nas operações de INSERT e UPDATE da camada de serviço (`database.js`), bem como a falta de mapeamento do campo no mapeador de retorno (`getProvas`) e no estado do React (`Exams.tsx` e `ReportCard.tsx`). Mapeei dinamicamente a propriedade `maxScore` em todos os fluxos da aplicação (Manager e Portal do Aluno), encapsulada pela conversão estrita `Number(p.max_score)` em conformidade com a Regra 19 de integridade numérica, garantindo que as pontuações e notas correspondentes no Boletim Escolar e Portal do Aluno reflitam precisamente o valor personalizado no banco de dados.
- **Nova Regra de Cálculo de Notas (Soma Capped 10 & Média por Período)**: Refatorei por completo a lógica de cálculo de notas do boletim escolar no Manager (`ReportCard.tsx`) e no Portal do Aluno (`Notas.tsx`). Agora, a nota de um aluno em um período para uma determinada disciplina é calculada pela **SOMA** das notas obtidas em todas as avaliações (provas e atividades) vinculadas àquele período, **limitada ao valor máximo de 10.0** (cap a 10). A Média Geral do aluno passou a ser calculada a nível de período: primeiro é calculada a média aritmética de todas as disciplinas daquele período específico e, caso existam múltiplos períodos com notas, a Média Geral do aluno é calculada pela média aritmética dos períodos lançados; caso exista apenas um único período ativo, a média geral é a média aritmética das disciplinas daquele único período.
### Fase 10: Limpeza da Arquitetura SQL-First do Portal do Aluno (Completa)
- **Depreciação Total do JSON (`school_data`) no Portal**: Removemos cirurgicamente todas as dependências e fallbacks híbridos que liam ou escreviam no antigo cache JSON da tabela `school_data` ou arquivo `school_data.json` em `portal/server.selfhosted.js`.
- **Autenticação, Configurações e Perfil**: Login (`/api/portal/login`), busca de dados da escola (`/api/portal/escola`), carregamento do perfil do aluno (`/api/portal/me`) e geração dinâmica do `manifest.json` do PWA agora são **100% orientados a tabelas relacionais do PostgreSQL** (`alunos` e `configuracoes`).
- **Financeiro, Notas e Frequência**: As rotas `/api/portal/financeiro` (finanças e cobranças), `/api/portal/notas` (boletim escolar), `/api/portal/frequencia` e `/api/portal/frequencia/justificar` foram reescritas para acessar de forma estrita as tabelas SQL `alunos_cobrancas`, `notas_boletim`, `disciplinas`, `periodos`, `provas` e `frequencias`, sem requerer sincronização nem backup JSON.
- **Contratos, Certificados e Aulas**: As rotas de contratos (`/api/portal/contratos`), certificados (`/api/portal/certificados`) e grade de aulas (`/api/portal/aulas`) agora operam de forma nativa e pura por meio de queries SQL no PostgreSQL.
- **Remoção de Código Morto**: Os métodos auxiliares de compatibilidade híbrida `getSchoolData()` e `saveSchoolData()` foram totalmente eliminados do servidor do portal.
### Fase 11: Padronização de Encargos Financeiros e Simulação Asaas
- **Regra de Postergação de Fins de Semana**: Implementada lógica que detecta se a data de vencimento da parcela recai em um final de semana (sábado ou domingo), estendendo a tolerância para segunda-feira e começando a calcular juros e multa apenas a partir de terça-feira.
- **Perda Automática de Desconto**: Se a parcela está atrasada (overdue), o desconto é considerado expirado. Os cálculos de multa (lateFee) e juros (interest) incidem sobre o valor bruto original (gross amount), e o desconto deixa de ser exibido/subtraído nas telas de simulação.
- **Remoção de Taxas Padrão Ocultas**: Caso o curso do aluno não possua as taxas de multa/juros configuradas no banco, o sistema agora assume 0% de encargos (antes assumia fixo 2% de multa e 1% de juros), respeitando estritamente o que foi cadastrado.
- **Sincronização de Visões**: Aplicado de forma idêntica no painel Manager (tanto na tabela geral quanto no histórico de cobranças por aluno) e no Portal do Aluno para correspondência visual absoluta.
### Fase 12: Simulação de Encargos no Dashboard do Portal do Aluno (SQL-First)
- **Correção do Dashboard do Portal**: Atualizado o painel principal do Portal do Aluno (`portal/src/pages/Dashboard.tsx`) para embutir as funções de simulação matemática de juros/multa de atraso e expiração de desconto. O dashboard agora reflete exatamente o mesmo valor com encargos calculado na página Financeiro, unificando os totais pendentes e os valores de parcelas vencidas do card "Próximo Vencimento".
- **Declaração de Tipos**: Atualizada a interface `Payment` em `portal/src/types.ts` para opcionalmente expor as propriedades `lateFee`, `interest` e `valor_pago`, garantindo a validação estrita do compilador TypeScript e prevenindo quebras de tela (White Screen).

View File

@ -0,0 +1,61 @@
# PLANO DE MIGRAÇÃO SQL-FIRST: DESLIGAMENTO DO `school_data.json`
Este documento detalha o passo a passo seguro para transferirmos cada bloco de dados do arquivo `school_data.json` para o PostgreSQL.
**Regra Inviolável:** Toda mudança será feita parte a parte. Primeiro criamos as rotas e migramos os dados. Depois trocamos a UI. Se algo falhar, o JSON continua lá como backup imediato.
---
## FASE 1: FUNCIONÁRIOS E PROFESSORES (Ponto de Partida)
**Objetivo:** Remover a dependência de `school_data.employees` e usar a tabela `funcionarios` e `categorias_funcionarios`.
- [ ] **Passo 1.1 (Backend):** Criar e expor as rotas REST (`GET`, `POST`, `PUT`, `DELETE` em `/api/funcionarios`) no arquivo `manager/server.selfhosted.js`.
- [ ] **Passo 1.2 (Dados):** Criar um script temporário para extrair todos os dados de `employees` do `school_data.json` e rodar um `INSERT` massivo na tabela `funcionarios` (mantendo os IDs intactos para não quebrar históricos).
- [ ] **Passo 1.3 (Frontend):** Atualizar o componente de Funcionários (`Employees.tsx`) para consumir o novo endpoint em vez da função antiga do contexto (`fetchFromCloud`/`saveToCloud`).
- [ ] **Passo 1.4 (Homologação):** Validar se listar, adicionar, editar e excluir funcionários estão funcionando e sendo salvos apenas no banco.
---
## FASE 2: CONFIGURAÇÕES, WHATSAPP E INTEGRAÇÕES
**Objetivo:** Eliminar `profile`, `evolutionConfig`, `messageTemplates` e `settings` do JSON. Usar as tabelas `configuracoes` e `usuarios` (Admin).
- [ ] **Passo 2.1 (Backend):** Criar rotas em `server.selfhosted.js` (`/api/configuracoes`) que leem as chaves (Key-Value) ou objetos diretamente do banco de dados.
- [ ] **Passo 2.2 (Dados):** Inserir os templates atuais de WhatsApp e configs gerais do JSON na tabela `configuracoes`.
- [ ] **Passo 2.3 (Backend - Automations):** Substituir em `server.selfhosted.js` todas as funções que dependem das instâncias da Evolution API para que consultem o SQL antes de enviar mensagens.
- [ ] **Passo 2.4 (Frontend):** Ajustar o componente de Configurações (`Settings.tsx`, `Messages.tsx`) para salvar no novo modelo relacional.
---
## FASE 3: CURSOS E TURMAS (Estrutura Pedagógica)
**Objetivo:** Livrar o sistema de `school_data.classes` e `school_data.courses`.
- [ ] **Passo 3.1 (Backend):** Criar endpoints isolados (`/api/cursos`, `/api/turmas`, `/api/disciplinas`) que retornem junções SQL prontas para alimentar listagens e dropdowns.
- [ ] **Passo 3.2 (Dados):** Migrar arrays de objetos de turmas e cursos para suas referidas tabelas.
- [ ] **Passo 3.3 (Frontend):** Modificar os componentes de Cursos e Turmas no Manager para consumir as APIs diretas. Garantir que o Dashboard e o Formulário de Alunos usem esses endpoints para compor os selects.
---
## FASE 4: ALUNOS (O Coração do Sistema)
**Objetivo:** Desvincular de vez o gigantesco array `school_data.students`. Usar a tabela `alunos`.
- [ ] **Passo 4.1 (Backend):** Construir as rotas pesadas do aluno (`/api/alunos`), suportando busca rápida (LIKE/ILIKE) e paginação.
- [ ] **Passo 4.2 (Dados):** Fazer script super sensível para iterar o array de `students`, parseando fotos (MinIO proxy) e gravando em PostgreSQL.
- [ ] **Passo 4.3 (Frontend - Manager):** Refatorar `Students.tsx` para carregar a listagem e os detalhes do aluno via SQL.
- [ ] **Passo 4.4 (Frontend - Portal):** Alterar o portal para que ao se logar, o aluno puxe os dados do seu próprio perfil de `SELECT * FROM alunos WHERE id = $1`.
---
## FASE 5: AVALIAÇÕES (Provas, Questões e Gabaritos)
**Objetivo:** Libertar as provas da chave `school_data.exams`.
- [ ] **Passo 5.1 (Backend):** Configurar endpoints para carregar a Prova + Questões em uma única requisição.
- [ ] **Passo 5.2 (Dados):** Migrar o `exams` e `activities` separando o escopo da avaliação e as perguntas vinculadas (`questoes_provas`).
- [ ] **Passo 5.3 (Frontend):** Modificar o módulo de criação de provas e a sala virtual de provas do aluno para ler os blocos diretamente do Postgres.
---
## FASE 6: FINALIZAÇÃO (Extinção do JSON)
**Objetivo:** Cortar as últimas raízes do `school_data.json` e declarar 100% migrado.
- [ ] **Passo 6.1 (Financeiro Final):** Desligar totalmente a função `syncJsonToRelationalTables`. O Portal dos Alunos passa a ler `alunos_cobrancas` diretamente (já fazemos isso no Manager). Extinguir os recebimentos antigos (`school_data.payments`).
- [ ] **Passo 6.2:** Remover a rota genérica `GET /api/school-data` e `PUT /api/school-data` do projeto inteiro.
- [ ] **Passo 6.3:** Realizar limpeza no arquivo físico do backend. O sistema agora é livre, ultra-rápido, relacional e profissionalmente 100% em SQL PostgreSQL.

57
TABELA_DE_MIGRACAO_SQL.md Normal file
View File

@ -0,0 +1,57 @@
# 📊 Tabela de Migração SQL-First — EduManager
> **Última atualização:** 28/05/2026 às 08:30 (BRT)
>
> Este documento rastreia o progresso da migração de cada módulo do sistema legado JSON (`school_data` no PostgreSQL) para tabelas relacionais estruturadas com arquitetura **SQL-First**.
---
## Legenda de Status
| Ícone | Significado |
|:---:|:---|
| 🟢 | **100% SQL-First** — Leitura e escrita diretamente no PostgreSQL. JSON atualizado apenas por reverse-sync no backend para compatibilidade. |
| 🟡 | **Híbrido / Em Transição** — Backend já usa SQL, mas o frontend ainda depende parcialmente do contexto JSON. |
| 🔴 | **Pendente** — Módulo ainda opera 100% via JSON blob. |
---
## Tabela Completa
| Módulo | Chave no JSON (`school_data`) | Tabela PostgreSQL | Leitura no Manager (Admin) | Escrita no Manager (Admin) | Leitura no Portal (Aluno) | Status Atual |
|:---|:---|:---|:---|:---|:---|:---|
| **Alunos** | `students` | `alunos` | SQL Estruturado (`GET /api/alunos` → estado `dbStudents`) | SQL Estruturado (`POST`/`PUT`/`DELETE` `/api/alunos` e `PATCH /api/alunos/:id/rematricular`) | SQL Estruturado (Query direta no Postgres) | 🟢 **100% SQL-First** — Zero chamadas `dbService.saveData` no frontend. Reverse-sync automático no backend. |
| **Financeiro (Cobranças)** | `payments` | `alunos_cobrancas` | SQL Estruturado (`/api/admin/cobrancas`) | SQL Estruturado (`/api/gerar_cobranca` / `/api/excluir_cobranca`) | SQL Estruturado (com Fallback no JSON) | 🟢 **100% SQL-First** — Reverse-sync para JSON apenas para compatibilidade legacy. |
| **Funcionários** | `employees`, `employeeCategories` | `funcionarios`, `categorias_funcionarios` | SQL Estruturado (`/api/funcionarios`) | SQL Estruturado (`POST`/`PUT`/`DELETE` `/api/funcionarios`) | Não aplicável | 🟢 **100% SQL-First** — Totalmente independente do JSON. |
| **Boletim (Notas)** | `grades`, `periods` | `notas_boletim`, `periodos` | SQL Estruturado (`/api/notas`) | SQL Estruturado (`/api/notas`) | SQL Estruturado (Arithmetic Mean direto do SQL) | 🟢 **100% SQL-First** |
| **Avaliações (Provas)** | `exams` | `provas`, `questoes_provas` | SQL Estruturado (`GET /api/provas` → estado `dbExams`) | SQL Estruturado (`POST`/`PUT`/`DELETE` `/api/provas` com sync de questões) | SQL Estruturado (Tabelas `provas` e `questoes_provas`) | 🟢 **100% SQL-First** — Zero chamadas `dbService.saveData` no frontend. Questões sincronizadas inline. Reverse-sync automático no backend. |
| **Frequências (Chamadas)** | `attendance` | `frequencias` | SQL Estruturado (`GET /api/frequencias` → estado `dbAttendance`) | SQL Estruturado (`POST`/`PUT`/`DELETE` `/api/frequencias`) | SQL Estruturado (Query direta no Postgres) | 🟢 **100% SQL-First** — Zero chamadas `dbService.saveData` no frontend. Reverse-sync automático no backend. |
| **Aulas e Diários** | `lessons` | `aulas` | SQL Estruturado (`GET /api/aulas` → estado `dbLessons`) | SQL Estruturado (`POST`/`DELETE` `/api/aulas/lote`) | SQL Estruturado (Query direta no Postgres) | 🟢 **100% SQL-First** — Zero chamadas `dbService.saveData` no frontend. Reverse-sync automático no backend. |
| **Contratos** | `contracts`, `contractTemplates` | `contratos`, `modelos_contrato` | SQL Estruturado (`GET /api/contratos` e `GET /api/modelos-contrato`) | SQL Estruturado (`POST`/`PUT`/`DELETE`) | SQL Estruturado (Lê de `contratos`) | 🟢 **100% SQL-First** — Zero chamadas `dbService.saveData` no frontend. Reverse-sync no backend. |
| **Pré-Matrícula** | _(novo módulo)_ | `prematricula_config`, `prematricula_campos`, `prematriculas` | SQL Estruturado (`GET /api/prematricula/*`) | SQL Estruturado (`POST`/`PUT`/`DELETE` `/api/prematricula/*`) | Página Pública (`/pre-matricula/:slug`) | 🟢 **100% SQL-First** — Módulo nativo SQL sem dependência de JSON. |
| **Configurações Globais** | `profile`, `evolutionConfig`, `messageTemplates` | `configuracoes` | SQL Estruturado (`GET /api/school-data` direto do SQL) | SQL Estruturado (via `PUT /api/school-data` e sync instantâneo) | SQL Estruturado (Query direta no Postgres) | 🟢 **100% SQL-First** — Zero chamadas legadas. Sincronização e leitura bidirecional 100% SQL. |
---
## Resumo Quantitativo
| Status | Qtd. Módulos | Módulos |
|:---|:---:|:---|
| 🟢 100% SQL-First | **10** | Alunos, Financeiro, Funcionários, Boletim, Avaliações, Frequências, Aulas, Contratos, Pré-Matrícula, Configurações Globais |
| 🟡 Híbrido | **0** | Nenhum |
| 🔴 Pendente | **0** | Nenhum |
---
## Próximos Passos (Ordem Sugerida de Prioridade)
1. **Parabéns!** — A migração de toda a infraestrutura do EduManager para a arquitetura SQL-First foi concluída com absoluto sucesso! 🚀🏆
---
## Regras de Arquitetura Aplicadas
- **Reverse Sync**: Todos os módulos 🟢 gravam no PostgreSQL primeiro e depois atualizam o JSON legado no backend para manter compatibilidade com módulos ainda em transição.
- **Frontend Independence**: Nenhum módulo 🟢 importa `dbService` no componente React. Todas as mutações passam por `fetch()` direto para rotas REST.
- **Biometric Safety**: A coluna `face_descriptor` usa `COALESCE(EXCLUDED.face_descriptor, alunos.face_descriptor)` para nunca sobrescrever dados biométricos.
- **Question Sync**: O módulo de Avaliações sincroniza questões inline via `syncQuestoesProva()` dentro das rotas POST/PUT, garantindo que duplicações incluam todas as questões.

4
act_runner_config.yaml Normal file
View File

@ -0,0 +1,4 @@
runner:
insecure: false
container:
network: "network_public"

4
config_gitea.sh Normal file
View File

@ -0,0 +1,4 @@
echo "" | sudo tee -a /var/lib/docker/volumes/fa03e1d7b997cab51d36d907dab232d316fcda69b9bc3a3e356c8d97bd4a6ace/_data/app.ini
echo "[actions]" | sudo tee -a /var/lib/docker/volumes/fa03e1d7b997cab51d36d907dab232d316fcda69b9bc3a3e356c8d97bd4a6ace/_data/app.ini
echo "ENABLED = true" | sudo tee -a /var/lib/docker/volumes/fa03e1d7b997cab51d36d907dab232d316fcda69b9bc3a3e356c8d97bd4a6ace/_data/app.ini
docker restart $(docker ps -q -f name=gitea_gitea-server)

3
create_config.py Normal file
View File

@ -0,0 +1,3 @@
import yaml
with open('/home/ubuntu/act_runner_config.yaml', 'w') as f:
f.write('runner:\n insecure: false\ncontainer:\n network: "network_public"\n')

BIN
diff_manager.txt Normal file

Binary file not shown.

BIN
diff_portal.txt Normal file

Binary file not shown.

125
docker-compose.yml Normal file
View File

@ -0,0 +1,125 @@
version: '3.8'
services:
# ============================
# BANCO DE DADOS PRINCIPAL
# ============================
postgres:
image: postgres:15-alpine
restart: always
environment:
POSTGRES_DB: edumanager
POSTGRES_USER: edumanager
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-EduManager2026!Seguro}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- edumanager-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U edumanager"]
interval: 10s
timeout: 5s
retries: 5
# ============================
# STORAGE S3-COMPATIBLE (MINIO)
# ============================
minio:
image: minio/minio:latest
restart: always
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-MiniO2026!Seguro}
volumes:
- miniodata:/data
networks:
- edumanager-internal
- network_public
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.edum-minio.rule=Host(`storageedu.microtecinformaticacurso.com.br`)"
- "traefik.http.routers.edum-minio.entrypoints=websecure"
- "traefik.http.routers.edum-minio.tls=true"
- "traefik.http.routers.edum-minio.tls.certresolver=leresolver"
- "traefik.http.services.edum-minio.loadbalancer.server.port=9000"
- "traefik.docker.network=network_public"
# ============================
# EDUMANAGER (PAINEL ADMIN)
# ============================
edumanager:
image: ghcr.io/sidney201900/edumanager-pro2026/edumanager:latest
restart: always
depends_on:
- postgres
- minio
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=postgresql://edumanager:${POSTGRES_PASSWORD:-EduManager2026!Seguro}@postgres:5432/edumanager
- JWT_SECRET=${JWT_SECRET:-EduManager-JWT-Secret-2026!}
- MINIO_ENDPOINT=minio
- MINIO_PORT=9000
- MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
- MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-MiniO2026!Seguro}
- MINIO_PUBLIC_URL=${MINIO_PUBLIC_URL:-https://storageedu.microtecinformaticacurso.com.br}
- ASAAS_API_KEY=${ASAAS_API_KEY}
- ASAAS_API_URL=${ASAAS_API_URL}
- ASAAS_WEBHOOK_TOKEN=${ASAAS_WEBHOOK_TOKEN}
networks:
- edumanager-internal
- network_public
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.edum-manager.rule=Host(`edumanager.microtecinformaticacurso.com.br`)"
- "traefik.http.routers.edum-manager.entrypoints=websecure"
- "traefik.http.routers.edum-manager.tls=true"
- "traefik.http.routers.edum-manager.tls.certresolver=leresolver"
- "traefik.http.services.edum-manager.loadbalancer.server.port=3000"
- "traefik.docker.network=network_public"
# ============================
# PORTAL DO ALUNO
# ============================
portalaluno:
image: ghcr.io/sidney201900/edumanager-pro2026/portalaluno:latest
restart: always
depends_on:
- postgres
environment:
- NODE_ENV=production
- PORT=3001
- DATABASE_URL=postgresql://edumanager:${POSTGRES_PASSWORD:-EduManager2026!Seguro}@postgres:5432/edumanager
- JWT_SECRET=${JWT_SECRET:-EduManager-JWT-Secret-2026!}
- MINIO_ENDPOINT=minio
- MINIO_PORT=9000
- MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
- MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-MiniO2026!Seguro}
- MINIO_PUBLIC_URL=${MINIO_PUBLIC_URL:-https://storageedu.microtecinformaticacurso.com.br}
networks:
- edumanager-internal
- network_public
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.edum-portal.rule=Host(`aluno.microtecinformaticacurso.com.br`)"
- "traefik.http.routers.edum-portal.entrypoints=websecure"
- "traefik.http.routers.edum-portal.tls=true"
- "traefik.http.routers.edum-portal.tls.certresolver=leresolver"
- "traefik.http.services.edum-portal.loadbalancer.server.port=3001"
- "traefik.docker.network=network_public"
volumes:
pgdata:
miniodata:
networks:
edumanager-internal:
driver: overlay
network_public:
external: true

8
manager/.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
dist
.git
.gitignore
*.md
.env
.env.*
!.env.example

4
manager/.env.example Normal file
View File

@ -0,0 +1,4 @@
VITE_SUPABASE_URL=your_supabase_project_url
VITE_SUPABASE_KEY=your_supabase_anon_key
ASAAS_API_KEY=your_asaas_api_key
ASAAS_WEBHOOK_TOKEN=your_asaas_webhook_token

24
manager/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

125
manager/DialogContext.tsx Normal file
View File

@ -0,0 +1,125 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { AlertTriangle, CheckCircle, Info, XCircle } from 'lucide-react';
type DialogType = 'alert' | 'confirm' | 'success' | 'error' | 'warning' | 'info';
interface DialogOptions {
title: string;
message: string;
type?: DialogType;
confirmLabel?: string;
cancelLabel?: string;
onConfirm?: () => void;
onCancel?: () => void;
}
interface DialogContextType {
showAlert: (title: string, message: string, type?: DialogType) => void;
showConfirm: (title: string, message: string, onConfirm: () => void, type?: DialogType) => void;
}
const DialogContext = createContext<DialogContextType | undefined>(undefined);
export const useDialog = () => {
const context = useContext(DialogContext);
if (!context) {
throw new Error('useDialog must be used within a DialogProvider');
}
return context;
};
export const DialogProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<DialogOptions | null>(null);
const [isClosing, setIsClosing] = useState(false);
const closeDialog = () => {
setIsClosing(true);
setTimeout(() => {
setIsOpen(false);
setOptions(null);
setIsClosing(false);
}, 400);
};
const showAlert = (title: string, message: string, type: DialogType = 'info') => {
setOptions({ title, message, type, confirmLabel: 'OK' });
setIsOpen(true);
};
const showConfirm = (title: string, message: string, onConfirm: () => void, type: DialogType = 'warning') => {
setOptions({
title,
message,
type,
confirmLabel: 'Confirmar',
cancelLabel: 'Cancelar',
onConfirm: () => {
onConfirm();
closeDialog();
},
onCancel: closeDialog
});
setIsOpen(true);
};
const getIcon = (type?: DialogType) => {
switch (type) {
case 'success': return <CheckCircle className="text-emerald-500" size={24} />;
case 'error': return <XCircle className="text-red-500" size={24} />;
case 'warning': return <AlertTriangle className="text-amber-500" size={24} />;
case 'alert': return <AlertTriangle className="text-red-500" size={24} />;
default: return <Info className="text-indigo-500" size={24} />;
}
};
const getIconBg = (type?: DialogType) => {
switch (type) {
case 'success': return 'bg-emerald-100';
case 'error': return 'bg-red-100';
case 'warning': return 'bg-amber-100';
case 'alert': return 'bg-red-100';
default: return 'bg-indigo-100';
}
};
return (
<DialogContext.Provider value={{ showAlert, showConfirm }}>
{children}
{isOpen && options && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-[100] transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-2xl w-full max-w-sm overflow-hidden shadow-2xl transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="h-2 bg-indigo-600 w-full"></div>
<div className="p-6 text-center">
<div className={`w-12 h-12 ${getIconBg(options.type)} rounded-full flex items-center justify-center mx-auto mb-4`}>
{getIcon(options.type)}
</div>
<h3 className="text-lg font-black text-slate-800 mb-2">{options.title}</h3>
<p className="text-sm text-slate-500 mb-6 whitespace-pre-wrap">{options.message}</p>
<div className="flex gap-3">
{options.cancelLabel && (
<button
onClick={options.onCancel}
className="flex-1 py-3 border border-slate-200 rounded-xl font-bold text-slate-600 hover:bg-slate-50 transition-colors"
>
{options.cancelLabel}
</button>
)}
<button
onClick={options.onConfirm || closeDialog}
className={`flex-1 py-3 text-white rounded-xl font-bold transition-all shadow-lg ${
options.type === 'error' || options.type === 'alert' || options.type === 'warning'
? 'bg-red-600 hover:bg-red-700 shadow-red-100'
: 'bg-indigo-600 hover:bg-indigo-700 shadow-indigo-100'
}`}
>
{options.confirmLabel}
</button>
</div>
</div>
</div>
</div>
)}
</DialogContext.Provider>
);
};

38
manager/Dockerfile Normal file
View File

@ -0,0 +1,38 @@
# ---- Build Stage ----
FROM node:22-alpine AS builder
WORKDIR /app
# Copiar package files e instalar dependências
COPY package.json ./
RUN npm install
# Copiar todo o código fonte
COPY . .
# Build da aplicação (gera a pasta /app/dist)
# Aumentamos a RAM para evitar crashs do Vite via QEMU ARM64
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN npm run build
# ---- Production Stage ----
FROM node:22-alpine AS production
WORKDIR /app
# Copiar package files e instalar apenas dependências de produção
COPY package.json ./
RUN npm install --omit=dev
# Copiar o servidor Express e pastas necessárias
COPY server.selfhosted.js ./server.js
COPY services ./services
# Copiar a pasta dist gerada no build
COPY --from=builder /app/dist ./dist
# Expor a porta do servidor
EXPOSE 3000
# Comando para iniciar o servidor em modo produção
CMD ["node", "server.js"]

43
manager/README.md Normal file
View File

@ -0,0 +1,43 @@
# EduManager - Sistema de Gestão Escolar
Este é um sistema de gestão escolar desenvolvido com React, TypeScript e Vite.
## Como fazer o deploy no Netlify
1. **Baixe o código**: Faça o download de todos os arquivos deste projeto.
2. **Crie um repositório Git**: Inicie um repositório Git local e faça o commit dos arquivos.
```bash
git init
git add .
git commit -m "Initial commit"
```
3. **Envie para o GitHub/GitLab/Bitbucket**: Crie um repositório remoto e envie seu código.
4. **Conecte ao Netlify**:
* Acesse [netlify.com](https://www.netlify.com/).
* Clique em "Add new site" -> "Import an existing project".
* Selecione seu provedor Git e o repositório.
5. **Configurações de Build**:
* O Netlify deve detectar automaticamente as configurações do arquivo `netlify.toml`.
* **Build command**: `npm run build`
* **Publish directory**: `dist`
6. **Variáveis de Ambiente**:
* No painel do Netlify, vá em **Site settings > Environment variables**.
* Adicione as variáveis do Supabase (se estiver usando):
* `VITE_SUPABASE_URL`: Sua URL do projeto Supabase.
* `VITE_SUPABASE_KEY`: Sua chave pública (anon key) do Supabase.
7. **Deploy**: Clique em "Deploy site".
## Funcionalidades
* Cadastro de Alunos e Turmas
* Gestão Financeira
* Geração de Contratos em PDF
* Dashboard com Gráficos
* Backup Local e na Nuvem (Supabase)
## Desenvolvimento Local
Para rodar o projeto localmente:
1. Instale as dependências: `npm install`
2. Rode o servidor de desenvolvimento: `npm run dev`

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

16
manager/check_columns.js Normal file
View File

@ -0,0 +1,16 @@
import pg from 'pg';
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://edumanager:EduManager2026!Seguro@postgres:5432/edumanager';
const pool = new pg.Pool({ connectionString: DATABASE_URL });
async function check() {
try {
const { rows } = await pool.query("SELECT column_name FROM information_schema.columns WHERE table_name = 'alunos_cobrancas'");
console.log(rows.map(r => r.column_name).join(', '));
} catch (e) {
console.error(e);
} finally {
await pool.end();
process.exit();
}
}
check();

25
manager/check_justif.cjs Normal file
View File

@ -0,0 +1,25 @@
const { Pool } = require('pg');
const pool = new Pool({
user: 'postgres',
host: 'localhost',
database: 'edumanager',
password: 'admin',
port: 5432,
});
async function run() {
try {
const res = await pool.query("SELECT id, aluno_id, data, tipo, justificativa, justificativa_aceita FROM frequencias WHERE justificativa IS NOT NULL LIMIT 5;");
console.log(JSON.stringify(res.rows, null, 2));
// Check all attendance for a student with justification
const res2 = await pool.query("SELECT * FROM frequencias WHERE aluno_id = (SELECT aluno_id FROM frequencias WHERE justificativa IS NOT NULL LIMIT 1) ORDER BY data DESC LIMIT 5");
console.log("Student Recent:", JSON.stringify(res2.rows, null, 2));
} catch(e) {
console.error(e);
} finally {
pool.end();
}
}
run();

View File

@ -0,0 +1,109 @@
import React, { useState } from 'react';
import { SchoolData } from '../types';
import { geminiService } from '../services/geminiService';
import { Send, Sparkles, User, Bot, Loader2 } from 'lucide-react';
interface AIHelperProps {
data: SchoolData;
}
const AIHelper: React.FC<AIHelperProps> = ({ data }) => {
const [prompt, setPrompt] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [messages, setMessages] = useState<{ role: 'user' | 'bot'; content: string }[]>([
{ role: 'bot', content: 'Olá! Sou seu assistente de IA. Posso ajudar a gerar contratos, analisar a saúde financeira da escola ou criar relatórios. O que você precisa hoje?' }
]);
const handleAsk = async (e: React.FormEvent) => {
e.preventDefault();
if (!prompt.trim() || isLoading) return;
const userMsg = prompt;
setMessages(prev => [...prev, { role: 'user', content: userMsg }]);
setPrompt('');
setIsLoading(true);
const response = await geminiService.getAIAnalysis(userMsg, data);
setMessages(prev => [...prev, { role: 'bot', content: response }]);
setIsLoading(false);
};
const quickActions = [
"Resumo da situação financeira",
"Template de contrato de matrícula",
"Sugestão de cursos em alta",
"Alunos com mensalidades atrasadas"
];
return (
<div className="flex flex-col h-[calc(100vh-120px)] space-y-6 animate-in fade-in duration-300">
<header>
<h2 className="text-3xl font-extrabold text-slate-800 flex items-center gap-3 tracking-tight">
<Sparkles className="text-indigo-600" /> Assistente IA
</h2>
<p className="text-slate-500 font-medium">Insights inteligentes para otimizar sua gestão.</p>
</header>
<div className="flex-1 bg-white rounded-[2.5rem] border border-slate-200 shadow-2xl flex flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto p-8 space-y-6 bg-slate-50/20">
{messages.map((msg, i) => (
<div key={i} className={`flex gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''} animate-in slide-in-from-bottom-2 duration-300`}>
<div className={`p-5 rounded-[2rem] max-w-[85%] shadow-sm ${
msg.role === 'user'
? 'bg-indigo-600 text-white rounded-tr-none'
: 'bg-white text-slate-800 rounded-tl-none border border-slate-100'
}`}>
<div className={`flex items-center gap-2 mb-2 text-[10px] font-black uppercase tracking-[0.2em] ${msg.role === 'user' ? 'text-indigo-200' : 'text-slate-400'}`}>
{msg.role === 'user' ? <><User size={12}/> Você</> : <><Bot size={12}/> EduManager AI</>}
</div>
<div className="text-sm whitespace-pre-wrap leading-relaxed font-medium">
{msg.content}
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex gap-4 animate-pulse">
<div className="bg-white border border-slate-100 p-5 rounded-[2rem] rounded-tl-none text-slate-400 shadow-sm flex items-center gap-3">
<Loader2 className="animate-spin text-indigo-500" size={20} />
<span className="text-xs font-bold uppercase tracking-wider">Analisando dados...</span>
</div>
</div>
)}
</div>
<div className="p-8 bg-white border-t border-slate-100">
<div className="flex flex-wrap gap-2 mb-5">
{quickActions.map((action, i) => (
<button
key={i}
onClick={() => setPrompt(action)}
className="text-[11px] font-bold bg-slate-50 border border-slate-200 px-4 py-2 rounded-2xl text-slate-600 hover:border-indigo-400 hover:text-indigo-600 transition-all hover:shadow-md hover:-translate-y-0.5"
>
{action}
</button>
))}
</div>
<form onSubmit={handleAsk} className="relative group">
<input
type="text"
placeholder="Digite sua dúvida ou solicitação aqui..."
className="w-full pl-6 pr-16 py-5 bg-white text-black border-2 border-slate-200 rounded-[2rem] focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 shadow-lg transition-all text-sm font-medium"
value={prompt}
onChange={e => setPrompt(e.target.value)}
/>
<button
disabled={isLoading}
className="absolute right-3 top-1/2 -translate-y-1/2 p-4 bg-indigo-600 text-white rounded-[1.5rem] hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-lg active:scale-95 group-hover:scale-105"
>
<Send size={20} />
</button>
</form>
</div>
</div>
</div>
);
};
export default AIHelper;

View File

@ -0,0 +1,382 @@
import React, { useState, useEffect, useRef } from 'react';
import { Bell, X, CheckCircle, Trash2, ShieldCheck, FileText, Paperclip, DollarSign, AlertTriangle, Info, TrendingUp, CreditCard, ClipboardList } from 'lucide-react';
import { SchoolData, Notification, View } from '../types';
import { dbService } from '../services/dbService';
interface Props {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
setView: (view: View) => void;
onNavigateToStudent?: (studentId: string) => void;
}
const AdminNotifications: React.FC<Props> = ({ data, updateData, setView, onNavigateToStudent }) => {
const [isOpen, setIsOpen] = useState(false);
const [viewingAttachment, setViewingAttachment] = useState<string | null>(null);
const [notifWithAttachment, setNotifWithAttachment] = useState<Notification | null>(null);
const [adminNotifs, setAdminNotifs] = useState<Notification[]>([]);
const prevCountRef = useRef<number>(0);
const audioRef = useRef<HTMLAudioElement | null>(null);
const handleDeleteAttachment = async () => {
if (!notifWithAttachment) return;
try {
const resp = await fetch(`/api/notificacoes/remover-anexo/${notifWithAttachment.id}`, { method: 'PUT' });
if (resp.ok) {
setAdminNotifs(prev => prev.map(n => n.id === notifWithAttachment.id ? { ...n, attachment: undefined } : n));
setViewingAttachment(null);
setNotifWithAttachment(null);
}
} catch (e) {
console.error('Erro ao excluir anexo:', e);
}
};
const fetchNotifications = async () => {
try {
const resp = await fetch('/api/notificacoes/admin');
if (resp.ok) {
const d = await resp.json();
setAdminNotifs(d.notifications);
}
} catch (e) {
console.error('Erro ao buscar notificações admin:', e);
}
};
useEffect(() => {
fetchNotifications();
const interval = setInterval(fetchNotifications, 30000); // Polling 30s
return () => clearInterval(interval);
}, []);
const unreadCount = adminNotifs.filter(n => !n.read).length;
// Som de notificação quando chega uma nova
useEffect(() => {
if (unreadCount > prevCountRef.current && prevCountRef.current >= 0) {
try {
if (!audioRef.current) {
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
oscillator.frequency.setValueAtTime(1100, ctx.currentTime + 0.1);
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.2);
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.4);
}
} catch(e) {
console.warn('Som de notificação indisponível', e);
}
}
prevCountRef.current = unreadCount;
}, [unreadCount]);
const handleAction = (notif: Notification) => {
if (!notif.read) handleMarkAsRead(notif.id);
const isFinance = notif.title.toLowerCase().includes('pagamento') || notif.title.toLowerCase().includes('cobrança');
const isExam = notif.title.toLowerCase().includes('prova') || notif.title.toLowerCase().includes('atividade');
if (isFinance) {
setView(View.Finance);
setIsOpen(false);
return;
}
if (isExam) {
setView(View.ReportCard);
setIsOpen(false);
return;
}
if (notif.title.toLowerCase().includes('justificativa') || notif.message.toLowerCase().includes('justificativa')) {
const targetId = (notif as any).fromStudentId || notif.studentId;
if (onNavigateToStudent && targetId !== 'admin') {
onNavigateToStudent(targetId);
} else {
setView(View.AttendanceQuery);
}
setIsOpen(false);
}
};
const handleMarkAsRead = async (id: string) => {
try {
const resp = await fetch(`/api/notificacoes/ler/${id}`, { method: 'PUT' });
if (resp.ok) {
setAdminNotifs(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
}
} catch (e) {
console.error('Erro ao marcar como lida:', e);
}
};
const handleClearRead = async () => {
try {
const resp = await fetch('/api/notificacoes/limpar-lidas', { method: 'DELETE' });
if (resp.ok) {
setAdminNotifs(prev => prev.filter(n => !n.read));
}
} catch (e) {
console.error('Erro ao limpar lidas:', e);
}
};
// Aceitar justificativa diretamente pela notificação
const handleAcceptJustification = (notif: Notification) => {
// Procura registros de falta pendentes de aceitação
const pendingAbsences = (data.attendance || []).filter(a =>
a.type === 'absence' && a.justification && !a.justificationAccepted
);
if (pendingAbsences.length > 0) {
// Tenta achar pelo studentId mencionado ou associado à notificação
const targetId = (notif as any).fromStudentId;
const matchedAbsence = targetId
? pendingAbsences.find(a => a.studentId === targetId) || pendingAbsences[0]
: pendingAbsences[0];
const updatedAttendance = (data.attendance || []).map(a =>
a.id === matchedAbsence.id ? { ...a, justificationAccepted: true } : a
);
const modifiedRecord = updatedAttendance.find(a => a.id === matchedAbsence.id);
fetch(`/api/frequencias/${matchedAbsence.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
updateData({ attendance: updatedAttendance });
dbService.saveData({ ...data, attendance: updatedAttendance });
handleMarkAsRead(notif.id);
} else {
// Se não encontrou pendentes, apenas marca como lida
handleMarkAsRead(notif.id);
}
};
return (
<div className="fixed top-4 right-16 md:top-6 md:right-8 z-50">
<button
onClick={() => setIsOpen(!isOpen)}
className={`relative p-2.5 rounded-full shadow-lg border transition-all ${
unreadCount > 0
? 'bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100 hover:shadow-xl shadow-amber-100'
: 'bg-white text-slate-600 border-slate-100 hover:text-indigo-600 hover:shadow-xl'
}`}
title="Notificações do Sistema"
>
<Bell size={22} className={unreadCount > 0 ? "animate-bounce" : ""} />
{unreadCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] font-black w-5 h-5 flex items-center justify-center rounded-full border-2 border-white shadow-sm animate-pulse">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute top-14 right-0 w-80 sm:w-96 bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden animate-in slide-in-from-top-4 fade-in duration-200 flex flex-col max-h-[80vh]">
<div className="p-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between sticky top-0 z-10">
<div>
<h3 className="font-black text-slate-800 flex items-center gap-2">Central de Alertas
{unreadCount > 0 && <span className="bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full text-[10px] font-bold">{unreadCount}</span>}
</h3>
</div>
<div className="flex items-center gap-2">
<button onClick={handleClearRead} className="p-1.5 text-slate-400 hover:bg-slate-200 hover:text-red-500 rounded-lg transition-colors" title="Limpar Lidas">
<Trash2 size={16} />
</button>
<button onClick={() => setIsOpen(false)} className="p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-700 rounded-lg transition-colors">
<X size={16} />
</button>
</div>
</div>
<div className="overflow-y-auto p-2 flex-1 relative">
{adminNotifs.length === 0 ? (
<div className="py-12 text-center text-slate-400">
<Bell size={32} className="mx-auto mb-2 opacity-20" />
<p className="text-sm font-bold">Nenhuma notificação</p>
<p className="text-xs mt-1">Sua caixa de entrada está limpa.</p>
</div>
) : (
<div className="space-y-2">
{adminNotifs.map(notif => {
const isJustificativa = notif.title.toLowerCase().includes('justificativa') || notif.message.toLowerCase().includes('justificativa');
let displayMessage = notif.message;
let justificationMotive = '';
let attachmentFromMessage = null;
let metadata: any = {};
if (notif.message.startsWith('{')) {
try {
const parsed = JSON.parse(notif.message);
displayMessage = parsed.text || parsed.motivo || displayMessage;
justificationMotive = parsed.motivo || '';
attachmentFromMessage = parsed.arquivo || parsed.arquivo_base64 || null;
} catch(e) {}
}
if (notif.anexo && notif.anexo.startsWith('{')) {
try { metadata = JSON.parse(notif.anexo); } catch(e) {}
}
const finalAttachment = notif.attachment || attachmentFromMessage;
const isFinance = metadata.type === 'finance' || notif.title.toLowerCase().includes('pagamento') || notif.title.toLowerCase().includes('cobrança');
const isExam = metadata.type === 'exam' || notif.title.toLowerCase().includes('prova') || notif.title.toLowerCase().includes('atividade');
// Configuração dinâmica de cores e ícones
let icon = <Info size={16} />;
let colorClass = 'text-indigo-500';
let bgClass = 'bg-indigo-50';
let borderClass = 'border-indigo-100';
if (isFinance) {
if (metadata.status === 'paid' || notif.title.includes('Confirmado')) {
icon = <DollarSign size={16} />;
colorClass = 'text-emerald-500';
bgClass = 'bg-emerald-50';
borderClass = 'border-emerald-100';
} else if (metadata.status === 'overdue' || notif.title.includes('Atraso')) {
icon = <AlertTriangle size={16} />;
colorClass = 'text-red-500';
bgClass = 'bg-red-50';
borderClass = 'border-red-100';
} else {
icon = <TrendingUp size={16} />;
colorClass = 'text-blue-500';
bgClass = 'bg-blue-50';
borderClass = 'border-blue-100';
}
} else if (isExam) {
icon = <ClipboardList size={16} />;
colorClass = 'text-violet-600';
bgClass = 'bg-violet-50';
borderClass = 'border-violet-100';
} else if (isJustificativa) {
icon = <FileText size={16} />;
colorClass = 'text-amber-500';
bgClass = 'bg-amber-50';
borderClass = 'border-amber-100';
}
return (
<div key={notif.id} onClick={() => handleAction(notif)} className={`p-3 rounded-xl border transition-all cursor-pointer relative overflow-hidden group ${notif.read ? 'bg-slate-50 border-transparent opacity-70' : `bg-white ${borderClass} hover:shadow-md shadow-sm`}`}>
{!notif.read && <div className={`absolute left-0 top-0 bottom-0 w-1 ${colorClass.replace('text-', 'bg-')}`}></div>}
<div className="flex justify-between items-start mb-1 gap-4">
<div className="flex items-center gap-2">
<span className={`${colorClass} ${bgClass} p-1 rounded-md`}>{icon}</span>
<h4 className={`text-sm font-black tracking-tight ${notif.read ? 'text-slate-400' : 'text-slate-800'}`}>
{notif.title}
</h4>
</div>
<span className={`text-[10px] font-bold whitespace-nowrap px-2 py-1 rounded ${notif.read ? 'bg-slate-100 text-slate-400' : `${bgClass} ${colorClass} border ${borderClass}`}`}>
{new Date(notif.createdAt).toLocaleDateString('pt-BR')}
</span>
</div>
<p className={`text-xs font-medium leading-relaxed mb-2 ${notif.read ? 'text-slate-400' : 'text-slate-600'}`}>
{displayMessage}
</p>
{isJustificativa && justificationMotive && (
<div className="bg-amber-50 p-2 rounded-lg border border-amber-100 mb-3">
<p className="text-[11px] font-bold text-amber-800 italic uppercase mb-1">Motivo enviado:</p>
<p className="text-xs text-amber-700 font-medium">"{justificationMotive}"</p>
</div>
)}
{(!notif.read) && (
<div className="flex justify-end mt-2 gap-2 transition-opacity">
{isJustificativa && (
<button
onClick={(e) => {
e.stopPropagation();
const targetId = (notif as any).fromStudentId || notif.studentId;
if (onNavigateToStudent && targetId !== 'admin') {
onNavigateToStudent(targetId);
} else {
setView(View.AttendanceQuery);
}
}}
className="text-[10px] font-black uppercase text-amber-600 bg-amber-50 hover:bg-amber-100 px-2 py-1 rounded-lg flex items-center gap-1 transition-colors"
>
<ShieldCheck size={12} /> Ver Histórico
</button>
)}
{isJustificativa && (
<button
onClick={(e) => { e.stopPropagation(); handleAcceptJustification(notif); }}
className="text-[10px] font-black uppercase text-emerald-600 bg-emerald-50 hover:emerald-100 px-2 py-1 rounded-lg flex items-center gap-1 transition-colors"
>
<CheckCircle size={12} /> Aceitar
</button>
)}
{isJustificativa && finalAttachment && !String(finalAttachment).startsWith('{') && (
<button
onClick={(e) => {
e.stopPropagation();
setViewingAttachment(finalAttachment);
setNotifWithAttachment(notif);
}}
className="text-[10px] font-black uppercase text-indigo-600 bg-indigo-50 hover:bg-indigo-100 px-2 py-1 rounded-lg flex items-center gap-1 transition-colors"
>
<Paperclip size={12} /> Ver Anexo
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); handleMarkAsRead(notif.id); }}
className="text-[10px] font-black uppercase text-indigo-600 bg-indigo-50 hover:bg-indigo-100 px-2 py-1 rounded-lg flex items-center gap-1 transition-colors"
>
<CheckCircle size={12} /> Lida
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
)}
{viewingAttachment && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
<div className="p-4 border-b flex items-center justify-between bg-slate-50">
<h3 className="font-black text-slate-800 flex items-center gap-2">
<FileText size={20} className="text-indigo-600" /> Visualização do Documento
</h3>
<div className="flex items-center gap-2">
<button
onClick={handleDeleteAttachment}
className="px-3 py-1.5 bg-red-50 text-red-600 rounded-lg text-xs font-bold hover:bg-red-100 flex items-center gap-1.5 transition-colors"
>
<Trash2 size={14} /> Excluir Arquivo
</button>
<button
onClick={() => { setViewingAttachment(null); setNotifWithAttachment(null); }}
className="p-2 text-slate-400 hover:bg-slate-200 hover:text-slate-700 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
</div>
<div className="flex-1 overflow-auto bg-slate-200 p-4 flex items-center justify-center">
{viewingAttachment.startsWith('data:application/pdf') || viewingAttachment.includes('.pdf') ? (
<iframe src={viewingAttachment} className="w-full h-full min-h-[70vh] rounded-lg shadow-sm bg-white" />
) : (
<img src={viewingAttachment} className="max-w-full max-h-full object-contain rounded-lg shadow-sm" alt="Documento" />
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminNotifications;

View File

@ -0,0 +1,443 @@
import React, { useState, useRef, useEffect } from 'react';
import { SchoolData, Attendance, Student } from '../types';
import { dbService } from '../services/dbService';
import { useDialog } from '../DialogContext';
import { Camera, CheckCircle, XCircle, User, SwitchCamera, Loader2, Search, RefreshCw } from 'lucide-react';
import * as faceapi from '@vladmandic/face-api';
interface AttendanceCaptureProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData }) => {
const { showAlert } = useDialog();
const [cameraActive, setCameraActive] = useState(false);
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user');
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [modelsLoaded, setModelsLoaded] = useState(false);
// Auto-detected state
const [detectedStudentId, setDetectedStudentId] = useState<string | null>(null);
const [detectedClassId, setDetectedClassId] = useState<string | null>(null);
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setCapturedImage(null);
setShowConfirmModal(false);
setDetectedStudentId(null);
setDetectedClassId(null);
setIsProcessing(false);
setIsClosing(false);
stopCamera();
}, 400);
};
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Load Models
useEffect(() => {
const loadModels = async () => {
const MODEL_URL = 'https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model/';
try {
await Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL),
faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL),
faceapi.nets.ssdMobilenetv1.loadFromUri(MODEL_URL),
]);
setModelsLoaded(true);
} catch (err) {
console.error("Error loading face-api models", err);
showAlert('Erro', "Erro ao carregar modelos de reconhecimento facial. Verifique sua conexão.", 'error');
}
};
loadModels();
}, []);
// Start Camera
const startCamera = async () => {
try {
// Stop any existing stream first
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
if (videoRef.current && videoRef.current.srcObject) {
const oldStream = videoRef.current.srcObject as MediaStream;
oldStream.getTracks().forEach(track => track.stop());
videoRef.current.srcObject = null;
}
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: facingMode }
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
try {
await videoRef.current.play();
} catch (e) {
console.error("Error playing video", e);
}
}
setCameraActive(true);
setIsProcessing(false);
} catch (err) {
console.error("Error accessing camera:", err);
showAlert('Erro', "Erro ao acessar a câmera. Verifique as permissões.", 'error');
}
};
// Attach stream to video when active
useEffect(() => {
if (cameraActive && videoRef.current && streamRef.current) {
videoRef.current.srcObject = streamRef.current;
videoRef.current.play().catch(e => console.error("Error playing video", e));
}
}, [cameraActive]);
// Stop Camera on unmount
useEffect(() => {
return () => {
stopCamera();
};
}, []);
const stopCamera = () => {
if (intervalRef.current) clearInterval(intervalRef.current);
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setCameraActive(false);
};
const switchCamera = () => {
setFacingMode(prev => prev === 'user' ? 'environment' : 'user');
};
// Restart camera when facing mode changes
useEffect(() => {
if (cameraActive) {
startCamera();
}
}, [facingMode]);
// Face Detection Loop
useEffect(() => {
if (cameraActive && modelsLoaded && videoRef.current) {
const detectFace = async () => {
if (!videoRef.current || videoRef.current.paused || videoRef.current.ended || isProcessing || showConfirmModal) return;
try {
const detections = await faceapi.detectAllFaces(videoRef.current, new faceapi.TinyFaceDetectorOptions())
.withFaceLandmarks()
.withFaceDescriptors();
if (detections.length > 0) {
// Find best match
const bestMatch = findBestMatch(detections[0].descriptor);
if (bestMatch) {
// Found a student!
setIsProcessing(true);
capturePhoto(bestMatch.studentId, bestMatch.classId);
}
}
} catch (e) {
console.error("Detection error", e);
}
};
intervalRef.current = setInterval(detectFace, 1000); // Check every 1s
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [cameraActive, modelsLoaded, isProcessing, showConfirmModal, data.students]);
const findBestMatch = (descriptor: Float32Array) => {
let bestDistance = 0.6; // Threshold
let bestStudentId = null;
let bestClassId = null;
// Iterate through all active students who have a face descriptor
for (const student of data.students) {
if (student.status !== 'active' || !student.faceDescriptor) continue;
const studentDescriptor = new Float32Array(student.faceDescriptor);
const distance = faceapi.euclideanDistance(descriptor, studentDescriptor);
if (distance < bestDistance) {
bestDistance = distance;
bestStudentId = student.id;
bestClassId = student.classId;
}
}
if (bestStudentId && bestClassId) {
return { studentId: bestStudentId, classId: bestClassId };
}
return null;
};
const capturePhoto = (studentId: string, classId: string) => {
if (videoRef.current && canvasRef.current) {
const video = videoRef.current;
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (context) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = canvas.toDataURL('image/jpeg');
setCapturedImage(imageData);
setDetectedStudentId(studentId);
setDetectedClassId(classId);
setShowConfirmModal(true);
}
}
};
const confirmPresence = () => {
if (!detectedStudentId || !detectedClassId || !capturedImage) return;
// Check if already present for THIS class within a 1-hour window
// (Allows multiple presences per day if lessons are at different times)
const now = new Date();
const alreadyPresent = data.attendance.some(a => {
if (a.studentId !== detectedStudentId || a.classId !== detectedClassId) return false;
const attDate = new Date(a.date);
const isSameDay = attDate.toDateString() === now.toDateString();
const diffMs = Math.abs(now.getTime() - attDate.getTime());
return isSameDay && diffMs < (5 * 60 * 1000); // Intervalo de 5 minutos
});
if (alreadyPresent) {
showAlert('Atenção', "Aluno já marcou presença hoje!", 'warning');
cancelCapture();
return;
}
// Encontrar a aula ativa para esta turma/aluno no momento exato da captura (Sem tolerância de 30 min)
const nowLocal = new Date();
const activeLesson = (data.lessons || []).find(l => {
if (l.classId !== detectedClassId || l.status === 'cancelled') return false;
const lessonDate = l.date; // YYYY-MM-DD
const startStr = `${lessonDate}T${l.startTime || '00:00'}:00`;
const endStr = `${lessonDate}T${l.endTime || '23:59'}:00`;
const lessonStart = new Date(startStr);
const lessonEnd = new Date(endStr);
// REGRA ESTREITA: Apenas do início ao fim da aula
return nowLocal >= lessonStart && nowLocal <= lessonEnd;
});
// BLOQUEIO: Se não houver aula em andamento, impede o registro
if (!activeLesson) {
showAlert('Atenção', "Nenhuma aula em andamento para esta turma no momento. O registro biométrico só é permitido durante o horário oficial da aula.", 'warning');
cancelCapture();
return;
}
// Gerar string de data local forçando o fuso horário de Brasília (America/Sao_Paulo)
// Isso evita que dispositivos em UTC (como o servidor Windows) salvem horários +3 horas
const brtString = new Date().toLocaleString('en-US', { timeZone: 'America/Sao_Paulo' });
const brtDate = new Date(brtString);
const localDateStr = brtDate.getFullYear() + '-' +
String(brtDate.getMonth() + 1).padStart(2, '0') + '-' +
String(brtDate.getDate()).padStart(2, '0') + 'T' +
String(brtDate.getHours()).padStart(2, '0') + ':' +
String(brtDate.getMinutes()).padStart(2, '0') + ':' +
String(brtDate.getSeconds()).padStart(2, '0');
// Limpar qualquer falta auto-gerada para o mesmo aluno nesta mesma aula/dia
const filteredAttendance = (data.attendance || []).filter(a => {
const isAutoAbsence = a.studentId === detectedStudentId &&
a.type === 'absence' &&
(a.autoGenerated || (a.id && a.id.startsWith('auto-abs-'))) &&
(a.lessonId === activeLesson.id || (a.date && a.date.startsWith(activeLesson.date)));
return !isAutoAbsence;
});
const newAttendance: Attendance = {
id: crypto.randomUUID(),
studentId: detectedStudentId,
classId: detectedClassId,
lessonId: activeLesson.id,
date: localDateStr,
photo: capturedImage,
type: 'presence',
verified: true
};
fetch('/api/frequencias', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAttendance)
});
const updatedAttendance = [...filteredAttendance, newAttendance];
updateData({ attendance: updatedAttendance });
// Reset de interface
setCapturedImage(null);
setShowConfirmModal(false);
setDetectedStudentId(null);
setDetectedClassId(null);
setIsProcessing(false);
closeModal();
showAlert('Sucesso', "Presença registrada e sincronizada com o servidor!", 'success');
};
const cancelCapture = () => {
closeModal();
};
const detectedStudent = data.students.find(s => s.id === detectedStudentId);
const detectedClass = data.classes.find(c => c.id === detectedClassId);
return (
<div className="max-w-4xl mx-auto space-y-6 animate-in fade-in duration-300 pb-20 px-4">
<header className="text-center">
<h2 className="text-2xl md:text-3xl font-extrabold text-slate-900 tracking-tight">Registro de Presença</h2>
<p className="text-slate-500 text-sm md:text-base font-medium">Posicione o rosto para identificação automática.</p>
</header>
<div className="flex flex-col items-center gap-6">
{/* Camera View Container */}
<div className="w-full max-w-md space-y-4">
<div className="bg-black rounded-2xl overflow-hidden relative aspect-[3/4] shadow-2xl flex flex-col border-4 border-white">
{cameraActive ? (
<>
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover flex-1"
/>
<canvas ref={canvasRef} className="hidden" />
{/* Overlay UI */}
<div className="absolute inset-0 pointer-events-none border-[3px] border-white/20 m-6 md:m-10 rounded-2xl flex flex-col items-center justify-center">
<div className="w-40 h-40 md:w-56 md:h-56 border-2 border-dashed border-white/40 rounded-full mb-4 animate-pulse"></div>
<p className="text-white/90 text-xs md:text-sm font-bold bg-black/50 px-4 py-1.5 rounded-full backdrop-blur-md">
Aguardando rosto...
</p>
</div>
{/* Switch Camera Button (Floating) */}
<button
onClick={switchCamera}
className="absolute bottom-4 right-4 p-3 bg-white/20 hover:bg-white/30 text-white rounded-full backdrop-blur-md transition-all active:scale-90"
title="Alternar Câmera"
>
<SwitchCamera size={20} />
</button>
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-slate-400 p-8 text-center bg-slate-50">
<div className="w-20 h-20 bg-slate-100 rounded-full flex items-center justify-center mb-4">
<Camera size={40} className="opacity-20" />
</div>
<p className="text-sm font-medium">A câmera está desligada.</p>
<p className="text-xs mt-1">Clique no botão abaixo para iniciar.</p>
</div>
)}
</div>
{/* Main Action Button */}
{!cameraActive ? (
<button
onClick={startCamera}
disabled={!modelsLoaded}
className="w-full py-5 bg-emerald-600 text-white rounded-2xl font-black text-xl hover:bg-emerald-700 shadow-xl shadow-emerald-100 flex items-center justify-center gap-3 transition-all hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50"
>
<CheckCircle size={28} /> Marcar Presença
</button>
) : (
<button
onClick={stopCamera}
className="w-full py-5 bg-red-500 text-white rounded-2xl font-black text-xl hover:bg-red-600 shadow-xl shadow-red-100 flex items-center justify-center gap-3 transition-all hover:scale-[1.02] active:scale-[0.98]"
>
<XCircle size={28} /> Cancelar
</button>
)}
</div>
{/* System Status (Minimalist) */}
{!cameraActive && (
<div className="flex flex-wrap justify-center gap-4 text-[10px] font-bold uppercase tracking-widest text-slate-400">
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${modelsLoaded ? 'bg-emerald-500' : 'bg-amber-500 animate-pulse'}`}></div>
{modelsLoaded ? 'IA Pronta' : 'Carregando IA'}
</div>
<div className="flex items-center gap-1.5">
<User size={12} />
{data.students.filter(s => s.faceDescriptor).length} Faces Cadastradas
</div>
</div>
)}
</div>
{/* Confirmation Modal */}
{showConfirmModal && capturedImage && detectedStudent && (
<div className={`fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-3xl w-full max-w-sm overflow-hidden shadow-2xl transition-all duration-400 relative ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
{/* Blue Top Bar */}
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-8 text-center space-y-6">
<div className="space-y-1">
<h3 className="text-2xl font-black text-slate-800">Identificado!</h3>
<p className="text-slate-500 text-sm font-medium">Confirmar presença para:</p>
</div>
<div className="relative w-48 h-48 mx-auto rounded-full overflow-hidden border-4 border-emerald-500 shadow-2xl">
<img src={capturedImage} alt="Captured" className="w-full h-full object-cover" />
</div>
<div className="space-y-1">
<p className="text-xl font-black text-indigo-900">{detectedStudent.name}</p>
<p className="text-sm font-bold text-indigo-500 bg-indigo-50 inline-block px-3 py-1 rounded-full">{detectedClass?.name}</p>
</div>
<div className="flex flex-col gap-3 pt-4">
<button
onClick={confirmPresence}
className="w-full py-4 bg-emerald-500 text-white rounded-2xl font-black text-lg hover:bg-emerald-600 shadow-lg shadow-emerald-200 flex items-center justify-center gap-2 transition-all active:scale-95"
>
<CheckCircle size={24} /> Confirmar Agora
</button>
<button
onClick={cancelCapture}
className="w-full py-3 text-slate-400 font-bold hover:text-red-500 transition-colors"
>
Não sou eu / Cancelar
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default AttendanceCapture;

File diff suppressed because it is too large Load Diff

234
manager/components/Auth.tsx Normal file
View File

@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { SchoolData, User } from '../types';
import { BookOpen, User as UserIcon, Lock, ArrowRight, Loader2, Shield } from 'lucide-react';
interface AuthProps {
data: SchoolData;
onLogin: (user: User) => void;
onUpdateUsers: (newUsers: User[]) => void;
}
const Auth: React.FC<AuthProps> = ({ data, onLogin, onUpdateUsers }) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isRecovering, setIsRecovering] = useState(false);
const [formData, setFormData] = useState({
name: '',
displayName: '',
password: '',
newPassword: '',
cpf: ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
// Simulate network delay for better UX
await new Promise(resolve => setTimeout(resolve, 800));
// Normalize inputs
const loginName = (formData.name || '').trim().toLowerCase();
const loginPass = formData.password.trim();
const loginCpf = formData.cpf.replace(/\D/g, '');
// Ensure data.users exists before finding
const usersList = data.users || [];
if (isRecovering) {
const userIndex = usersList.findIndex(u => (u.name || '').toLowerCase() === loginName);
const user = usersList[userIndex];
if (user) {
const storedCpf = (user.cpf || '').replace(/\D/g, '');
if (!loginCpf) {
setError('O CPF é obrigatório para recuperação.');
} else if (storedCpf && storedCpf !== loginCpf) {
setError('CPF incorreto para este usuário.');
} else if (!storedCpf) {
setError('Este usuário não possui CPF cadastrado. Entre em contato com o suporte.');
} else if (!formData.newPassword) {
setError('Digite a nova senha.');
} else if (!formData.displayName) {
setError('O Nome Completo é obrigatório.');
} else {
const updatedUsers = [...usersList];
updatedUsers[userIndex] = {
...updatedUsers[userIndex],
password: formData.newPassword.trim(),
displayName: formData.displayName.trim()
};
onUpdateUsers(updatedUsers);
setSuccess('Dados atualizados com sucesso! Faça login.');
setIsRecovering(false);
setFormData({ name: '', displayName: '', password: '', newPassword: '', cpf: '' });
}
} else {
setError('Usuário não encontrado.');
}
} else {
// Login Logic
const user = usersList.find(u =>
(u.name || '').toLowerCase() === loginName &&
u.password === loginPass
);
if (user) {
onLogin(user);
} else {
setError('Usuário ou senha inválidos.');
}
}
setIsLoading(false);
};
const inputClass = "w-full pl-10 pr-4 py-3 bg-slate-50 text-slate-900 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all font-medium text-sm";
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden border border-slate-100 animate-zoom-in">
<div className="p-8 pb-6 bg-white">
<div className="flex justify-center mb-6">
{data.logo ? (
<img src={data.logo} alt="Logo" className="h-28 w-auto max-w-full object-contain" />
) : (
<div className="w-16 h-16 bg-indigo-50 rounded-2xl flex items-center justify-center text-indigo-600 shadow-sm">
<BookOpen size={32} />
</div>
)}
</div>
<h2 className="text-2xl font-black text-center text-slate-800 mb-1">
{isRecovering ? 'Recuperar Senha' : 'Acesso Restrito'}
</h2>
<p className="text-center text-slate-500 text-sm font-medium mb-8">
{isRecovering ? 'Crie uma nova senha para o seu usuário.' : 'Insira suas credenciais para acessar o EduManager.'}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="relative group">
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors" size={18} />
<input
type="text"
placeholder="Nome de Usuário"
required
className={inputClass}
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
/>
</div>
{isRecovering && (
<>
<div className="relative group">
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors" size={18} />
<input
type="text"
placeholder="Nome Completo (Ex: João Silva)"
required
className={inputClass}
value={formData.displayName}
onChange={e => setFormData({...formData, displayName: e.target.value})}
/>
</div>
<div className="relative group">
<Shield className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors" size={18} />
<input
type="text"
placeholder="Confirme seu CPF"
required
className={inputClass}
value={formData.cpf}
onChange={e => {
const val = e.target.value.replace(/\D/g, '').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d{1,2})/, '$1-$2').slice(0, 14);
setFormData({...formData, cpf: val});
}}
/>
</div>
</>
)}
{!isRecovering ? (
<div className="relative group">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors" size={18} />
<input
type="password"
placeholder="Senha"
required
className={inputClass}
value={formData.password}
onChange={e => setFormData({...formData, password: e.target.value})}
/>
</div>
) : (
<div className="relative group">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors" size={18} />
<input
type="password"
placeholder="Nova Senha"
required
className={inputClass}
value={formData.newPassword}
onChange={e => setFormData({...formData, newPassword: e.target.value})}
/>
</div>
)}
{error && (
<div className="p-3 bg-red-50 text-red-600 text-xs font-bold rounded-lg text-center animate-in fade-in">
{error}
</div>
)}
{success && (
<div className="p-3 bg-emerald-50 text-emerald-600 text-xs font-bold rounded-lg text-center animate-in fade-in">
{success}
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm shadow-lg shadow-indigo-200 hover:bg-indigo-700 hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0 transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed mt-4"
>
{isLoading ? (
<Loader2 className="animate-spin" size={20} />
) : (
<>
{isRecovering ? 'Redefinir Senha' : 'Entrar'}
{!isRecovering && <ArrowRight size={18} />}
</>
)}
</button>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={() => {
setIsRecovering(!isRecovering);
setError('');
setSuccess('');
setFormData({ name: '', displayName: '', password: '', newPassword: '', cpf: '' });
}}
className="text-sm font-bold text-indigo-600 hover:text-indigo-800 transition-colors"
>
{isRecovering ? 'Voltar para o Login' : 'Esqueceu a senha?'}
</button>
</div>
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100 text-center">
<p className="text-[10px] text-slate-400 uppercase font-bold">Acesso Padrão: admin / admin</p>
</div>
</div>
</div>
);
};
export default Auth;

View File

@ -0,0 +1,709 @@
import React, { useState, useRef, useEffect } from 'react';
import { SchoolData, Certificate, Student, CertificateTemplate, TextOverlay } from '../types';
import { dbService } from '../services/dbService';
import { useDialog } from '../DialogContext';
import { Award, Upload, Search, Trash2, Download, Eye, X, Image as ImageIcon, Edit2, Save, Type, Move, Palette, Baseline, Layout, Copy, Check, Plus } from 'lucide-react';
import jsPDF from 'jspdf';
interface CertificatesProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const Certificates: React.FC<CertificatesProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [dbClasses, setDbClasses] = useState<any[]>(data?.classes || []);
const [dbCourses, setDbCourses] = useState<any[]>(data?.courses || []);
const [dbSubjects, setDbSubjects] = useState<any[]>(data?.subjects || []);
React.useEffect(() => {
Promise.all([
fetch('/api/turmas').catch(() => ({ ok: false, json: async () => ({}) })),
fetch('/api/cursos').catch(() => ({ ok: false, json: async () => ({}) })),
fetch('/api/disciplinas').catch(() => ({ ok: false, json: async () => ({}) }))
]).then(async (responses) => {
const [resT, resC, resS] = responses;
if (resT && resT.ok) {
const jsonT = await resT.json();
if (jsonT.turmas) setDbClasses(jsonT.turmas.map((t: any) => ({
id: t.id, name: t.nome, courseId: t.curso_id, maxStudents: Number(t.max_alunos || 0)
})));
}
if (resC && resC.ok) {
const jsonC = await resC.json();
if (jsonC.cursos) setDbCourses(jsonC.cursos.map((c: any) => ({
id: c.id, name: c.nome, monthlyFee: Number(c.mensalidade || 0), registrationFee: Number(c.taxa_matricula || 0)
})));
}
if (resS && resS.ok) {
const jsonS = await resS.json();
if (jsonS.disciplinas) setDbSubjects(jsonS.disciplinas.map((d: any) => ({
id: d.id, name: d.nome
})));
}
}).catch(console.error);
}, []);
const [searchTerm, setSearchTerm] = useState('');
const [selectedStudentId, setSelectedStudentId] = useState('');
const [description, setDescription] = useState('');
const [frontImage, setFrontImage] = useState<string | null>(null);
const [backImage, setBackImage] = useState<string | null>(null);
const [previewCertificate, setPreviewCertificate] = useState<Certificate | null>(null);
const [editingCertificate, setEditingCertificate] = useState<Certificate | null>(null);
const [activeTab, setActiveTab] = useState<'front' | 'back'>('front');
const [templateName, setTemplateName] = useState('');
const [showTemplateModal, setShowTemplateModal] = useState(false);
// Overlays States
const [frontOverlays, setFrontOverlays] = useState<TextOverlay[]>([]);
const [backOverlays, setBackOverlays] = useState<TextOverlay[]>([]);
const [selectedOverlayId, setSelectedOverlayId] = useState<string | null>(null);
const fileInputFrontRef = useRef<HTMLInputElement>(null);
const fileInputBackRef = useRef<HTMLInputElement>(null);
const activeOverlays = activeTab === 'front' ? frontOverlays : backOverlays;
const setActiveOverlays = activeTab === 'front' ? setFrontOverlays : setBackOverlays;
const selectedOverlay = activeOverlays.find(o => o.id === selectedOverlayId);
const handleAddOverlay = () => {
const newOverlay: TextOverlay = {
id: crypto.randomUUID(),
text: activeTab === 'front' ? 'Certificamos que {{aluno}}...' : 'Conteúdo do verso...',
x: 50,
y: 50,
fontSize: activeTab === 'front' ? 24 : 12,
color: '#000000'
};
setActiveOverlays([...activeOverlays, newOverlay]);
setSelectedOverlayId(newOverlay.id);
};
const handleUpdateOverlay = (id: string, updates: Partial<TextOverlay>) => {
setActiveOverlays(activeOverlays.map(o => o.id === id ? { ...o, ...updates } : o));
};
const handleRemoveOverlay = (id: string) => {
setActiveOverlays(activeOverlays.filter(o => o.id !== id));
if (selectedOverlayId === id) setSelectedOverlayId(null);
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>, side: 'front' | 'back') => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
if (side === 'front') setFrontImage(reader.result as string);
else setBackImage(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSaveCertificate = () => {
if (!selectedStudentId || !frontImage) {
showAlert('Atenção', '⚠️ Por favor, selecione um aluno e faça upload da imagem da frente.', 'warning');
return;
}
const certificateData: Certificate = {
id: editingCertificate ? editingCertificate.id : crypto.randomUUID(),
studentId: selectedStudentId,
description,
frontImage,
backImage: backImage || undefined,
issueDate: editingCertificate ? editingCertificate.issueDate : new Date().toISOString(),
frontOverlays,
backOverlays
};
let updatedCertificates;
if (editingCertificate) {
updatedCertificates = (data.certificates || []).map(c => c.id === editingCertificate.id ? certificateData : c);
} else {
updatedCertificates = [...(data.certificates || []), certificateData];
}
updateData({ certificates: updatedCertificates });
dbService.saveData({ ...data, certificates: updatedCertificates });
resetForm();
showAlert('Sucesso', editingCertificate ? '✅ Certificado atualizado!' : '✅ Certificado salvo com sucesso!', 'success');
};
const handleSaveTemplate = () => {
if (!templateName || !frontImage) {
showAlert('Atenção', '⚠️ Informe um nome para o modelo e carregue pelo menos a imagem da frente.', 'warning');
return;
}
const newTemplate: CertificateTemplate = {
id: crypto.randomUUID(),
name: templateName,
frontImage,
backImage: backImage || undefined,
frontOverlays,
backOverlays
};
const updatedTemplates = [...(data.certificateTemplates || []), newTemplate];
updateData({ certificateTemplates: updatedTemplates });
dbService.saveData({ ...data, certificateTemplates: updatedTemplates });
setTemplateName('');
setShowTemplateModal(false);
showAlert('Sucesso', '✅ Modelo salvo com sucesso!', 'success');
};
const loadTemplate = (template: CertificateTemplate) => {
setFrontImage(template.frontImage);
setBackImage(template.backImage);
setFrontOverlays(template.frontOverlays || []);
setBackOverlays(template.backOverlays || []);
setSelectedOverlayId(null);
showAlert('Modelo Carregado', `✅ Modelo "${template.name}" carregado!`, 'success');
};
const resetForm = () => {
setSelectedStudentId('');
setDescription('');
setFrontImage(null);
setBackImage(null);
setFrontOverlays([]);
setBackOverlays([]);
setSelectedOverlayId(null);
setEditingCertificate(null);
if (fileInputFrontRef.current) fileInputFrontRef.current.value = '';
if (fileInputBackRef.current) fileInputBackRef.current.value = '';
};
const handleEditCertificate = (cert: Certificate) => {
setEditingCertificate(cert);
setSelectedStudentId(cert.studentId);
setDescription(cert.description || '');
setFrontImage(cert.frontImage);
setBackImage(cert.backImage);
setFrontOverlays(cert.frontOverlays || []);
setBackOverlays(cert.backOverlays || []);
setSelectedOverlayId(null);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleDeleteCertificate = (id: string) => {
showConfirm(
'Excluir Certificado',
'⚠️ Tem certeza que deseja excluir este certificado?',
() => {
const updatedCertificates = (data.certificates || []).filter(c => c.id !== id);
updateData({ certificates: updatedCertificates });
dbService.saveData({ ...data, certificates: updatedCertificates });
}
);
};
const handleDeleteTemplate = (id: string) => {
showConfirm(
'Excluir Modelo',
'⚠️ Excluir este modelo?',
() => {
const updatedTemplates = (data.certificateTemplates || []).filter(t => t.id !== id);
updateData({ certificateTemplates: updatedTemplates });
dbService.saveData({ ...data, certificateTemplates: updatedTemplates });
}
);
};
const getStudentStats = (studentId: string) => {
const studentGrades = (data.grades || []).filter(g => g.studentId === studentId);
const media = studentGrades.length > 0
? (studentGrades.reduce((acc, curr) => acc + curr.value, 0) / studentGrades.length).toFixed(1)
: '0.0';
const studentAttendance = (data.attendance || []).filter(a => a.studentId === studentId);
const presences = studentAttendance.filter(a => a.type === 'presence').length;
const frequencia = studentAttendance.length > 0
? ((presences / studentAttendance.length) * 100).toFixed(0)
: '0';
return { media, frequencia };
};
const handleDownloadPDF = (cert: Certificate) => {
const doc = new jsPDF({
orientation: 'landscape',
unit: 'mm',
format: 'a4'
});
const width = doc.internal.pageSize.getWidth();
const height = doc.internal.pageSize.getHeight();
const student = data.students.find(s => s.id === cert.studentId);
const studentName = student?.name || 'Aluno';
const { media, frequencia } = getStudentStats(cert.studentId);
const historico = (data.grades || [])
.filter(g => g.studentId === cert.studentId)
.map(g => {
const subject = dbSubjects.find(s => s.id === g.subjectId);
return `${subject?.name || 'Disciplina'}: ${g.value}`;
})
.join('\n');
// Helper to draw wrapped text
const drawWrappedText = (text: string, xPerc: number, yPerc: number, fSize: number, color: string) => {
const processedText = text
.replace(/{{aluno}}/gi, studentName)
.replace(/{{media}}/gi, media)
.replace(/{{frequencia}}/gi, `${frequencia}%`)
.replace(/{{historico}}/gi, historico);
doc.setTextColor(color);
doc.setFontSize(fSize);
const xPos = (xPerc / 100) * width;
const yPos = (yPerc / 100) * height;
const maxW = width * 0.8; // 80% of page width
const lines = doc.splitTextToSize(processedText, maxW);
doc.text(lines, xPos, yPos, { align: 'center' });
};
// Front
doc.addImage(cert.frontImage, 'JPEG', 0, 0, width, height);
(cert.frontOverlays || []).forEach(o => {
drawWrappedText(o.text, o.x, o.y, o.fontSize, o.color);
});
// Back (Only if has back image or back overlays)
if (cert.backImage || (cert.backOverlays && cert.backOverlays.length > 0)) {
doc.addPage();
if (cert.backImage) {
doc.addImage(cert.backImage, 'JPEG', 0, 0, width, height);
}
(cert.backOverlays || []).forEach(o => {
drawWrappedText(o.text, o.x, o.y, o.fontSize, o.color);
});
}
doc.save(`Certificado_${studentName.replace(/\s+/g, '_')}.pdf`);
};
const filteredCertificates = (data.certificates || []).filter(cert => {
const student = data.students.find(s => s.id === cert.studentId);
return (student?.name || '').toLowerCase().includes((searchTerm || '').toLowerCase());
});
const currentStudentName = data.students.find(s => s.id === selectedStudentId)?.name || 'Nome do Aluno';
const { media: currentMedia, frequencia: currentFrequencia } = getStudentStats(selectedStudentId);
const currentHistorico = (data.grades || [])
.filter(g => g.studentId === selectedStudentId)
.map(g => {
const subject = dbSubjects.find(s => s.id === g.subjectId);
return `${subject?.name || 'Disciplina'}: ${g.value}`;
})
.join('\n') || 'Matemática: 9.5\nPortuguês: 8.0\nHistória: 10.0';
const processPreviewText = (text: string) => {
return text
.replace(/{{aluno}}/gi, currentStudentName)
.replace(/{{media}}/gi, currentMedia)
.replace(/{{frequencia}}/gi, `${currentFrequencia}%`)
.replace(/{{historico}}/gi, currentHistorico);
};
return (
<div className="space-y-8 animate-in fade-in duration-300 pb-20">
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Certificados</h2>
<p className="text-slate-500 font-medium">Gerencie, edite e emita certificados personalizados.</p>
</div>
<div className="flex gap-2">
{editingCertificate && (
<button
onClick={resetForm}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200 transition-colors font-bold text-sm flex items-center gap-2"
>
<X size={18} /> Cancelar Edição
</button>
)}
<button
onClick={() => setShowTemplateModal(true)}
className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-colors font-bold text-sm flex items-center gap-2"
>
<Layout size={18} /> Modelos Salvos
</button>
</div>
</header>
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8">
{/* Form & Preview Section */}
<div className="xl:col-span-8 space-y-6">
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xl space-y-6">
<div className="flex items-center justify-between border-b border-slate-100 pb-4">
<div className="flex items-center gap-3 text-indigo-600">
<div className="p-2 bg-indigo-50 rounded-lg">
<Award size={20} />
</div>
<h3 className="text-lg font-black text-slate-800">
{editingCertificate ? 'Editar Certificado' : 'Novo Certificado'}
</h3>
</div>
<div className="flex bg-slate-100 p-1 rounded-xl">
<button
onClick={() => setActiveTab('front')}
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${activeTab === 'front' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
FRENTE
</button>
<button
onClick={() => setActiveTab('back')}
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${activeTab === 'back' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
VERSO
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Aluno</label>
<select
className="w-full px-4 py-3 bg-slate-50 text-black border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-medium"
value={selectedStudentId}
onChange={(e) => setSelectedStudentId(e.target.value)}
>
<option value="">Selecione um aluno...</option>
{data.students
.filter(s => s.status === 'active')
.sort((a, b) => a.name.localeCompare(b.name))
.map(student => (
<option key={student.id} value={student.id}>{student.name}</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Descrição Interna</label>
<input
type="text"
className="w-full px-4 py-3 bg-slate-50 text-black border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
placeholder="Ex: Conclusão de Curso 2024"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-black text-slate-400 uppercase tracking-widest ml-1">Textos ({activeTab === 'front' ? 'Frente' : 'Verso'})</h4>
<button
onClick={handleAddOverlay}
className="px-3 py-1.5 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-600 hover:text-white transition-all font-bold text-[10px] flex items-center gap-1.5"
>
<Plus size={14} /> Adicionar Texto
</button>
</div>
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
{activeOverlays.map((overlay, index) => (
<div
key={overlay.id}
className={`p-4 rounded-2xl border transition-all space-y-4 ${selectedOverlayId === overlay.id ? 'border-indigo-500 bg-indigo-50/30 ring-2 ring-indigo-100' : 'border-slate-100 bg-slate-50'}`}
onClick={() => setSelectedOverlayId(overlay.id)}
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Texto #{index + 1}</span>
<button
onClick={(e) => { e.stopPropagation(); handleRemoveOverlay(overlay.id); }}
className="p-1.5 text-slate-400 hover:text-red-500 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
<div className="space-y-4">
<div>
<div className="flex justify-between items-end mb-1.5 ml-1">
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest">Conteúdo</label>
<span className="text-[9px] text-indigo-500 font-bold">Variáveis: {"{{aluno}}"}, {"{{media}}"}, {"{{frequencia}}"}, {"{{historico}}"}</span>
</div>
<textarea
className="w-full px-4 py-3 bg-white text-black border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm min-h-[80px]"
value={overlay.text}
onChange={(e) => handleUpdateOverlay(overlay.id, { text: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1 flex items-center gap-1">
<Baseline size={12} /> Fonte
</label>
<input type="number" className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm" value={overlay.fontSize} onChange={(e) => handleUpdateOverlay(overlay.id, { fontSize: parseInt(e.target.value) || 0 })} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1 flex items-center gap-1">
<Palette size={12} /> Cor
</label>
<input type="color" className="h-11 w-full p-1 bg-white border border-slate-200 rounded-xl cursor-pointer" value={overlay.color} onChange={(e) => handleUpdateOverlay(overlay.id, { color: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1 flex items-center gap-1">
<Move size={12} /> Posição (X: {overlay.x}% | Y: {overlay.y}%)
</label>
<div className="space-y-4 px-2">
<input type="range" min="0" max="100" value={overlay.x} onChange={(e) => handleUpdateOverlay(overlay.id, { x: parseInt(e.target.value) || 0 })} className="w-full accent-indigo-600" />
<input type="range" min="0" max="100" value={overlay.y} onChange={(e) => handleUpdateOverlay(overlay.id, { y: parseInt(e.target.value) || 0 })} className="w-full accent-indigo-600" />
</div>
</div>
</div>
</div>
))}
{activeOverlays.length === 0 && (
<div className="py-8 text-center text-slate-400 italic text-sm border-2 border-dashed border-slate-100 rounded-2xl">
Nenhum texto adicionado. Clique em "Adicionar Texto".
</div>
)}
</div>
</div>
</div>
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4">
{/* Front Image Upload */}
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Frente do Certificado</label>
<div
className={`aspect-[1.414/1] rounded-2xl border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center relative overflow-hidden group bg-slate-50 ${activeTab === 'front' ? 'border-indigo-500 ring-4 ring-indigo-50' : 'border-slate-300'}`}
onClick={() => fileInputFrontRef.current?.click()}
>
{frontImage ? (
<>
<img src={frontImage} alt="Frente" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white font-bold text-xs">Alterar Frente</div>
</>
) : (
<>
<ImageIcon className="text-slate-400 mb-2" size={32} />
<span className="text-xs font-bold text-slate-500 uppercase">Upload Frente</span>
</>
)}
<input type="file" ref={fileInputFrontRef} className="hidden" accept="image/*" onChange={(e) => handleImageUpload(e, 'front')} />
</div>
</div>
{/* Back Image Upload */}
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Verso do Certificado</label>
<div
className={`aspect-[1.414/1] rounded-2xl border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center relative overflow-hidden group bg-slate-50 ${activeTab === 'back' ? 'border-indigo-500 ring-4 ring-indigo-50' : 'border-slate-300'}`}
onClick={() => fileInputBackRef.current?.click()}
>
{backImage ? (
<>
<img src={backImage} alt="Verso" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white font-bold text-xs">Alterar Verso</div>
</>
) : (
<>
<ImageIcon className="text-slate-400 mb-2" size={32} />
<span className="text-xs font-bold text-slate-500 uppercase">Upload Verso</span>
</>
)}
<input type="file" ref={fileInputBackRef} className="hidden" accept="image/*" onChange={(e) => handleImageUpload(e, 'back')} />
</div>
</div>
</div>
</div>
</div>
<div className="pt-6 border-t border-slate-100 flex gap-4">
<button
onClick={() => setShowTemplateModal(true)}
className="flex-1 py-4 bg-white border-2 border-indigo-100 text-indigo-600 rounded-2xl hover:bg-indigo-50 transition-all font-black text-lg flex items-center justify-center gap-2"
>
<Copy size={24} /> Salvar como Modelo
</button>
<button
onClick={handleSaveCertificate}
className="flex-[2] py-4 bg-indigo-600 text-white rounded-2xl hover:bg-indigo-700 transition-all shadow-xl shadow-indigo-100 font-black text-lg flex items-center justify-center gap-2 active:scale-95"
>
{editingCertificate ? <Save size={24} /> : <Upload size={24} />}
{editingCertificate ? 'Atualizar Certificado' : 'Salvar Certificado'}
</button>
</div>
</div>
{/* Visual Preview Montage */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xl space-y-4">
<h3 className="text-lg font-black text-slate-800 flex items-center gap-2">
<Eye size={20} className="text-indigo-600" /> Pré-visualização ({activeTab === 'front' ? 'FRENTE' : 'VERSO'})
</h3>
<div className="relative aspect-[1.414/1] w-full bg-slate-100 rounded-xl overflow-hidden shadow-inner border border-slate-200">
{activeTab === 'front' ? (
<>
{frontImage ? <img src={frontImage} alt="Preview Front" className="w-full h-full object-cover" /> : <div className="w-full h-full flex items-center justify-center text-slate-400 font-bold">Carregue a imagem da frente</div>}
{frontOverlays.map(o => (
<div
key={o.id}
className={`absolute pointer-events-none text-center transform -translate-x-1/2 -translate-y-1/2 whitespace-pre-wrap ${selectedOverlayId === o.id ? 'ring-2 ring-indigo-500 ring-offset-2 rounded px-1' : ''}`}
style={{
left: `${o.x}%`,
top: `${o.y}%`,
fontSize: `${o.fontSize}px`,
color: o.color,
width: '80%',
fontWeight: 'bold',
textShadow: '0 1px 2px rgba(0,0,0,0.1)'
}}
>
{processPreviewText(o.text)}
</div>
))}
</>
) : (
<>
{backImage ? <img src={backImage} alt="Preview Back" className="w-full h-full object-cover" /> : <div className="w-full h-full flex items-center justify-center text-slate-400 font-bold">Carregue a imagem do verso</div>}
{backOverlays.map(o => (
<div
key={o.id}
className={`absolute pointer-events-none text-center transform -translate-x-1/2 -translate-y-1/2 whitespace-pre-wrap ${selectedOverlayId === o.id ? 'ring-2 ring-indigo-500 ring-offset-2 rounded px-1' : ''}`}
style={{
left: `${o.x}%`,
top: `${o.y}%`,
fontSize: `${o.fontSize}px`,
color: o.color,
width: '80%',
fontWeight: 'bold',
textShadow: '0 1px 2px rgba(0,0,0,0.1)'
}}
>
{processPreviewText(o.text)}
</div>
))}
</>
)}
<div className="absolute bottom-4 right-4 bg-black/50 backdrop-blur-md text-white text-[10px] font-bold px-3 py-1.5 rounded-full uppercase">
{activeTab} (MODO EDIÇÃO)
</div>
</div>
</div>
</div>
{/* List Section */}
<div className="xl:col-span-4 space-y-6">
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xl h-full flex flex-col">
<div className="flex items-center gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input
type="text"
placeholder="Buscar certificados..."
className="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-4 pr-2 custom-scrollbar">
{filteredCertificates.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<Award size={48} className="mx-auto mb-4 opacity-20" />
<p className="font-medium">Nenhum certificado.</p>
</div>
) : (
filteredCertificates.map(cert => {
const student = data.students.find(s => s.id === cert.studentId);
return (
<div key={cert.id} className="p-4 bg-slate-50 rounded-2xl border border-slate-100 hover:border-indigo-200 transition-all group relative">
<div className="flex items-center gap-4 mb-3">
<div className="w-10 h-10 rounded-xl bg-indigo-100 flex items-center justify-center text-indigo-600 font-bold shrink-0"><Award size={20} /></div>
<div className="min-w-0">
<h4 className="font-bold text-slate-800 truncate">{student?.name || 'Aluno Removido'}</h4>
<p className="text-[10px] text-slate-500 font-medium">Emitido: {new Date(cert.issueDate).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={() => handleDownloadPDF(cert)} className="flex-1 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors text-[10px] font-bold flex items-center justify-center gap-1"><Download size={14} /> PDF</button>
<button onClick={() => handleEditCertificate(cert)} className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" title="Editar"><Edit2 size={16} /></button>
<button onClick={() => handleDeleteCertificate(cert.id)} className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Excluir"><Trash2 size={16} /></button>
</div>
</div>
);
})
)}
</div>
</div>
</div>
</div>
{/* TEMPLATE MODAL */}
{showTemplateModal && (
<div className="fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 overflow-y-auto animate-in fade-in duration-300">
<div className="bg-white rounded-3xl w-full max-w-2xl shadow-2xl flex flex-col max-h-[90vh] my-auto animate-slide-up">
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-xl font-black text-slate-800">Modelos de Certificado</h3>
<p className="text-xs text-slate-500">Salve ou carregue configurações pré-definidas.</p>
</div>
<button onClick={() => setShowTemplateModal(false)} className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all"><X size={20} /></button>
</div>
<div className="p-6 space-y-6 overflow-y-auto">
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-200 space-y-4">
<h4 className="text-xs font-black text-slate-400 uppercase tracking-widest">Salvar Configuração Atual</h4>
<div className="flex gap-2">
<input
type="text"
placeholder="Nome do modelo (ex: Curso Informática)"
className="flex-1 px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-medium focus:ring-2 focus:ring-indigo-500 outline-none"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
/>
<button onClick={handleSaveTemplate} className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2">
<Save size={18} /> Salvar
</button>
</div>
</div>
<div className="space-y-3">
<h4 className="text-xs font-black text-slate-400 uppercase tracking-widest ml-1">Modelos Disponíveis</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(data.certificateTemplates || []).map(template => (
<div key={template.id} className="p-4 bg-white border border-slate-200 rounded-2xl hover:border-indigo-300 transition-all group flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="font-bold text-slate-800 truncate">{template.name}</span>
<button onClick={() => handleDeleteTemplate(template.id)} className="text-slate-300 hover:text-red-500 transition-colors"><Trash2 size={16} /></button>
</div>
<button
onClick={() => { loadTemplate(template); setShowTemplateModal(false); }}
className="w-full py-2 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-all font-bold text-xs flex items-center justify-center gap-2"
>
<Check size={14} /> Carregar Modelo
</button>
</div>
))}
{(data.certificateTemplates || []).length === 0 && (
<div className="col-span-full py-8 text-center text-slate-400 italic text-sm">Nenhum modelo salvo ainda.</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default Certificates;

View File

@ -0,0 +1,706 @@
import React, { useState } from 'react';
import { SchoolData, Class } from '../types';
import { useDialog } from '../DialogContext';
import { Plus, Edit2, Trash2, X, Clock, User, Book, Printer, RefreshCw, Calendar, Settings } from 'lucide-react';
import { pdfService } from '../services/pdfService';
import LessonSchedule from './LessonSchedule';
import { dbService } from '../services/dbService';
interface ClassesProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
onNavigateToClass: (classId: string, studentId?: string) => void;
}
const Classes: React.FC<ClassesProps> = ({ data, updateData, onNavigateToClass }) => {
const { showAlert, showConfirm } = useDialog();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [editingClass, setEditingClass] = useState<Class | null>(null);
const [isGeneratingPDF, setIsGeneratingPDF] = useState<string | null>(null);
const [scheduleClass, setScheduleClass] = useState<Class | null>(null); // For LessonSchedule component
const [viewingStudentsClass, setViewingStudentsClass] = useState<Class | null>(null); // For student list modal
const normalizePhotoUrl = (url?: string) => {
if (!url || typeof url !== 'string') return '';
if (url.startsWith('data:image') || url.startsWith('blob:')) return url;
if (url.startsWith('/storage/')) return url;
try {
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
if (match) return `/storage/${match[1]}`;
} catch(e) {}
return url;
};
const [classes, setClasses] = useState<Class[]>([]);
const [courses, setCourses] = useState<any[]>([]); // To display names correctly
const [employees, setEmployees] = useState<any[]>([]); // To display names
const [isLoadingData, setIsLoadingData] = useState(true);
const loadData = async () => {
try {
setIsLoadingData(true);
const [clsRes, crsRes, empRes] = await Promise.all([
fetch('/api/turmas'),
fetch('/api/cursos'),
fetch('/api/funcionarios')
]);
const clsData = await clsRes.json();
const crsData = await crsRes.json();
const empData = await empRes.json();
const mappedClasses = (clsData.turmas || []).map((t: any) => ({
id: t.id,
name: t.nome,
courseId: t.curso_id,
teacher: t.professor,
schedule: t.horario,
scheduleDay: t.dia_semana,
maxStudents: Number(t.max_alunos || 0),
startDate: t.data_inicio ? t.data_inicio.substring(0, 10) : '',
endDate: t.data_fim ? t.data_fim.substring(0, 10) : '',
defaultStartTime: t.horario_inicio_padrao,
defaultEndTime: t.horario_fim_padrao
}));
setClasses(mappedClasses);
setCourses(crsData.cursos || []);
setEmployees(empData.funcionarios || []);
} catch (err) {
console.error('Erro ao buscar turmas/cursos/funcionários:', err);
showAlert('Erro', 'Falha ao carregar dados do servidor.', 'error');
} finally {
setIsLoadingData(false);
}
};
React.useEffect(() => {
loadData();
}, []);
const [formData, setFormData] = useState<Omit<Class, 'id'>>({
name: '',
courseId: '',
teacher: '',
schedule: '',
scheduleDay: '',
maxStudents: 15,
startDate: '',
endDate: '',
defaultStartTime: '',
defaultEndTime: ''
});
const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'];
const [quickTimeClass, setQuickTimeClass] = useState<Class | null>(null);
const [quickStartTime, setQuickStartTime] = useState('');
const [quickEndTime, setQuickEndTime] = useState('');
// Auto-calculate end date based on course durationMonths
React.useEffect(() => {
if (formData.courseId && formData.startDate) {
const course = courses.find((c: any) => c.id === formData.courseId);
if (course && course.duracao_meses) {
const start = new Date(formData.startDate + 'T12:00:00Z');
const end = new Date(start);
end.setUTCMonth(end.getUTCMonth() + course.duracao_meses);
const endString = end.toISOString().split('T')[0];
setFormData(prev => ({ ...prev, endDate: endString }));
}
}
}, [formData.courseId, formData.startDate, courses]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.courseId || !formData.teacher) {
showAlert('Atenção', '⚠️ Por favor, preencha todos os campos obrigatórios.', 'warning');
return;
}
const todayStr = new Date().toISOString().split('T')[0];
// Removido bloqueio de data retroativa para permitir planejamento histórico
const newClassId = editingClass ? editingClass.id : crypto.randomUUID();
const resolvedScheduleName = formData.scheduleDay ? DAY_NAMES[parseInt(formData.scheduleDay)] : formData.schedule;
const newClass: Class = {
...formData,
id: newClassId,
schedule: resolvedScheduleName
};
let updatedLessons = [...(data.lessons || [])];
// Gerar cronograma automaticamente
if (newClass.startDate && newClass.endDate && newClass.scheduleDay && newClass.defaultStartTime && newClass.defaultEndTime) {
let generationStartStr = newClass.startDate;
if (editingClass) {
// Ao editar, removemos apenas as aulas que coincidem ou são futuras em relação ao ponto de alteração
// Mas o sistema agora permite gerar todo o período do curso (mesmo retroativo) se solicitado.
updatedLessons = updatedLessons.filter(l => !(l.classId === newClass.id && l.date >= generationStartStr));
}
const generatedLessons = [];
let currentDate = new Date(generationStartStr + 'T12:00:00Z');
const endObject = new Date(newClass.endDate + 'T12:00:00Z');
const targetDay = parseInt(newClass.scheduleDay);
// Avançar até o primeiro dia da semana alvo a partir da data de início (nunca para trás)
while (currentDate.getUTCDay() !== targetDay) {
currentDate.setUTCDate(currentDate.getUTCDate() + 1);
}
while (currentDate <= endObject) {
const dateString = currentDate.toISOString().split('T')[0];
generatedLessons.push({
id: crypto.randomUUID(),
classId: newClass.id,
date: dateString,
startTime: newClass.defaultStartTime,
endTime: newClass.defaultEndTime,
status: 'scheduled',
type: 'regular'
});
currentDate.setUTCDate(currentDate.getUTCDate() + 7);
}
updatedLessons = [...updatedLessons, ...generatedLessons];
}
const payload = {
nome: newClass.name,
curso_id: newClass.courseId,
professor: newClass.teacher,
horario: newClass.schedule,
dia_semana: newClass.scheduleDay,
max_alunos: newClass.maxStudents,
data_inicio: newClass.startDate,
data_fim: newClass.endDate,
horario_inicio_padrao: newClass.defaultStartTime,
horario_fim_padrao: newClass.defaultEndTime
};
const saveToServer = async () => {
try {
if (editingClass) {
const res = await fetch(`/api/turmas/${editingClass.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Failed to update class');
} else {
const res = await fetch('/api/turmas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, id: newClass.id })
});
if (!res.ok) throw new Error('Failed to create class');
}
// Save lessons in both PostgreSQL and JSON for compatibility
const classLessons = updatedLessons.filter(l => l.classId === newClass.id);
if (classLessons.length > 0) {
await fetch('/api/aulas/lote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aulas: classLessons })
}).catch(e => console.warn('Erro ao salvar aulas no SQL:', e));
}
// Sincroniza também a lista global de turmas para o JSON não deletá-la na sync reversa
let updatedClasses = [...(data.classes || [])];
if (editingClass) {
updatedClasses = updatedClasses.map(c => c.id === editingClass.id ? newClass : c);
} else {
updatedClasses.push(newClass);
}
updateData({ classes: updatedClasses, lessons: updatedLessons });
dbService.saveData({ ...data, classes: updatedClasses, lessons: updatedLessons });
await loadData();
closeModal();
} catch (err) {
showAlert('Erro', 'Falha ao salvar turma no banco.', 'error');
}
};
saveToServer();
};
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setIsModalOpen(false);
setIsClosing(false);
setEditingClass(null);
setFormData({ name: '', courseId: '', teacher: '', schedule: '', maxStudents: 15 });
}, 400);
};
const handleEdit = (cls: Class) => {
setEditingClass(cls);
setFormData({ ...cls });
setIsModalOpen(true);
};
const handleDelete = (id: string) => {
showConfirm(
'Excluir Turma',
'⚠️ Tem certeza que deseja excluir esta turma? Isso não removerá os alunos, mas eles ficarão sem turma.',
async () => {
try {
const res = await fetch(`/api/turmas/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
// Remove a turma e suas aulas associadas do estado global/JSON para manter sincronizado
const updatedClasses = (data.classes || []).filter(c => c.id !== id);
const updatedLessons = (data.lessons || []).filter(l => l.classId !== id);
updateData({ classes: updatedClasses, lessons: updatedLessons });
dbService.saveData({ ...data, classes: updatedClasses, lessons: updatedLessons });
await loadData();
} catch (err) {
showAlert('Erro', 'Ocorreu um erro ao deletar a turma.', 'error');
}
}
);
};
const handleDownloadClassList = async (cls: Class) => {
setIsGeneratingPDF(cls.id);
try {
await pdfService.generateClassListPDF(cls, data);
} catch (error) {
console.error('Error generating PDF:', error);
} finally {
setIsGeneratingPDF(null);
}
};
const handleQuickTimeSave = () => {
if (!quickTimeClass || !quickStartTime || !quickEndTime) {
showAlert('Atenção', 'Preencha início e término.', 'warning');
return;
}
if (quickStartTime >= quickEndTime) {
showAlert('Atenção', 'Fim deve ser maior que início.', 'warning');
return;
}
// Save class default times
const updatedClass = { ...quickTimeClass, defaultStartTime: quickStartTime, defaultEndTime: quickEndTime };
const updatedClasses = data.classes.map(c => c.id === quickTimeClass.id ? updatedClass : c);
// Update all future scheduled lessons for this class
const today = new Date().toISOString().split('T')[0];
const updatedLessons = (data.lessons || []).map(l => {
if (l.classId === quickTimeClass.id && l.status === 'scheduled' && l.date >= today) {
return { ...l, startTime: quickStartTime, endTime: quickEndTime };
}
return l;
});
updateData({ classes: updatedClasses, lessons: updatedLessons });
dbService.saveData({ ...data, classes: updatedClasses, lessons: updatedLessons });
setQuickTimeClass(null);
showAlert('Sucesso', 'Horário alterado para a turma e todas as aulas futuras atualizadas!', 'success');
};
const calculateAge = (birthDate: string) => {
if (!birthDate) return null;
const cleanDate = birthDate.substring(0, 10);
if (!cleanDate.includes('-')) return null;
const [year, month, day] = cleanDate.split('-').map(Number);
const birth = new Date(year, month - 1, day);
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age;
};
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm";
return (
<div className="space-y-6 animate-in fade-in duration-300">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Turmas <span className="text-sm font-normal text-green-600 bg-green-50 px-2 py-1 rounded-md ml-2 border border-green-200">PostgreSQL</span></h2>
<p className="text-slate-500">Controle de horários e ocupação das salas.</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="bg-indigo-600 text-white px-6 py-3 rounded-xl flex items-center gap-2 hover:bg-indigo-700 transition-all shadow-lg font-bold"
>
<Plus size={20} /> Nova Turma
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{classes.map(cls => {
const studentCount = data.students.filter(s => s.classId === cls.id).length;
const occupancyPercent = Math.min(100, (studentCount / (cls.maxStudents || 30)) * 100);
const course = courses.find((c: any) => c.id === cls.courseId);
const now = new Date();
const clsLessons = (data.lessons || []).filter(l => l.classId === cls.id && l.status !== 'cancelled');
const isOngoing = clsLessons.some(l => {
if (!l.startTime || !l.endTime) return false;
const lDate = new Date(l.date + 'T12:00:00Z');
if (lDate.getDate() !== now.getDate() || lDate.getMonth() !== now.getMonth() || lDate.getFullYear() !== now.getFullYear()) return false;
const [sh, sm] = l.startTime.split(':').map(Number);
const lStart = new Date(now); lStart.setHours(sh, sm, 0, 0);
const [eh, em] = l.endTime.split(':').map(Number);
const lEnd = new Date(now); lEnd.setHours(eh, em, 0, 0);
return now >= lStart && now <= lEnd;
});
return (
<div key={cls.id} className={`bg-white p-7 rounded-xl border shadow-sm hover:shadow-xl transition-all group flex flex-col h-full ${isOngoing ? 'border-blue-400 border-b-4 border-b-blue-500 shadow-blue-100' : 'border-slate-200 border-b-4 border-b-indigo-500/20 hover:border-b-indigo-500'}`}>
<div className="flex justify-between items-start mb-5 relative">
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="text-xl font-black text-slate-900 leading-tight">{cls.name}</h3>
{isOngoing && (
<span className="px-2 py-0.5 bg-blue-600 text-white text-[9px] font-black uppercase rounded-full animate-pulse shadow-sm flex items-center gap-1">
<Clock size={10} /> Em andamento
</span>
)}
</div>
<span className="text-[10px] font-black text-indigo-600 uppercase tracking-[0.2em]">{course?.nome || course?.name || 'Sem Curso Vinculado'}</span>
{cls.defaultStartTime && cls.defaultEndTime && (
<div className="mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 bg-indigo-50 text-indigo-700 text-xs font-bold rounded-lg border border-indigo-100">
<Clock size={12} /> {cls.defaultStartTime} - {cls.defaultEndTime}
</div>
)}
</div>
<div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<button
onClick={(e) => { e.stopPropagation(); handleDownloadClassList(cls); }}
disabled={isGeneratingPDF === cls.id}
className="p-2 text-slate-400 hover:text-indigo-600 rounded-lg transition-all disabled:opacity-50 bg-slate-50 hover:bg-indigo-50"
title="Imprimir Diário"
>
{isGeneratingPDF === cls.id ? <RefreshCw size={16} className="animate-spin" /> : <Printer size={16} />}
</button>
<button onClick={(e) => { e.stopPropagation(); handleEdit(cls); }} className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-slate-50 rounded-lg transition-all" title="Editar Turma">
<Edit2 size={16} />
</button>
<button onClick={(e) => { e.stopPropagation(); handleDelete(cls.id); }} className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all" title="Excluir Turma">
<Trash2 size={16} />
</button>
</div>
</div>
<div className="space-y-3 mb-5 flex-1">
<div className="flex items-center gap-3 text-sm text-slate-600 bg-slate-50 p-3 rounded-lg">
<User size={18} className="text-indigo-500" />
<div>
<p className="text-[10px] uppercase font-bold text-slate-400">Professor</p>
<p className="font-semibold text-slate-800">{cls.teacher}</p>
</div>
</div>
<div className="flex items-center gap-3 text-sm text-slate-600 bg-slate-50 p-3 rounded-lg">
<Clock size={18} className="text-indigo-500" />
<div>
<p className="text-[10px] uppercase font-bold text-slate-400">Dias de Aula</p>
<p className="font-semibold text-slate-800 flex items-center gap-2">
{cls.scheduleDay ? DAY_NAMES[parseInt(cls.scheduleDay)] : cls.schedule}
{cls.defaultStartTime && cls.defaultEndTime && (
<span className="text-indigo-600 font-black">
{cls.defaultStartTime} às {cls.defaultEndTime}
</span>
)}
</p>
</div>
</div>
{/* Contagem de Aulas */}
{(() => {
const now = new Date();
const totalLessons = clsLessons.length;
const completedLessons = clsLessons.filter(l => {
if (l.status === 'cancelled') return false;
const lDate = new Date(l.date + 'T12:00:00Z');
if (!l.endTime) return lDate < now;
const [eh, em] = l.endTime.split(':').map(Number);
const lEnd = new Date(lDate);
lEnd.setUTCHours(eh, em, 0, 0);
return now > lEnd;
}).length;
const cancelledLessons = clsLessons.filter(l => l.status === 'cancelled').length;
const remainingLessons = totalLessons - completedLessons - cancelledLessons;
return totalLessons > 0 ? (
<div className="flex items-center gap-2 flex-wrap text-[10px] font-black">
<span className="px-2 py-1 bg-indigo-50 text-indigo-600 rounded-lg flex items-center gap-1">
<Calendar size={10} /> {totalLessons} Total
</span>
<span className="px-2 py-1 bg-emerald-50 text-emerald-600 rounded-lg">
{completedLessons} Concluídas
</span>
<span className="px-2 py-1 bg-amber-50 text-amber-600 rounded-lg">
{remainingLessons} Restantes
</span>
</div>
) : null;
})()}
</div>
<div className="space-y-2">
<div className="flex justify-between items-end text-xs font-bold text-slate-500 px-1">
<span>OCUPAÇÃO</span>
<span>{studentCount} / {cls.maxStudents}</span>
</div>
<div className="w-full bg-slate-100 h-3 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-1000 ${
occupancyPercent > 90 ? 'bg-red-500' : occupancyPercent > 50 ? 'bg-indigo-500' : 'bg-emerald-500'
}`}
style={{ width: `${occupancyPercent}%` }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-5">
<button
onClick={() => setViewingStudentsClass(cls)}
className="bg-slate-50 border border-slate-200 text-slate-700 hover:bg-slate-100 py-3 rounded-xl font-bold flex items-center justify-center gap-2 transition-all"
>
<User size={18} /> Ver Alunos
</button>
<button
onClick={() => setScheduleClass(cls)}
className="bg-indigo-50 border border-indigo-100 text-indigo-700 hover:bg-indigo-600 hover:text-white py-3 rounded-xl font-black flex items-center justify-center gap-2 transition-all shadow-sm group-hover:shadow-md"
>
<Calendar size={18} /> Cronograma
</button>
</div>
</div>
);
})}
{classes.length === 0 && (
<div className="col-span-full py-20 text-center text-slate-400 border-4 border-dashed border-slate-200 rounded-xl">
<Book size={48} className="mx-auto mb-4 opacity-10" />
<p className="font-bold text-lg">Nenhuma turma cadastrada ainda.</p>
<p className="text-sm">Vincule um curso a uma nova turma para começar.</p>
</div>
)}
</div>
{isModalOpen && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-xl w-full max-w-2xl shadow-2xl my-auto transition-all duration-400 relative overflow-hidden ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
{/* Blue Top Bar */}
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-2xl font-black text-slate-800 tracking-tight">
{editingClass ? 'Editar Turma' : 'Criar Turma'}
</h3>
<p className="text-sm text-slate-500">Selecione o curso e horários.</p>
</div>
<button onClick={closeModal} className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90">
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Nome da Turma</label>
<input required className={inputClass} placeholder="Ex: TURMA A - NOITE"
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Curso Vinculado</label>
<select required className={inputClass}
value={formData.courseId} onChange={e => setFormData({...formData, courseId: e.target.value})}>
<option value="">Selecione um curso...</option>
{courses.map((c: any) => <option key={c.id} value={c.id}>{c.nome}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Data de Início</label>
<input type="date" required className={inputClass}
value={formData.startDate} onChange={e => setFormData({...formData, startDate: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Data de Fim (Automática)</label>
<input type="date" required className={inputClass}
value={formData.endDate} onChange={e => setFormData({...formData, endDate: e.target.value})} />
</div>
</div>
<div className="grid grid-cols-3 gap-6">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Dia da Semana</label>
<select required className={inputClass}
value={formData.scheduleDay} onChange={e => setFormData({...formData, scheduleDay: e.target.value})}>
<option value="">Selecione...</option>
{DAY_NAMES.map((d, i) => <option key={i} value={i}>{d}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Início (Hora)</label>
<input type="time" required className={inputClass}
value={formData.defaultStartTime} onChange={e => setFormData({...formData, defaultStartTime: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Término (Hora)</label>
<input type="time" required className={inputClass}
value={formData.defaultEndTime} onChange={e => setFormData({...formData, defaultEndTime: e.target.value})} />
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Vagas Locais</label>
<input type="number" required className={inputClass}
value={formData.maxStudents} onChange={e => setFormData({...formData, maxStudents: parseInt(e.target.value) || 0})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Professor Responsável</label>
<select required className={inputClass}
value={formData.teacher} onChange={e => setFormData({...formData, teacher: e.target.value})}>
<option value="">Selecione um professor...</option>
{employees
.map((emp: any) => (
<option key={emp.id} value={emp.nome || emp.name}>{emp.nome || emp.name}</option>
))}
{formData.teacher && !employees.some((e: any) => (e.nome || e.name) === formData.teacher) && (
<option value={formData.teacher}>{formData.teacher} (Manual)</option>
)}
</select>
</div>
</div>
<div className="pt-4 flex gap-4 border-t border-slate-100">
<button type="button" onClick={closeModal} className="flex-1 px-6 py-4 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 transition-colors font-bold">
Cancelar
</button>
<button type="submit" className="flex-1 px-6 py-4 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition-all shadow-lg font-bold">
{editingClass ? 'Atualizar e Sincronizar Calendário' : 'Criar Turma e Gerar Calendário'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Lesson Schedule Modal */}
{scheduleClass && (
<LessonSchedule
classObj={scheduleClass}
data={data}
updateData={updateData}
onClose={() => setScheduleClass(null)}
/>
)}
{/* Viewing Students Modal */}
{viewingStudentsClass && (
<div className="fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto animate-in fade-in">
<div className="bg-white rounded-2xl w-full max-w-2xl shadow-2xl my-auto relative overflow-hidden animate-slide-up">
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-2xl font-black text-slate-800 tracking-tight">Alunos da Turma</h3>
<p className="text-sm text-slate-500 mt-1">{viewingStudentsClass.name} {data.students.filter(s => s.classId === viewingStudentsClass.id).length} alunos matriculados</p>
</div>
<button
onClick={() => setViewingStudentsClass(null)}
className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-lg shadow-sm transition-all hover:rotate-90"
>
<X size={24} />
</button>
</div>
<div className="p-4 max-h-[60vh] overflow-y-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-100 text-[10px] uppercase text-slate-400 font-black tracking-widest">
<th className="p-4">Aluno</th>
<th className="p-4">Idade</th>
<th className="p-4 text-right">Ação</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{data.students
.filter(s => s.classId === viewingStudentsClass.id)
.sort((a,b) => (a.name || '').localeCompare(b.name || ''))
.map(student => (
<tr key={student.id} className="hover:bg-slate-50 transition-colors group">
<td className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-100 overflow-hidden border border-slate-200">
{student.photo ? (
<img src={normalizePhotoUrl(student.photo)} alt={student.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-slate-300">
<User size={20} />
</div>
)}
</div>
<div>
<p className="font-bold text-slate-800 text-sm">{student.name}</p>
<p className="text-[10px] text-slate-400 font-medium uppercase tracking-wider">{student.enrollmentNumber || 'Sem matrícula'}</p>
</div>
</div>
</td>
<td className="p-4 text-sm font-bold text-slate-600">
{calculateAge(student.birthDate) !== null ? `${calculateAge(student.birthDate)} anos` : '-'}
</td>
<td className="p-4 text-right">
<button
onClick={() => onNavigateToClass(viewingStudentsClass.id, student.id)}
className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg text-xs font-bold hover:bg-indigo-600 hover:text-white transition-all shadow-sm"
>
Ver Perfil
</button>
</td>
</tr>
))}
{data.students.filter(s => s.classId === viewingStudentsClass.id).length === 0 && (
<tr>
<td colSpan={3} className="p-10 text-center text-slate-400 italic text-sm">Nenhum aluno matriculado nesta turma.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end">
<button
onClick={() => setViewingStudentsClass(null)}
className="px-6 py-2.5 bg-white border border-slate-200 text-slate-600 font-bold hover:bg-slate-50 rounded-xl transition-colors shadow-sm"
>
Fechar
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Classes;

View File

@ -0,0 +1,623 @@
import React, { useState, useEffect } from 'react';
import { SchoolData, Contract, Student, Payment } from '../types';
import { useDialog } from '../DialogContext';
import { Plus, Search, Trash2, X, User, Calendar, FileSignature, ListChecks, Printer, AlertTriangle, RefreshCw, Edit2, Info } from 'lucide-react';
import { pdfService } from '../services/pdfService';
interface ContractsProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [dbClasses, setDbClasses] = useState<any[]>(data?.classes || []);
const [dbCourses, setDbCourses] = useState<any[]>(data?.courses || []);
React.useEffect(() => {
Promise.all([
fetch('/api/turmas').catch(() => ({ ok: false, json: async () => ({}) })),
fetch('/api/cursos').catch(() => ({ ok: false, json: async () => ({}) })),
]).then(async (responses) => {
const [resT, resC] = responses;
if (resT && resT.ok) {
const jsonT = await resT.json();
if (jsonT.turmas) setDbClasses(jsonT.turmas.map((t: any) => ({
id: t.id, name: t.nome, courseId: t.curso_id, maxStudents: Number(t.max_alunos || 0)
})));
}
if (resC && resC.ok) {
const jsonC = await resC.json();
if (jsonC.cursos) setDbCourses(jsonC.cursos.map((c: any) => ({
id: c.id, name: c.nome, monthlyFee: Number(c.mensalidade || 0), registrationFee: Number(c.taxa_matricula || 0)
})));
}
}).catch(console.error);
}, []);
const [dbContracts, setDbContracts] = useState<Contract[]>([]);
const [dbTemplates, setDbTemplates] = useState<any[]>([]);
const loadData = async () => {
try {
const [resC, resT] = await Promise.all([
fetch(`/api/contratos?t=${Date.now()}`),
fetch(`/api/modelos-contrato?t=${Date.now()}`)
]);
if (resC.ok) {
const json = await resC.json();
setDbContracts(json.contratos || []);
}
if (resT.ok) {
const json = await resT.json();
setDbTemplates(json.modelos || []);
}
} catch(e) { console.error(e); }
};
useEffect(() => { loadData(); }, []);
const [activeTab, setActiveTab] = useState<'contracts' | 'templates'>('contracts');
const [isModalOpen, setIsModalOpen] = useState(false);
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [isGeneratingPDF, setIsGeneratingPDF] = useState<string | null>(null);
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [contractToGenerate, setContractToGenerate] = useState<Contract | null>(null);
const [genConfig, setGenConfig] = useState({
startDate: new Date().toLocaleDateString('pt-BR'),
installments: 12,
discount: 0
});
const [formData, setFormData] = useState<Omit<Contract, 'id' | 'createdAt'>>({
studentId: '',
title: '',
content: ''
});
const [templateFormData, setTemplateFormData] = useState({
id: '',
name: '',
content: ''
});
const generateContractContent = (student: any, templateContent: string) => {
const cls = dbClasses.find(c => c.id === student?.classId);
const course = dbCourses.find(c => c.id === cls?.courseId);
if (!student || !course) return templateContent;
let template = templateContent;
// Aluno
template = template.replace(/{{aluno}}/g, student.name || '');
template = template.replace(/{{aluno_cpf}}/g, student.cpf || '');
template = template.replace(/{{aluno_rg}}/g, student.rg || '');
template = template.replace(/{{aluno_nascimento}}/g, student.birthDate ? new Date(student.birthDate).toLocaleDateString('pt-BR') : '');
template = template.replace(/{{aluno_email}}/g, student.email || '');
template = template.replace(/{{aluno_telefone}}/g, student.phone || '');
template = template.replace(/{{aluno_cep}}/g, student.addressZip || '');
template = template.replace(/{{aluno_endereco}}/g, `${student.addressStreet || ''}, ${student.addressNumber || ''}`);
template = template.replace(/{{aluno_bairro}}/g, student.addressNeighborhood || '');
template = template.replace(/{{aluno_cidade}}/g, student.addressCity || '');
template = template.replace(/{{aluno_estado}}/g, student.addressState || '');
// Responsável
template = template.replace(/{{responsavel_nome}}/g, student.guardianName || '');
template = template.replace(/{{responsavel_cpf}}/g, student.guardianCpf || '');
template = template.replace(/{{responsavel_nascimento}}/g, student.guardianBirthDate ? new Date(student.guardianBirthDate).toLocaleDateString('pt-BR') : '');
// Curso e Turma
template = template.replace(/{{curso}}/g, course.name || '');
template = template.replace(/{{mensalidade}}/g, course.monthlyFee ? `R$ ${course.monthlyFee.toFixed(2)}` : 'R$ 0,00');
template = template.replace(/{{duracao}}/g, course.duration || '');
template = template.replace(/{{curso_taxa_matricula}}/g, course.registrationFee ? `R$ ${course.registrationFee.toFixed(2)}` : 'R$ 0,00');
template = template.replace(/{{turma_nome}}/g, cls?.name || '');
template = template.replace(/{{turma_professor}}/g, cls?.teacher || '');
template = template.replace(/{{turma_horario}}/g, cls?.schedule || '');
// Escola
template = template.replace(/{{data}}/g, new Date().toLocaleDateString('pt-BR'));
template = template.replace(/{{escola}}/g, data.profile.name || '');
template = template.replace(/{{cnpj_escola}}/g, data.profile.cnpj || '');
return template;
};
// Pre-load content when student is selected based on template
useEffect(() => {
if (formData.studentId && !formData.content) {
const student = data.students.find(s => s.id === formData.studentId);
const templateObj = dbTemplates?.find(t => t.id === student?.contractTemplateId);
if (student) {
const finalContent = generateContractContent(student, templateObj?.content || '');
setFormData(prev => ({
...prev,
content: finalContent,
title: prev.title || `Contrato de Matrícula - ${student.name}`
}));
}
}
}, [formData.studentId, data]);
const filteredContracts = dbContracts.filter(c => {
const student = data.students.find(s => s.id === c.studentId);
const search = (searchTerm || '').toLowerCase();
return (c.title || '').toLowerCase().includes(search) || (student?.name || '').toLowerCase().includes(search);
});
const filteredTemplates = dbTemplates.filter(t =>
(t.name || '').toLowerCase().includes((searchTerm || '').toLowerCase())
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.studentId || !formData.title || !formData.content) {
showAlert('Atenção', '⚠️ Por favor, selecione um aluno e preencha o título e conteúdo do contrato.', 'warning');
return;
}
if ((formData as any).id) {
// Editar individual
fetch(`/api/contratos/${(formData as any).id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
}).then(() => loadData());
closeModal();
return;
}
const newContract: Contract = {
...formData,
id: crypto.randomUUID(),
createdAt: new Date().toISOString()
};
fetch('/api/contratos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newContract)
}).then(() => loadData());
closeModal();
};
const handleTemplateSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!templateFormData.name || !templateFormData.content) {
showAlert('Atenção', '⚠️ Preencha o nome e o conteúdo do modelo.', 'warning');
return;
}
const templates = dbTemplates || [];
let updatedTemplates;
if (templateFormData.id) {
updatedTemplates = templates.map(t => t.id === templateFormData.id ? templateFormData : t);
} else {
updatedTemplates = [...templates, { ...templateFormData, id: crypto.randomUUID() }];
}
if (templateFormData.id) {
fetch('/api/modelos-contrato/' + templateFormData.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(templateFormData)
}).then(async () => {
// Propagar para todos os alunos que usam este template e já tem contrato gerado
const affectedStudents = data.students.filter(s => s.contractTemplateId === templateFormData.id);
const promises = [];
affectedStudents.forEach(student => {
const existingContract = dbContracts.find(c => c.studentId === student.id);
if (existingContract) {
const newContent = generateContractContent(student, templateFormData.content);
promises.push(
fetch(`/api/contratos/${existingContract.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: existingContract.title, content: newContent })
})
);
}
});
if (promises.length > 0) {
await Promise.all(promises);
}
loadData();
});
} else {
fetch('/api/modelos-contrato', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...templateFormData, id: updatedTemplates.find(t=>t.name===templateFormData.name)?.id || crypto.randomUUID() })
}).then(() => loadData());
}
closeTemplateModal();
};
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setIsModalOpen(false);
setShowGenerateModal(false);
setIsClosing(false);
setFormData({ studentId: '', title: '', content: '' });
setContractToGenerate(null);
}, 400);
};
const closeTemplateModal = () => {
setIsClosing(true);
setTimeout(() => {
setIsTemplateModalOpen(false);
setIsClosing(false);
setTemplateFormData({ id: '', name: '', content: '' });
}, 400);
};
const handleDelete = (id: string) => {
showConfirm(
'Excluir Contrato',
'Tem certeza que deseja excluir este contrato?',
() => {
fetch(`/api/contratos/${id}`, { method: 'DELETE' }).then(() => loadData());
}
);
};
const handleDeleteTemplate = (id: string) => {
showConfirm(
'Excluir Modelo',
'Tem certeza que deseja excluir este modelo de contrato?',
() => {
fetch(`/api/modelos-contrato/${id}`, { method: 'DELETE' }).then(() => loadData());
}
);
};
const handleDownloadContract = async (contract: Contract, student: Student) => {
setIsGeneratingPDF(contract.id);
try {
await pdfService.generateContractPDF(contract, student, data);
} catch (error) {
console.error('Error generating PDF:', error);
} finally {
setIsGeneratingPDF(null);
}
};
const openGenerateModal = (contract: Contract) => {
const student = data.students.find(s => s.id === contract.studentId);
const cls = dbClasses.find(c => c.id === student?.classId);
const course = dbCourses.find(c => c.id === cls?.courseId);
if (student && cls && course) {
setContractToGenerate(contract);
setGenConfig({
startDate: new Date().toLocaleDateString('pt-BR'),
installments: course.durationMonths || 12,
discount: 0
});
setShowGenerateModal(true);
} else {
console.warn("Missing data for generation");
}
};
const handleGenerate = async () => {
if (!contractToGenerate) return;
const contract = contractToGenerate;
const student = data.students.find(s => s.id === contract.studentId);
const cls = dbClasses.find(c => c.id === student?.classId);
const course = dbCourses.find(c => c.id === cls?.courseId);
if (!course || !student) return;
// Parse date from DD/MM/YYYY
const [d, m, y] = genConfig.startDate.split('/');
const startDate = new Date(`${y}-${m}-${d}`);
const newPayments: Payment[] = [];
for (let i = 0; i < genConfig.installments; i++) {
const dueDate = new Date(startDate);
dueDate.setMonth(startDate.getMonth() + i);
const finalAmount = Math.max(0, course.monthlyFee - genConfig.discount);
newPayments.push({
id: crypto.randomUUID(),
studentId: student.id,
contractId: contract.id,
amount: finalAmount,
discount: genConfig.discount,
dueDate: dueDate.toISOString().split('T')[0],
status: 'pending',
type: 'monthly',
installmentNumber: i + 1,
totalInstallments: genConfig.installments,
description: `Parcela ${i + 1}/${genConfig.installments} - ${course.name}${genConfig.discount > 0 ? ` (Desc: R$ ${genConfig.discount})` : ''}`
});
}
// 1. Salvar no JSON (manter compatibilidade)
updateData({ payments: [...data.payments, ...newPayments] });
// 2. Fase 2: Salvar no SQL via sincronização no próximo boot
// Nota: Parcelas de contrato sem Asaas serão migradas pelo syncJsonToRelationalTables no próximo restart.
// A Fase 3 eliminará essa dependência quando o Manager ler direto do SQL.
closeModal();
};
const formatDateMask = (val: string) => {
return val.replace(/\D/g, '').replace(/(\d{2})(\d)/, '$1/$2').replace(/(\d{2})(\d)/, '$1/$2').slice(0, 10);
};
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm";
return (
<div className="space-y-6 animate-in fade-in duration-300">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Contratos</h2>
<p className="text-slate-500 text-sm">Gestão de termos de adesão e modelos de contrato.</p>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<button
onClick={() => {
setTemplateFormData({ id: '', name: '', content: '' });
setIsTemplateModalOpen(true);
}}
className="flex-1 sm:flex-none bg-white border border-slate-200 text-slate-700 px-6 py-3 rounded-xl flex items-center justify-center gap-2 hover:bg-slate-50 transition-all shadow-sm font-bold"
>
<Plus size={20} /> Novo Modelo
</button>
<button
onClick={() => setIsModalOpen(true)}
className="flex-1 sm:flex-none bg-indigo-600 text-white px-6 py-3 rounded-xl flex items-center justify-center gap-2 hover:bg-indigo-700 transition-all shadow-lg font-bold"
>
<Plus size={20} /> Novo Contrato
</button>
</div>
</div>
<div className="flex border-b border-slate-200">
<button
onClick={() => setActiveTab('contracts')}
className={`px-6 py-3 font-bold text-sm transition-all border-b-2 ${activeTab === 'contracts' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
Contratos Emitidos
</button>
<button
onClick={() => setActiveTab('templates')}
className={`px-6 py-3 font-bold text-sm transition-all border-b-2 ${activeTab === 'templates' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
Modelos de Contrato
</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 shadow-xl overflow-hidden">
<div className="p-6 border-b border-slate-100 bg-slate-50/30">
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input type="text" placeholder={activeTab === 'contracts' ? "Buscar por título ou aluno..." : "Buscar por nome do modelo..."} className={`${inputClass} pl-12`} value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
</div>
</div>
{activeTab === 'contracts' ? (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase font-bold tracking-wider">
<tr>
<th className="px-6 py-4">Documento / Beneficiário</th>
<th className="px-6 py-4">Data Emissão</th>
<th className="px-6 py-4 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredContracts.map(contract => {
const student = data.students.find(s => s.id === contract.studentId);
const hasPayments = data.payments.some(p => p.contractId === contract.id);
return (
<tr key={contract.id} className="hover:bg-slate-50/50 transition-colors group">
<td className="px-6 py-5">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-xl ${hasPayments ? 'bg-emerald-50 text-emerald-600' : 'bg-indigo-50 text-indigo-600'}`}><FileSignature size={24} /></div>
<div>
<div className="font-bold text-slate-900">{contract.title}</div>
<div className="text-xs text-slate-500 flex items-center gap-1 mt-1"><User size={12} /> {student?.name || 'Aluno Removido'} {hasPayments && <span className="ml-2 text-emerald-600 font-bold flex items-center gap-1"><ListChecks size={12}/> Financeiro Gerado</span>}</div>
</div>
</div>
</td>
<td className="px-6 py-5 text-slate-600 text-sm font-medium"><div className="flex items-center gap-2"><Calendar size={16} className="text-slate-400" /> {new Date(contract.createdAt).toLocaleDateString('pt-BR')}</div></td>
<td className="px-6 py-5 text-right flex justify-end gap-2">
<button
onClick={() => handleDownloadContract(contract, student!)}
disabled={isGeneratingPDF === contract.id}
className="p-3 bg-white border border-slate-200 text-slate-400 hover:text-indigo-600 rounded-xl transition-all shadow-sm disabled:opacity-50"
title="Imprimir Contrato"
>
{isGeneratingPDF === contract.id ? (
<RefreshCw size={20} className="animate-spin" />
) : (
<Printer size={20} />
)}
</button>
<button
onClick={() => {
setFormData({ studentId: contract.studentId, title: contract.title, content: contract.content, ...( { id: contract.id } as any ) });
setIsModalOpen(true);
}}
className="p-3 bg-white border border-slate-200 text-slate-400 hover:text-blue-600 rounded-xl transition-all shadow-sm"
title="Editar Contrato"
>
<Edit2 size={20} />
</button>
<button onClick={() => handleDelete(contract.id)} className="p-3 bg-white border border-slate-200 text-slate-400 hover:text-red-600 rounded-xl transition-all shadow-sm" title="Excluir"><Trash2 size={20} /></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase font-bold tracking-wider">
<tr>
<th className="px-6 py-4">Nome do Modelo</th>
<th className="px-6 py-4 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredTemplates.map(template => (
<tr key={template.id} className="hover:bg-slate-50/50 transition-colors group">
<td className="px-6 py-5">
<div className="flex items-center gap-4">
<div className="p-3 rounded-xl bg-indigo-50 text-indigo-600"><FileSignature size={24} /></div>
<div className="font-bold text-slate-900">{template.name}</div>
</div>
</td>
<td className="px-6 py-5 text-right flex justify-end gap-2">
<button
onClick={() => {
setTemplateFormData(template);
setIsTemplateModalOpen(true);
}}
className="p-3 bg-white border border-slate-200 text-slate-400 hover:text-indigo-600 rounded-xl transition-all shadow-sm"
title="Editar Modelo"
>
<Edit2 size={20} />
</button>
<button onClick={() => handleDeleteTemplate(template.id)} className="p-3 bg-white border border-slate-200 text-slate-400 hover:text-red-600 rounded-xl transition-all shadow-sm" title="Excluir"><Trash2 size={20} /></button>
</td>
</tr>
))}
{filteredTemplates.length === 0 && (
<tr>
<td colSpan={2} className="px-6 py-10 text-center text-slate-400 italic">Nenhum modelo de contrato encontrado.</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
{/* CREATE CONTRACT MODAL */}
{isModalOpen && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-2xl w-full max-w-2xl overflow-hidden shadow-2xl transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="h-2 bg-indigo-600 w-full"></div>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div><h3 className="text-2xl font-black text-slate-800 tracking-tight">Criar Contrato Manual</h3><p className="text-sm text-slate-500">O conteúdo será preenchido pelo modelo vinculado ao aluno.</p></div>
<button onClick={closeModal} className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90"><X size={24} /></button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<select required className={inputClass} value={formData.studentId} onChange={e => setFormData({...formData, studentId: e.target.value})}>
<option value="">Selecione o Aluno...</option>
{data.students.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<input required placeholder="Título do Documento" className={inputClass} value={formData.title} onChange={e => setFormData({...formData, title: e.target.value})} />
</div>
<textarea required rows={10} placeholder="Conteúdo do Contrato..." className={`${inputClass} font-serif text-sm leading-relaxed resize-none`} value={formData.content} onChange={e => setFormData({...formData, content: e.target.value})} />
<div className="pt-4 flex gap-4">
<button type="button" onClick={closeModal} className="flex-1 px-6 py-4 border border-slate-200 rounded-xl text-slate-600 font-bold">Cancelar</button>
<button type="submit" className="flex-1 px-6 py-4 bg-indigo-600 text-white rounded-xl shadow-lg font-bold">Salvar Contrato</button>
</div>
</form>
</div>
</div>
)}
{/* CREATE/EDIT TEMPLATE MODAL */}
{isTemplateModalOpen && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-2xl w-full max-w-3xl overflow-hidden shadow-2xl transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="h-2 bg-indigo-600 w-full"></div>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div><h3 className="text-2xl font-black text-slate-800 tracking-tight">{templateFormData.id ? 'Editar Modelo' : 'Novo Modelo de Contrato'}</h3><p className="text-sm text-slate-500">Defina as cláusulas e use placeholders para dados dinâmicos.</p></div>
<button onClick={closeTemplateModal} className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90"><X size={24} /></button>
</div>
<form onSubmit={handleTemplateSubmit} className="p-8 space-y-5">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Nome do Modelo</label>
<input required placeholder="Ex: Contrato de Matrícula Padrão" className={inputClass} value={templateFormData.name} onChange={e => setTemplateFormData({...templateFormData, name: e.target.value})} />
</div>
<div className="bg-amber-50 border border-amber-100 p-4 rounded-lg flex gap-3">
<Info className="text-amber-500 shrink-0" size={20} />
<div className="text-xs text-amber-800 space-y-2">
<p className="font-bold">Placeholders Disponíveis:</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
<p><code className="bg-white px-1 rounded">{"{{aluno}}"}</code>, <code className="bg-white px-1 rounded">{"{{aluno_cpf}}"}</code>, <code className="bg-white px-1 rounded">{"{{aluno_rg}}"}</code></p>
<p><code className="bg-white px-1 rounded">{"{{responsavel_nome}}"}</code>, <code className="bg-white px-1 rounded">{"{{responsavel_cpf}}"}</code></p>
<p><code className="bg-white px-1 rounded">{"{{curso}}"}</code>, <code className="bg-white px-1 rounded">{"{{mensalidade}}"}</code>, <code className="bg-white px-1 rounded">{"{{duracao}}"}</code></p>
<p><code className="bg-white px-1 rounded">{"{{escola}}"}</code>, <code className="bg-white px-1 rounded">{"{{cnpj_escola}}"}</code>, <code className="bg-white px-1 rounded">{"{{data}}"}</code></p>
</div>
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 ml-1">Conteúdo do Contrato</label>
<textarea required rows={12} placeholder="Digite as cláusulas do contrato..." className={`${inputClass} font-serif text-sm leading-relaxed resize-none`} value={templateFormData.content} onChange={e => setTemplateFormData({...templateFormData, content: e.target.value})} />
</div>
<div className="pt-4 flex gap-4">
<button type="button" onClick={closeTemplateModal} className="flex-1 px-6 py-4 border border-slate-200 rounded-xl text-slate-600 font-bold">Cancelar</button>
<button type="submit" className="flex-1 px-6 py-4 bg-indigo-600 text-white rounded-xl shadow-lg font-bold">Salvar Modelo</button>
</div>
</form>
</div>
</div>
)}
{/* GENERATE INSTALLMENTS MODAL */}
{showGenerateModal && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-2xl w-full max-w-md overflow-hidden shadow-2xl transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="h-2 bg-emerald-600 w-full"></div>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-emerald-50">
<div><h3 className="text-xl font-black text-slate-800">Gerar Financeiro</h3></div>
<button onClick={closeModal} className="p-2 text-slate-400 hover:text-slate-600"><X size={20}/></button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Data 1ª Parcela</label>
<input className={inputClass} value={genConfig.startDate} onChange={e => setGenConfig({...genConfig, startDate: formatDateMask(e.target.value)})} placeholder="DD/MM/AAAA" maxLength={10} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Qtd. Parcelas</label>
<input type="number" className={inputClass} value={genConfig.installments} onChange={e => setGenConfig({...genConfig, installments: parseInt(e.target.value) || 0})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Desconto Mensal (R$)</label>
<input type="number" className={inputClass} value={genConfig.discount} onChange={e => setGenConfig({...genConfig, discount: parseFloat(e.target.value)})} />
</div>
<div className="pt-2 flex gap-3">
<button onClick={closeModal} className="flex-1 py-3 border border-slate-200 rounded-lg font-bold text-slate-500 hover:bg-slate-50">Cancelar</button>
<button onClick={handleGenerate} className="flex-1 py-3 bg-emerald-600 text-white rounded-lg font-bold hover:bg-emerald-700">Confirmar</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default Contracts;

View File

@ -0,0 +1,294 @@
import React, { useState, useEffect } from 'react';
import { SchoolData, Course } from '../types';
import { useDialog } from '../DialogContext';
import { Plus, Edit2, Trash2, X, Clock, DollarSign, BookText, Info, AlertTriangle } from 'lucide-react';
interface CoursesProps {
data: SchoolData; // mantido para classes e afins
updateData: (newData: Partial<SchoolData>) => void;
}
const Courses: React.FC<CoursesProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
const [formData, setFormData] = useState<Omit<Course, 'id'>>({
name: '',
duration: '',
durationMonths: 12, // Default value
registrationFee: 0,
monthlyFee: 0,
description: '',
finePercentage: 0,
interestPercentage: 0
});
const extractMonths = (text: string): number => {
const match = text.match(/\d+/);
return match ? parseInt(match[0]) : 12;
};
const [courses, setCourses] = useState<Course[]>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
const loadData = async () => {
try {
setIsLoadingData(true);
const res = await fetch('/api/cursos');
const json = await res.json();
const mappedCourses = (json.cursos || []).map((c: any) => ({
id: c.id,
name: c.nome,
duration: c.duracao,
durationMonths: c.duracao_meses,
registrationFee: Number(c.taxa_matricula || 0),
monthlyFee: Number(c.mensalidade || 0),
description: c.descricao,
finePercentage: Number(c.multa_percentual || 0),
interestPercentage: Number(c.juros_percentual || 0)
}));
setCourses(mappedCourses);
} catch (err) {
console.error('Erro ao buscar cursos:', err);
showAlert('Erro', 'Falha ao carregar cursos do servidor.', 'error');
} finally {
setIsLoadingData(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.duration || formData.monthlyFee <= 0) {
showAlert('Atenção', '⚠️ Por favor, preencha o nome, duração e valor da mensalidade.', 'warning');
return;
}
// Auto-calculate months from text if possible, otherwise keep default or existing
const calculatedMonths = extractMonths(formData.duration);
const finalData = {
...formData,
durationMonths: calculatedMonths
};
const payload = {
nome: finalData.name,
duracao: finalData.duration,
duracao_meses: finalData.durationMonths,
taxa_matricula: finalData.registrationFee,
mensalidade: finalData.monthlyFee,
descricao: finalData.description,
multa_percentual: finalData.finePercentage,
juros_percentual: finalData.interestPercentage
};
try {
const finalCourseId = editingCourse ? editingCourse.id : crypto.randomUUID();
const finalCourse: Course = {
...finalData,
id: finalCourseId
};
if (editingCourse) {
const res = await fetch(`/api/cursos/${editingCourse.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Failed');
} else {
const res = await fetch('/api/cursos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, id: finalCourseId })
});
if (!res.ok) throw new Error('Failed');
}
// Sincroniza a lista global de cursos para o JSON
let updatedCourses = [...(data.courses || [])];
if (editingCourse) {
updatedCourses = updatedCourses.map(c => c.id === editingCourse.id ? finalCourse : c);
} else {
updatedCourses.push(finalCourse);
}
updateData({ courses: updatedCourses });
await loadData();
closeModal();
} catch (err) {
showAlert('Erro', 'Ocorreu um erro ao salvar o curso.', 'error');
}
};
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setIsModalOpen(false);
setIsClosing(false);
setEditingCourse(null);
setFormData({ name: '', duration: '', durationMonths: 12, registrationFee: 0, monthlyFee: 0, description: '', finePercentage: 0, interestPercentage: 0 });
}, 400);
};
const handleEdit = (course: Course) => {
setEditingCourse(course);
setFormData({
name: course.name || '',
duration: course.duration || '',
durationMonths: course.durationMonths || 12,
registrationFee: course.registrationFee || 0,
monthlyFee: course.monthlyFee || 0,
description: course.description || '',
finePercentage: course.finePercentage || 0,
interestPercentage: course.interestPercentage || 0
});
setIsModalOpen(true);
};
const checkAndDelete = (id: string) => {
const hasClasses = data.classes.some(c => c.courseId === id);
if (hasClasses) {
showAlert('Atenção', 'Não é possível excluir um curso que possui turmas vinculadas.', 'warning');
return;
}
showConfirm(
'Excluir Curso',
'Tem certeza que deseja excluir este curso?',
async () => {
try {
const res = await fetch(`/api/cursos/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
// Sincroniza a remoção do curso no estado global/JSON
const updatedCourses = (data.courses || []).filter(c => c.id !== id);
updateData({ courses: updatedCourses });
await loadData();
} catch (err) {
showAlert('Erro', 'Ocorreu um erro ao excluir o curso.', 'error');
}
}
);
};
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm";
return (
<div className="space-y-6 animate-in fade-in duration-300">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Cursos <span className="text-sm font-normal text-green-600 bg-green-50 px-2 py-1 rounded-md ml-2 border border-green-200">PostgreSQL</span></h2>
<p className="text-slate-500">Gerencie os cursos oferecidos pela escola.</p>
</div>
<button onClick={() => setIsModalOpen(true)} className="bg-indigo-600 text-white px-6 py-3 rounded-xl flex items-center gap-2 hover:bg-indigo-700 transition-all shadow-lg font-bold"><Plus size={20} /> Novo Curso</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map(course => (
<div key={course.id} className="bg-white p-7 rounded-xl border border-slate-200 shadow-sm hover:shadow-xl transition-all group flex flex-col h-full relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 bg-white/80 backdrop-blur-sm rounded-bl-2xl">
<button onClick={() => handleEdit(course)} className="p-2 text-slate-400 hover:text-indigo-600 rounded-lg hover:bg-indigo-50 transition-all"><Edit2 size={16} /></button>
<button onClick={() => checkAndDelete(course.id)} className="p-2 text-slate-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-all"><Trash2 size={16} /></button>
</div>
<div className="mb-6">
<div className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center text-indigo-600 mb-4">
<BookText size={24} />
</div>
<h3 className="text-xl font-black text-slate-900 leading-tight mb-2">{course.name}</h3>
<p className="text-sm text-slate-500 line-clamp-2">{course.description || 'Sem descrição definida.'}</p>
</div>
<div className="space-y-3 mt-auto">
<div className="flex items-center gap-3 text-sm text-slate-600 bg-slate-50 p-3 rounded-lg">
<Clock size={18} className="text-indigo-500" />
<div>
<p className="text-[10px] uppercase font-bold text-slate-400">Duração</p>
<p className="font-semibold text-slate-800">{course.duration}</p>
</div>
</div>
<div className="flex items-center gap-3 text-sm text-slate-600 bg-slate-50 p-3 rounded-lg">
<DollarSign size={18} className="text-emerald-500" />
<div>
<p className="text-[10px] uppercase font-bold text-slate-400">Investimento Mensal</p>
<p className="font-semibold text-slate-800">R$ {course.monthlyFee.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</p>
</div>
</div>
</div>
</div>
))}
</div>
{isModalOpen && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-xl w-full max-w-2xl shadow-2xl my-auto transition-all duration-400 relative overflow-hidden ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
{/* Blue Top Bar */}
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div><h3 className="text-2xl font-black text-slate-800 tracking-tight">{editingCourse ? 'Editar Curso' : 'Novo Curso'}</h3><p className="text-sm text-slate-500">Defina os detalhes e valores do curso.</p></div>
<button onClick={closeModal} className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90"><X size={24} /></button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Nome do Curso</label>
<input required className={inputClass} placeholder="Ex: Informática Básica" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Descrição</label>
<textarea rows={3} className={inputClass} placeholder="Breve resumo do conteúdo..." value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} />
</div>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Duração</label>
<input required className={inputClass} placeholder="Ex: 12 meses" value={formData.duration} onChange={e => setFormData({...formData, duration: e.target.value})} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Taxa de Matrícula (R$)</label>
<input type="number" required min="0" step="0.01" className={inputClass} value={formData.registrationFee} onChange={e => setFormData({...formData, registrationFee: parseFloat(e.target.value)})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Mensalidade (R$)</label>
<input type="number" required min="0" step="0.01" className={inputClass} value={formData.monthlyFee} onChange={e => setFormData({...formData, monthlyFee: parseFloat(e.target.value)})} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Multa por Atraso (%)</label>
<input type="number" min="0" step="0.01" className={inputClass} value={formData.finePercentage} onChange={e => setFormData({...formData, finePercentage: parseFloat(e.target.value) || 0})} />
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Juros ao Mês (%)</label>
<input type="number" min="0" step="0.01" className={inputClass} value={formData.interestPercentage} onChange={e => setFormData({...formData, interestPercentage: parseFloat(e.target.value) || 0})} />
</div>
</div>
</div>
<div className="pt-4 flex gap-4">
<button type="button" onClick={closeModal} className="flex-1 px-6 py-4 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 transition-colors font-bold">Cancelar</button>
<button type="submit" className="flex-1 px-6 py-4 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition-all shadow-lg font-bold">Salvar Curso</button>
</div>
</form>
</div>
</div>
)}
{/* DELETE CONFIRM MODAL */}
</div>
);
};
export default Courses;

View File

@ -0,0 +1,743 @@
import React, { useState, useMemo } from 'react';
import { SchoolData, Student, Payment, Class, View } from '../types';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
PieChart,
Pie,
AreaChart,
Area,
Legend,
LineChart,
Line
} from 'recharts';
import {
Users,
BookOpen,
Wallet,
Clock,
FileDown,
RefreshCw,
TrendingUp,
UserPlus,
CheckCircle2,
AlertCircle,
Calendar,
ChevronRight,
Layout,
ClipboardPen,
X
} from 'lucide-react';
import { pdfService } from '../services/pdfService';
interface DashboardProps {
data: SchoolData;
setView?: (view: View) => void;
}
const Dashboard: React.FC<DashboardProps> = ({ data, setView }) => {
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const [dashboardView, setDashboardView] = useState<'standard' | 'detailed'>('standard');
const [dbClasses, setDbClasses] = useState<any[]>(data.classes || []);
const [prematriculas, setPrematriculas] = useState<any[]>([]);
const [showLogsModal, setShowLogsModal] = useState(false);
React.useEffect(() => {
fetch('/api/turmas')
.then(res => res.json())
.then(json => {
if (json.turmas) {
setDbClasses(json.turmas.map((t: any) => ({
id: t.id,
name: t.nome,
courseId: t.curso_id,
maxStudents: Number(t.max_alunos || 0)
})));
}
})
.catch(console.error);
fetch('/api/prematricula/inscricoes')
.then(res => res.json())
.then(json => {
if (json.inscricoes) {
setPrematriculas(json.inscricoes);
}
})
.catch(console.error);
}, []);
// Basic Stats
const activeStudents = useMemo(() => data.students.filter(s => s.status === 'active').length, [data.students]);
const totalClasses = useMemo(() => dbClasses.length, [dbClasses]);
const pendingPayments = useMemo(() => data.payments.filter(p => p.status === 'pending').length, [data.payments]);
const revenue = useMemo(() => data.payments
.filter(p => p.status === 'paid')
.reduce((sum, p) => sum + (Number((p as any).valor_pago) || (Number(p.amount) - (Number(p.discount) || 0))), 0), [data.payments]);
// Advanced Stats
const newStudentsThisMonth = useMemo(() => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return data.students.filter(s => new Date(s.registrationDate) >= startOfMonth).length;
}, [data.students]);
const attendanceRate = useMemo(() => {
if (!data.attendance || data.attendance.length === 0) return 0;
const presents = data.attendance.filter(a => a.type === 'presence').length;
return Math.round((presents / data.attendance.length) * 100);
}, [data.attendance]);
const averagePaymentValue = useMemo(() => {
if (data.payments.length === 0) return 0;
const total = data.payments.reduce((sum, p) => sum + p.amount, 0);
return Math.round(total / data.payments.length);
}, [data.payments]);
const aulasARepor = useMemo(() => {
if (!data.lessons) return 0;
const cancelled = data.lessons.filter(l => l.status === 'cancelled');
return cancelled.filter(c => !data.lessons!.some(l => l.originalLessonId === c.id)).length;
}, [data.lessons]);
// Chart Data: Class Occupancy
const classOccupancy = useMemo(() => dbClasses.map(c => ({
name: c.name,
students: data.students.filter(s => s.classId === c.id).length,
capacity: c.maxStudents || 20 // Usando a capacidade real se disponível
})).sort((a, b) => b.students - a.students), [dbClasses, data.students]);
// Chart Data: Payment Status
const paymentStatus = useMemo(() => [
{ name: 'Pago', value: data.payments.filter(p => p.status === 'paid').length, color: '#10b981' },
{ name: 'Pendente', value: data.payments.filter(p => p.status === 'pending').length, color: '#f59e0b' },
{ name: 'Atrasado', value: data.payments.filter(p => p.status === 'overdue').length, color: '#ef4444' },
], [data.payments]);
// Chart Data: Revenue Over Time (Last 6 months)
const revenueHistory = useMemo(() => {
const months = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
const now = new Date();
const history = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthName = months[d.getMonth()];
const monthPayments = data.payments.filter(p => {
const pDate = new Date(p.paidDate || p.dueDate);
return pDate.getMonth() === d.getMonth() && pDate.getFullYear() === d.getFullYear() && p.status === 'paid';
});
const monthRevenue = monthPayments.reduce((sum, p) => sum + (Number((p as any).valor_pago) || (Number(p.amount) - (Number(p.discount) || 0))), 0);
history.push({ name: monthName, revenue: monthRevenue });
}
return history;
}, [data.payments]);
// Recent Activity
const recentActivity = useMemo(() => {
const activities = [
...data.students.slice(-3).map(s => ({
type: 'student',
title: 'Novo Aluno',
desc: s.name,
date: s.registrationDate,
icon: UserPlus,
color: 'bg-blue-100 text-blue-600'
})),
...data.payments.filter(p => p.status === 'paid').slice(-3).map(p => ({
type: 'payment',
title: 'Pagamento Recebido',
desc: `R$ ${(Number((p as any).valor_pago) || (Number(p.amount) - (Number(p.discount) || 0))).toLocaleString()}`,
date: p.paidDate || p.dueDate,
icon: CheckCircle2,
color: 'bg-emerald-100 text-emerald-600'
}))
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5);
return activities;
}, [data.students, data.payments]);
// All activities for modal log auditing
const allActivities = useMemo(() => {
const activities = [
...data.students.map(s => ({
type: 'student',
title: 'Novo Aluno',
desc: s.name,
date: s.registrationDate,
icon: UserPlus,
color: 'bg-blue-100 text-blue-600'
})),
...data.payments.filter(p => p.status === 'paid').map(p => ({
type: 'payment',
title: 'Pagamento Recebido',
desc: `R$ ${(Number((p as any).valor_pago) || (Number(p.amount) - (Number(p.discount) || 0))).toLocaleString()}`,
date: p.paidDate || p.dueDate,
icon: CheckCircle2,
color: 'bg-emerald-100 text-emerald-600'
}))
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return activities;
}, [data.students, data.payments]);
const handleGenerateReport = async () => {
setIsGeneratingPDF(true);
try {
await pdfService.generateFullSchoolReportPDF(data);
} catch (error) {
console.error('Error generating PDF:', error);
} finally {
setIsGeneratingPDF(false);
}
};
const stats = [
{ label: 'Alunos Ativos', value: activeStudents, icon: Users, color: 'text-blue-600', bg: 'bg-blue-100', trend: '+12%', targetView: View.Students },
{ label: 'Turmas Ativas', value: totalClasses, icon: BookOpen, color: 'text-indigo-600', bg: 'bg-indigo-100', trend: '+2', targetView: View.Classes },
{
label: 'Pré-Matrículas',
value: prematriculas.length,
icon: ClipboardPen,
color: 'text-amber-600',
bg: 'bg-amber-100',
trend: `${prematriculas.filter(p => p.status === 'pendente').length} pendentes`,
targetView: View.PreMatricula
},
{ label: 'Receita Total', value: `R$ ${revenue.toLocaleString()}`, icon: Wallet, color: 'text-emerald-600', bg: 'bg-emerald-100', trend: '+8.4%', targetView: View.Finance },
{ label: 'Taxa de Presença', value: `${attendanceRate}%`, icon: TrendingUp, color: 'text-purple-600', bg: 'bg-purple-100', trend: '+2.1%', targetView: View.AttendanceQuery },
];
const secondaryStats = [
{ label: 'Aulas a Repor', value: aulasARepor, icon: Calendar, color: 'text-red-600', targetView: View.Classes },
{ label: 'Novos Alunos (Mês)', value: newStudentsThisMonth, icon: UserPlus, color: 'text-sky-600', targetView: View.Students },
{ label: 'Pagamentos Pendentes', value: pendingPayments, icon: Clock, color: 'text-amber-600', targetView: View.Finance },
{ label: 'Ticket Médio', value: `R$ ${averagePaymentValue}`, icon: Wallet, color: 'text-slate-600', targetView: View.Finance },
];
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Painel Executivo</h2>
<p className="text-slate-500 font-medium">Visão geral do desempenho da instituição.</p>
</div>
<div className="flex items-center gap-3">
<div className="flex bg-slate-100 p-1 rounded-lg border border-slate-200">
<button
onClick={() => setDashboardView('standard')}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${dashboardView === 'standard' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Padrão
</button>
<button
onClick={() => setDashboardView('detailed')}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all ${dashboardView === 'detailed' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Detalhado
</button>
</div>
<button
onClick={handleGenerateReport}
disabled={isGeneratingPDF}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 transition-all shadow-md active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-sm"
>
{isGeneratingPDF ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<FileDown size={18} />
)}
{isGeneratingPDF ? 'Gerando...' : 'Exportar PDF'}
</button>
</div>
</header>
{/* Main Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
{stats.map((stat, i) => (
<div
key={i}
onClick={() => setView && setView(stat.targetView)}
className="group bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:-translate-y-1 active:scale-95 cursor-pointer transition-all duration-300 ease-out"
>
<div className="flex justify-between items-start mb-4">
<div className={`${stat.bg} ${stat.color} p-3 rounded-xl group-hover:scale-110 transition-transform`}>
<stat.icon size={24} />
</div>
<span className="text-xs font-bold text-emerald-600 bg-emerald-50 px-2 py-1 rounded-full">
{stat.trend}
</span>
</div>
<div>
<p className="text-sm text-slate-500 font-bold uppercase tracking-wider mb-1">{stat.label}</p>
<h3 className="text-3xl font-black text-slate-900">{stat.value}</h3>
</div>
</div>
))}
</div>
{/* Secondary Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{secondaryStats.map((stat, i) => (
<div
key={i}
onClick={() => setView && setView(stat.targetView)}
className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex items-center justify-between hover:bg-slate-100 hover:shadow-md hover:-translate-y-0.5 active:scale-95 cursor-pointer transition-all duration-300 ease-out"
>
<div className="flex items-center gap-3">
<div className={`${stat.color}`}>
<stat.icon size={20} />
</div>
<p className="text-sm font-bold text-slate-600">{stat.label}</p>
</div>
<p className="text-lg font-black text-slate-900">{stat.value}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Revenue Area Chart */}
<div
onClick={() => setView && setView(View.Finance)}
className="lg:col-span-2 bg-white p-6 rounded-2xl border border-slate-200 shadow-sm cursor-pointer hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out active:scale-[0.99]"
>
<div className="flex justify-between items-center mb-8">
<div>
<h3 className="text-lg font-black text-slate-900">Fluxo de Receita</h3>
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter">Últimos 6 meses</p>
</div>
<div className="flex items-center gap-2 text-emerald-600 font-black text-sm">
<TrendingUp size={16} />
<span>+15.2% vs ano anterior</span>
</div>
</div>
<div className="h-80" onClick={(e) => e.stopPropagation()}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={revenueHistory}>
<defs>
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.1}/>
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
tick={{fill: '#64748b', fontSize: 12, fontWeight: 600}}
dy={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{fill: '#64748b', fontSize: 12, fontWeight: 600}}
tickFormatter={(value) => `R$ ${value}`}
/>
<Tooltip
contentStyle={{borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'}}
formatter={(value: number) => [`R$ ${value.toLocaleString()}`, 'Receita']}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="#10b981"
strokeWidth={3}
fillOpacity={1}
fill="url(#colorRevenue)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Payment Status Pie Chart */}
<div
onClick={() => setView && setView(View.Finance)}
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm cursor-pointer hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out active:scale-[0.99]"
>
<h3 className="text-lg font-black text-slate-900 mb-2">Status Financeiro</h3>
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter mb-8">Distribuição de pagamentos</p>
<div className="h-64 relative" onClick={(e) => e.stopPropagation()}>
{data.payments.length > 0 ? (
<>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={paymentStatus}
cx="50%"
cy="50%"
innerRadius={70}
outerRadius={90}
paddingAngle={8}
dataKey="value"
stroke="none"
>
{paymentStatus.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'}}
/>
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-3xl font-black text-slate-900">{data.payments.length}</span>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Total</span>
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-slate-400 font-bold italic">Sem dados</div>
)}
</div>
<div className="mt-6 space-y-3">
{paymentStatus.map((item, i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{backgroundColor: item.color}}></div>
<span className="text-sm font-bold text-slate-600">{item.name}</span>
</div>
<span className="text-sm font-black text-slate-900">{item.value}</span>
</div>
))}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Class Occupancy Bar Chart */}
<div
onClick={() => setView && setView(View.Classes)}
className="lg:col-span-2 bg-white p-6 rounded-2xl border border-slate-200 shadow-sm cursor-pointer hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out active:scale-[0.99]"
>
<div className="flex justify-between items-center mb-8">
<div>
<h3 className="text-lg font-black text-slate-900">Ocupação das Turmas</h3>
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter">Alunos por turma</p>
</div>
<button
onClick={(e) => { e.stopPropagation(); setView && setView(View.Classes); }}
className="text-indigo-600 text-xs font-black uppercase tracking-widest hover:underline"
>
Ver todas
</button>
</div>
<div className="h-80" onClick={(e) => e.stopPropagation()}>
{classOccupancy.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={classOccupancy} layout="vertical" margin={{left: 40}}>
<CartesianGrid strokeDasharray="3 3" horizontal={true} vertical={false} stroke="#f1f5f9" />
<XAxis type="number" hide />
<YAxis
dataKey="name"
type="category"
axisLine={false}
tickLine={false}
tick={{fill: '#1e293b', fontSize: 11, fontWeight: 800}}
width={80}
/>
<Tooltip
cursor={{fill: '#f8fafc'}}
contentStyle={{borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'}}
/>
<Bar dataKey="students" fill="#6366f1" radius={[0, 10, 10, 0]} barSize={20}>
{classOccupancy.map((entry, index) => (
<Cell key={`cell-${index}`} fill={index === 0 ? '#4f46e5' : '#818cf8'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-full text-slate-400 font-bold italic">Sem turmas</div>
)}
</div>
</div>
{/* Recent Activity Feed */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out">
<h3 className="text-lg font-black text-slate-900 mb-6">Atividade Recente</h3>
<div className="space-y-6">
{recentActivity.length > 0 ? (
recentActivity.map((activity, i) => (
<div
key={i}
onClick={(e) => {
e.stopPropagation();
if (setView) {
setView(activity.type === 'student' ? View.Students : View.Finance);
}
}}
className="flex gap-4 relative cursor-pointer hover:bg-slate-50 p-2 -m-2 rounded-xl transition-all duration-200 active:scale-98"
>
{i !== recentActivity.length - 1 && (
<div className="absolute left-5 top-10 bottom-0 w-0.5 bg-slate-100 -mb-6"></div>
)}
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 z-10 ${activity.color}`}>
<activity.icon size={18} />
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<p className="text-sm font-black text-slate-900 truncate">{activity.title}</p>
<span className="text-[10px] font-bold text-slate-400 whitespace-nowrap ml-2">
{new Date(activity.date).toLocaleDateString('pt-BR', {day: '2-digit', month: 'short'})}
</span>
</div>
<p className="text-xs text-slate-500 font-medium truncate">{activity.desc}</p>
</div>
</div>
))
) : (
<div className="text-center py-10 text-slate-400 font-bold italic">Nenhuma atividade recente</div>
)}
</div>
<button
onClick={() => setShowLogsModal(true)}
className="w-full mt-8 py-3 rounded-xl border border-slate-200 text-slate-600 text-xs font-black uppercase tracking-widest hover:bg-slate-50 transition-colors flex items-center justify-center gap-2 active:scale-95"
>
Ver Log Completo
<ChevronRight size={14} />
</button>
</div>
</div>
{/* Pré-Matrículas Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Recent Pre-Enrollments */}
<div
onClick={() => setView && setView(View.PreMatricula)}
className="lg:col-span-2 bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out cursor-pointer active:scale-[0.99]"
>
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-lg font-black text-slate-900">Pré-Matrículas Recentes</h3>
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter">Últimas solicitações recebidas</p>
</div>
<span className="px-3 py-1 bg-amber-100 text-amber-800 rounded-full text-xs font-black">
{prematriculas.filter(p => p.status === 'pendente').length} Pendentes
</span>
</div>
<div className="overflow-x-auto" onClick={(e) => e.stopPropagation()}>
{prematriculas.length > 0 ? (
<table className="w-full text-left border-collapse text-xs">
<thead>
<tr className="border-b border-slate-100 text-slate-400 font-bold uppercase tracking-wider">
<th className="py-3 pr-4">Candidato</th>
<th className="py-3 px-4">Contato</th>
<th className="py-3 px-4">Turma de Interesse</th>
<th className="py-3 px-4 text-center">Status</th>
<th className="py-3 pl-4 text-right">Data</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50 font-medium text-slate-700">
{prematriculas.slice(0, 5).map((p) => {
const statusColor =
p.status === 'aprovado' ? 'bg-emerald-100 text-emerald-800 font-bold' :
p.status === 'rejeitado' ? 'bg-rose-100 text-rose-800 font-bold' :
'bg-amber-100 text-amber-800 font-bold';
return (
<tr key={p.id} className="hover:bg-slate-50 transition-colors">
<td className="py-3.5 pr-4 font-bold text-slate-900">{p.nome}</td>
<td className="py-3.5 px-4 font-mono text-slate-500">{p.telefone || p.email || '—'}</td>
<td className="py-3.5 px-4 font-bold text-indigo-600">{p.turmaNome || 'Geral'}</td>
<td className="py-3.5 px-4 text-center">
<span className={`inline-block px-2.5 py-0.5 rounded-full text-[10px] uppercase tracking-wider ${statusColor}`}>
{p.status}
</span>
</td>
<td className="py-3.5 pl-4 text-right text-slate-400">
{new Date(p.createdAt || p.created_at).toLocaleDateString('pt-BR')}
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="text-center py-8 text-slate-400 font-bold italic">Nenhuma pré-matrícula recebida</div>
)}
</div>
</div>
{/* Pre-Enrollment Stats Funnel */}
<div
onClick={() => setView && setView(View.PreMatricula)}
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out cursor-pointer active:scale-[0.99]"
>
<h3 className="text-lg font-black text-slate-900 mb-2">Funil de Pré-Matrícula</h3>
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter mb-6">Conversão e status</p>
<div className="space-y-4">
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100 flex items-center justify-between shadow-sm">
<div>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Total Geral</p>
<h4 className="text-2xl font-black text-slate-900 mt-1">{prematriculas.length}</h4>
</div>
<div className="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center text-slate-600 font-black">
Σ
</div>
</div>
<div className="p-4 bg-amber-50 rounded-2xl border border-amber-100 flex items-center justify-between shadow-sm">
<div>
<p className="text-[10px] font-black text-amber-500 uppercase tracking-widest">Aguardando Análise</p>
<h4 className="text-2xl font-black text-amber-800 mt-1">
{prematriculas.filter(p => p.status === 'pendente').length}
</h4>
</div>
<span className="w-8 h-8 bg-amber-200 text-amber-800 rounded-full flex items-center justify-center text-xs font-extrabold animate-pulse">
</span>
</div>
<div className="p-4 bg-emerald-50 rounded-2xl border border-emerald-100 flex items-center justify-between shadow-sm">
<div>
<p className="text-[10px] font-black text-emerald-500 uppercase tracking-widest">Aprovados</p>
<h4 className="text-2xl font-black text-emerald-800 mt-1">
{prematriculas.filter(p => p.status === 'aprovado').length}
</h4>
</div>
<span className="w-8 h-8 bg-emerald-200 text-emerald-800 rounded-full flex items-center justify-center text-xs font-extrabold">
</span>
</div>
<div className="p-4 bg-rose-50 rounded-2xl border border-rose-100 flex items-center justify-between shadow-sm">
<div>
<p className="text-[10px] font-black text-rose-500 uppercase tracking-widest">Rejeitados</p>
<h4 className="text-2xl font-black text-rose-800 mt-1">
{prematriculas.filter(p => p.status === 'rejeitado').length}
</h4>
</div>
<span className="w-8 h-8 bg-rose-200 text-rose-800 rounded-full flex items-center justify-center text-xs font-extrabold">
</span>
</div>
</div>
</div>
</div>
{/* Detailed View Expansion */}
{dashboardView === 'detailed' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 animate-in slide-in-from-bottom-4 duration-500">
<div
onClick={() => setView && setView(View.Students)}
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out cursor-pointer active:scale-[0.99]"
>
<h3 className="text-lg font-black text-slate-900 mb-6">Distribuição por Gênero</h3>
<div className="h-48 flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={[
{ name: 'Feminino', value: data.students.filter(s => (s as any).sexo === 'Feminino' || s.gender === 'F' || (s as any).sexo === 'F').length },
{ name: 'Masculino', value: data.students.filter(s => (s as any).sexo === 'Masculino' || s.gender === 'M' || (s as any).sexo === 'M').length },
{ name: 'Outro', value: data.students.filter(s => (s as any).sexo === 'Outro' || s.gender === 'O' || (s as any).sexo === 'O').length },
]}
cx="50%"
cy="50%"
outerRadius={60}
dataKey="value"
label
>
<Cell fill="#ec4899" />
<Cell fill="#3b82f6" />
<Cell fill="#94a3b8" />
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
<div
onClick={() => setView && setView(View.Students)}
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 ease-out cursor-pointer active:scale-[0.99]"
>
<h3 className="text-lg font-black text-slate-900 mb-6">Alunos por Status</h3>
<div className="h-48" onClick={(e) => e.stopPropagation()}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={[
{ name: 'Ativo', value: data.students.filter(s => s.status === 'active').length },
{ name: 'Inativo', value: data.students.filter(s => s.status === 'inactive').length },
{ name: 'Trancado', value: data.students.filter(s => s.status === 'suspended').length },
]}>
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fontSize: 12, fontWeight: 700}} />
<Tooltip cursor={{fill: 'transparent'}} />
<Bar dataKey="value" fill="#6366f1" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
)}
{/* Floating Audit Logs Modal (Rule 11 & 17 compliant) */}
{showLogsModal && (
<div className="fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-3xl w-full max-w-2xl p-6 shadow-2xl border border-slate-100 flex flex-col max-h-[80vh] animate-in fade-in zoom-in-95 duration-200">
<div className="flex justify-between items-center pb-4 border-b border-slate-100 mb-4">
<div>
<h3 className="text-lg font-black text-slate-900">Histórico de Atividades</h3>
<p className="text-[10px] text-slate-400 font-black uppercase tracking-widest mt-0.5">Logs de auditoria e novos registros</p>
</div>
<button
onClick={() => setShowLogsModal(false)}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-colors"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto space-y-4 pr-2 custom-scrollbar">
{allActivities.length > 0 ? (
allActivities.map((activity, i) => (
<div
key={i}
onClick={() => {
if (setView) {
setView(activity.type === 'student' ? View.Students : View.Finance);
setShowLogsModal(false);
}
}}
className="flex gap-4 p-3 rounded-2xl hover:bg-slate-50 border border-transparent hover:border-slate-100 transition-all duration-200 cursor-pointer active:scale-98"
>
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${activity.color}`}>
<activity.icon size={18} />
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<p className="text-sm font-black text-slate-900 truncate">{activity.title}</p>
<span className="text-[10px] font-bold text-slate-400 whitespace-nowrap ml-2">
{new Date(activity.date).toLocaleDateString('pt-BR', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
<p className="text-xs text-slate-500 font-medium truncate mt-0.5">{activity.desc}</p>
</div>
</div>
))
) : (
<div className="text-center py-10 text-slate-400 font-bold italic">Nenhuma atividade registrada</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default Dashboard;

View File

@ -0,0 +1,510 @@
import React, { useState, useEffect } from 'react';
import { Employee, EmployeeCategory } from '../types';
import { Plus, Edit2, Trash2, X, Search, Users, Briefcase, Calendar, Phone, Mail, FileText, Settings2 } from 'lucide-react';
import { useDialog } from '../DialogContext';
const Employees: React.FC = () => {
const { showAlert, showConfirm } = useDialog();
const [searchTerm, setSearchTerm] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
const [editingCategory, setEditingCategory] = useState<EmployeeCategory | null>(null);
const [formData, setFormData] = useState<Omit<Employee, 'id'>>({
name: '',
cpf: '',
phone: '',
email: '',
admissionDate: new Date().toISOString().split('T')[0],
categoryId: ''
});
const [categoryFormData, setCategoryFormData] = useState({ name: '' });
const [employees, setEmployees] = useState<Employee[]>([]);
const [categories, setCategories] = useState<EmployeeCategory[]>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
const loadData = async () => {
try {
setIsLoadingData(true);
const [empRes, catRes] = await Promise.all([
fetch(`/api/funcionarios?t=${Date.now()}`),
fetch(`/api/categorias_funcionarios?t=${Date.now()}`)
]);
const empData = await empRes.json();
const catData = await catRes.json();
// Mapeamento caso a API retorne os nomes das colunas diferentes do TS
const mappedEmployees = (empData.funcionarios || []).map((e: any) => ({
id: e.id,
name: e.name || e.nome,
cpf: e.cpf,
email: e.email,
phone: e.phone || e.telefone,
admissionDate: e.hireDate || (e.data_admissao ? e.data_admissao.substring(0, 10) : ''),
categoryId: e.categoryId || e.categoria_id
}));
const mappedCategories = (catData.categorias || []).map((c: any) => ({
id: c.id,
name: c.name || c.nome
}));
setEmployees(mappedEmployees);
setCategories(mappedCategories);
} catch (err) {
console.error('Erro ao buscar funcionários/categorias:', err);
showAlert('Erro', 'Não foi possível carregar a lista do servidor.', 'error');
} finally {
setIsLoadingData(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredEmployees = employees.filter(emp =>
(emp.name || '').toLowerCase().includes((searchTerm || '').toLowerCase()) ||
(emp.cpf || '').includes(searchTerm || '') ||
(emp.email || '').toLowerCase().includes((searchTerm || '').toLowerCase())
);
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setIsModalOpen(false);
setIsClosing(false);
setEditingEmployee(null);
setFormData({
name: '',
cpf: '',
phone: '',
email: '',
admissionDate: new Date().toISOString().split('T')[0],
categoryId: ''
});
}, 400);
};
const closeCategoryModal = () => {
setIsCategoryModalOpen(false);
setEditingCategory(null);
setCategoryFormData({ name: '' });
};
const handleEdit = (emp: Employee) => {
setEditingEmployee(emp);
setFormData({
name: emp.name,
cpf: emp.cpf,
phone: emp.phone,
email: emp.email,
admissionDate: emp.admissionDate,
categoryId: emp.categoryId
});
setIsModalOpen(true);
};
const handleDelete = (emp: Employee) => {
showConfirm(
'Remover Funcionário',
`Tem certeza que deseja remover ${emp.name}?`,
async () => {
try {
const res = await fetch(`/api/funcionarios/${emp.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Falha ao excluir');
await loadData();
showAlert('Sucesso', 'Funcionário removido com sucesso.', 'success');
} catch (error) {
showAlert('Erro', 'Ocorreu um erro ao excluir o funcionário.', 'error');
}
}
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.categoryId) {
showAlert('Atenção', 'Selecione uma categoria para o funcionário.', 'warning');
return;
}
const payload = {
nome: formData.name,
cpf: formData.cpf,
email: formData.email,
telefone: formData.phone,
data_admissao: formData.admissionDate,
categoria_id: formData.categoryId
};
try {
if (editingEmployee) {
const res = await fetch(`/api/funcionarios/${editingEmployee.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Failed to update');
showAlert('Sucesso', 'Funcionário atualizado com sucesso.', 'success');
} else {
const res = await fetch('/api/funcionarios', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, id: crypto.randomUUID() })
});
if (!res.ok) throw new Error('Failed to create');
showAlert('Sucesso', 'Funcionário cadastrado com sucesso.', 'success');
}
await loadData();
closeModal();
} catch (err) {
showAlert('Erro', 'Ocorreu um problema de comunicação com a API.', 'error');
}
};
const handleCategorySubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!categoryFormData.name.trim()) return;
try {
if (editingCategory) {
const res = await fetch(`/api/categorias_funcionarios/${editingCategory.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nome: categoryFormData.name })
});
if (!res.ok) throw new Error('Failed to update');
} else {
const res = await fetch('/api/categorias_funcionarios', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: crypto.randomUUID(), nome: categoryFormData.name })
});
if (!res.ok) throw new Error('Failed to create');
}
await loadData();
setCategoryFormData({ name: '' });
setEditingCategory(null);
} catch (err) {
showAlert('Erro', 'Ocorreu um erro ao salvar categoria.', 'error');
}
};
const handleDeleteCategory = (cat: EmployeeCategory) => {
const hasEmployees = employees.some(emp => emp.categoryId === cat.id);
if (hasEmployees) {
showAlert('Atenção', 'Não é possível excluir uma categoria que possui funcionários vinculados.', 'warning');
return;
}
showConfirm(
'Remover Categoria',
`Deseja remover a categoria "${cat.name}"?`,
async () => {
try {
const res = await fetch(`/api/categorias_funcionarios/${cat.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
await loadData();
} catch (err) {
showAlert('Erro', 'Ocorreu um erro ao excluir a categoria.', 'error');
}
}
);
};
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm";
return (
<div className="space-y-6 animate-in fade-in duration-300">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Funcionários <span className="text-sm font-normal text-green-600 bg-green-50 px-2 py-1 rounded-md ml-2 border border-green-200">PostgreSQL</span></h2>
<p className="text-slate-500">Gerencie sua equipe e categorias profissionais.</p>
</div>
<div className="flex gap-2 w-full md:w-auto">
<button
onClick={() => setIsCategoryModalOpen(true)}
className="flex-1 md:flex-none bg-white text-slate-700 px-6 py-3 rounded-xl flex items-center justify-center gap-2 hover:bg-slate-50 transition-all shadow-sm border border-slate-200 font-bold"
>
<Settings2 size={20} /> Categorias
</button>
<button
onClick={() => setIsModalOpen(true)}
className="flex-1 md:flex-none bg-indigo-600 text-white px-6 py-3 rounded-xl flex items-center justify-center gap-2 hover:bg-indigo-700 transition-all shadow-lg font-bold"
>
<Plus size={20} /> Novo Funcionário
</button>
</div>
</div>
{/* Search and Stats */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-3 relative group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors" size={20} />
<input
type="text"
placeholder="Buscar por nome, CPF ou e-mail..."
className="w-full pl-12 pr-4 py-4 bg-white border border-slate-200 rounded-2xl shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center text-indigo-600">
<Users size={24} />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total Equipe</p>
<p className="text-2xl font-black text-slate-900">{employees.length}</p>
</div>
</div>
</div>
{/* Employees Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{filteredEmployees.map(emp => {
const category = categories.find(c => c.id === emp.categoryId);
return (
<div key={emp.id} className="bg-white rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-all group overflow-hidden">
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center text-slate-500 group-hover:bg-indigo-50 group-hover:text-indigo-600 transition-colors">
<Users size={24} />
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleEdit(emp)}
className="p-2 text-slate-400 hover:text-indigo-600 rounded-lg hover:bg-indigo-50 transition-all"
>
<Edit2 size={18} />
</button>
<button
onClick={() => handleDelete(emp)}
className="p-2 text-slate-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-all"
>
<Trash2 size={18} />
</button>
</div>
</div>
<h3 className="text-lg font-black text-slate-900 mb-1">{emp.name}</h3>
<span className="inline-block px-2 py-1 bg-indigo-50 text-indigo-600 text-[10px] font-black uppercase rounded-md mb-4">
{category?.name || 'Sem Categoria'}
</span>
<div className="space-y-2 text-sm text-slate-600">
<div className="flex items-center gap-2">
<FileText size={14} className="text-slate-400" />
<span>CPF: {emp.cpf}</span>
</div>
<div className="flex items-center gap-2">
<Phone size={14} className="text-slate-400" />
<span>{emp.phone}</span>
</div>
<div className="flex items-center gap-2">
<Mail size={14} className="text-slate-400" />
<span className="truncate">{emp.email}</span>
</div>
<div className="flex items-center gap-2">
<Calendar size={14} className="text-slate-400" />
<span>Admissão: {new Date(emp.admissionDate).toLocaleDateString('pt-BR')}</span>
</div>
</div>
</div>
</div>
);
})}
</div>
{!isLoadingData && employees.length === 0 && (
<div className="bg-white border-2 border-dashed border-slate-200 rounded-3xl p-12 text-center">
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-4 text-slate-300">
<Users size={40} />
</div>
<h3 className="text-xl font-bold text-slate-900 mb-2">Nenhum funcionário cadastrado</h3>
<p className="text-slate-500 mb-6">Comece adicionando os membros da sua equipe.</p>
<button
onClick={() => setIsModalOpen(true)}
className="bg-indigo-600 text-white px-8 py-3 rounded-xl font-bold hover:bg-indigo-700 transition-all shadow-lg"
>
Cadastrar Primeiro Funcionário
</button>
</div>
)}
{/* Employee Modal */}
{isModalOpen && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-xl w-full max-w-2xl shadow-2xl my-auto transition-all duration-400 relative overflow-hidden ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-xl font-black text-slate-800 tracking-tight">
{editingEmployee ? 'Editar Funcionário' : 'Novo Funcionário'}
</h3>
<p className="text-xs text-slate-500">Preencha os dados profissionais.</p>
</div>
<button onClick={closeModal} className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-lg shadow-sm transition-all hover:rotate-90">
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Nome Completo</label>
<input
required
className={inputClass}
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">CPF</label>
<input
required
className={inputClass}
placeholder="000.000.000-00"
value={formData.cpf}
onChange={e => {
const val = e.target.value.replace(/\D/g, '').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d{1,2})/, '$1-$2').slice(0, 14);
setFormData({ ...formData, cpf: val });
}}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Categoria</label>
<select
required
className={inputClass}
value={formData.categoryId}
onChange={e => setFormData({ ...formData, categoryId: e.target.value })}
>
<option value="">Selecione...</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Telefone</label>
<input
required
className={inputClass}
placeholder="(00) 00000-0000"
value={formData.phone}
onChange={e => {
const val = e.target.value.replace(/\D/g, '').replace(/(\d{2})(\d)/, '($1) $2').replace(/(\d{5})(\d)/, '$1-$2').slice(0, 15);
setFormData({ ...formData, phone: val });
}}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">E-mail</label>
<input
type="email"
required
className={inputClass}
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Data de Admissão</label>
<input
type="date"
required
className={inputClass}
value={formData.admissionDate}
onChange={e => setFormData({ ...formData, admissionDate: e.target.value })}
/>
</div>
<div className="md:col-span-2 pt-4 flex gap-3">
<button type="button" onClick={closeModal} className="flex-1 py-3 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 font-bold text-sm">
Cancelar
</button>
<button type="submit" className="flex-1 py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 shadow-lg font-bold text-sm">
{editingEmployee ? 'Atualizar' : 'Cadastrar'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Categories Modal */}
{isCategoryModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-transparent animate-in fade-in duration-200">
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl relative overflow-hidden animate-slide-up">
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0"></div>
<div className="p-6 border-b border-slate-100 flex justify-between items-center">
<h3 className="text-xl font-black text-slate-800 tracking-tight">Gerenciar Categorias</h3>
<button onClick={closeCategoryModal} className="p-2 text-slate-400 hover:text-red-500 transition-all">
<X size={20} />
</button>
</div>
<div className="p-6 space-y-6">
<form onSubmit={handleCategorySubmit} className="flex gap-2">
<input
placeholder="Nova categoria (ex: Professor)"
className={`${inputClass} flex-1`}
value={categoryFormData.name}
onChange={e => setCategoryFormData({ name: e.target.value })}
/>
<button type="submit" className="bg-indigo-600 text-white p-3 rounded-xl hover:bg-indigo-700 transition-all shadow-md">
<Plus size={20} />
</button>
</form>
<div className="space-y-2 max-h-60 overflow-y-auto pr-2">
{categories.map(cat => (
<div key={cat.id} className="flex items-center justify-between p-3 bg-slate-50 rounded-xl group">
<span className="font-bold text-slate-700">{cat.name}</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => {
setEditingCategory(cat);
setCategoryFormData({ name: cat.name });
}}
className="p-1.5 text-slate-400 hover:text-indigo-600 rounded-lg hover:bg-white transition-all"
>
<Edit2 size={16} />
</button>
<button
onClick={() => handleDeleteCategory(cat)}
className="p-1.5 text-slate-400 hover:text-red-600 rounded-lg hover:bg-white transition-all"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
{categories.length === 0 && (
<p className="text-center text-slate-400 text-sm py-4 italic">Nenhuma categoria cadastrada.</p>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default Employees;

View File

@ -0,0 +1,880 @@
import React, { useState, useRef } from 'react';
import { SchoolData, Exam, Question } from '../types';
import { FileText, Plus, Search, BookOpen, Upload, Trash2, ArrowLeft, Save, CheckCircle, Image as ImageIcon, X, RefreshCw, Lock, Unlock, AlertTriangle, Copy, Bell } from 'lucide-react';
import { uploadExamImage } from '../services/supabase';
import { useDialog } from '../DialogContext';
interface ExamsProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
const [searchTerm, setSearchTerm] = useState('');
const [currentView, setCurrentView] = useState<'list' | 'builder'>('list');
const [editingExam, setEditingExam] = useState<Exam | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [duplicatingExam, setDuplicatingExam] = useState<Exam | null>(null);
const [targetClassId, setTargetClassId] = useState('');
const [activeTab, setActiveTab] = useState<'ativos' | 'lixeira'>('ativos');
const { showAlert, showConfirm } = useDialog();
const [dbClasses, setDbClasses] = useState<any[]>(data?.classes || []);
const [dbCourses, setDbCourses] = useState<any[]>(data?.courses || []);
const [dbSubjects, setDbSubjects] = useState<any[]>(data?.subjects || []);
const [dbExams, setDbExams] = useState<Exam[]>(data.exams || []);
const loadExams = async () => {
try {
const res = await fetch(`/api/provas?t=${Date.now()}`);
if (res.ok) {
const { provas } = await res.json();
setDbExams(provas.map((p: any) => ({
id: p.id,
classId: p.classId || p.turma_id,
subjectId: p.subjectId || p.disciplina_id,
periodId: p.periodId || p.periodo_id,
title: p.title || p.titulo,
durationMinutes: p.durationMinutes || p.duracao_minutos,
status: p.status,
allowRetake: p.allowRetake ?? p.permitir_refacao ?? false,
isDeleted: p.isDeleted ?? p.is_deleted ?? false,
evaluationType: p.evaluationType || p.evaluation_type || 'exam',
maxScore: p.maxScore !== undefined && p.maxScore !== null ? Number(p.maxScore) : (p.max_score !== undefined && p.max_score !== null ? Number(p.max_score) : 10),
questions: p.questions || []
})));
}
} catch(e) {
console.error(e);
}
};
React.useEffect(() => {
loadExams();
}, []);
React.useEffect(() => {
Promise.all([
fetch('/api/turmas').catch(() => ({ ok: false, json: async () => ({}) })),
fetch('/api/cursos').catch(() => ({ ok: false, json: async () => ({}) })),
fetch('/api/disciplinas').catch(() => ({ ok: false, json: async () => ({}) }))
]).then(async (responses) => {
const [resT, resC, resS] = responses;
if (resT && resT.ok) {
const jsonT = await resT.json();
if (jsonT.turmas) setDbClasses(jsonT.turmas.map((t: any) => ({
id: t.id, name: t.nome, courseId: t.curso_id, maxStudents: Number(t.max_alunos || 0)
})));
}
if (resC && resC.ok) {
const jsonC = await resC.json();
if (jsonC.cursos) setDbCourses(jsonC.cursos.map((c: any) => ({
id: c.id, name: c.nome, monthlyFee: Number(c.mensalidade || 0), registrationFee: Number(c.taxa_matricula || 0)
})));
}
if (resS && resS.ok) {
const jsonS = await resS.json();
if (jsonS.disciplinas) setDbSubjects(jsonS.disciplinas.map((d: any) => ({
id: d.id, name: d.nome
})));
}
}).catch(console.error);
}, []);
const normalizePhotoUrl = (url?: string) => {
if (!url) return '';
if (url.startsWith('data:image') || url.startsWith('/storage')) return url;
try {
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
if (match) return `/storage/${match[1]}`;
} catch (e) { }
return url;
};
const exams = dbExams || [];
const filteredExams = exams.filter(exam =>
(activeTab === 'ativos' ? !exam.isDeleted : !!exam.isDeleted) &&
(exam.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
dbClasses.find(c => c.id === exam.classId)?.name.toLowerCase().includes(searchTerm.toLowerCase()))
);
const handleStartCreate = () => {
setEditingExam({
id: Date.now().toString(),
title: '',
classId: dbClasses[0]?.id || '',
durationMinutes: 60,
status: 'draft',
questions: [],
evaluationType: 'exam',
maxScore: 10,
allowRetake: false
} as any);
setCurrentView('builder');
};
const handleEditExam = (exam: Exam) => {
setEditingExam({ ...exam });
setCurrentView('builder');
};
const handleToggleRetake = async (examId: string) => {
const targetExam = exams.find(e => e.id === examId);
if (!targetExam) return;
try {
await fetch(`/api/provas/${examId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...targetExam, allowRetake: !targetExam.allowRetake })
});
await loadExams();
} catch (e) {
console.error('Erro ao alterar refação:', e);
}
};
const handleDeleteExam = (examId: string) => {
showConfirm(
'Mover para Lixeira',
'Tem certeza que deseja mover esta avaliação para a lixeira? Ela será ocultada para os alunos, mas as notas no boletim continuarão intactas.',
async () => {
try {
const targetExam = exams.find(e => e.id === examId);
if (targetExam) {
await fetch(`/api/provas/${examId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...targetExam, isDeleted: true })
});
}
} catch (e) { console.error('Erro ao deletar prova:', e); }
await loadExams();
showAlert('Sucesso', 'Avaliação movida para a lixeira.', 'success');
}
);
};
const handleRestoreExam = async (examId: string) => {
try {
const targetExam = exams.find(e => e.id === examId);
if (targetExam) {
await fetch(`/api/provas/${examId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...targetExam, isDeleted: false })
});
}
} catch (e) { console.error('Erro ao restaurar prova:', e); }
await loadExams();
showAlert('Sucesso', 'Avaliação reativada.', 'success');
};
const handlePermanentDelete = (examId: string) => {
showConfirm(
'Excluir Permanentemente',
'⚠️ Atenção: Esta ação irá apagar esta avaliação e todas as suas questões PERMANENTEMENTE do banco de dados. As submissões dos alunos também serão removidas. Esta ação NÃO pode ser desfeita. Deseja continuar?',
async () => {
try {
const response = await fetch(`/api/provas/${examId}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Falha ao excluir');
await loadExams();
showAlert('Sucesso', 'Avaliação excluída permanentemente.', 'success');
} catch (e) {
console.error('Erro ao excluir permanentemente:', e);
showAlert('Erro', 'Falha ao excluir a avaliação do servidor.', 'error');
}
}
);
};
const handleDuplicateExam = async () => {
if (!duplicatingExam || !targetClassId) return;
const newExam: Exam = {
...duplicatingExam,
id: Date.now().toString() + Math.random().toString(36).substring(7),
classId: targetClassId,
status: 'draft',
title: `${duplicatingExam.title} (Cópia)`,
questions: (duplicatingExam.questions || []).map((q: any) => ({
...q,
id: Date.now().toString() + Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 6)
}))
};
try {
const response = await fetch('/api/provas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newExam)
});
if (!response.ok) throw new Error('Falha ao duplicar prova');
await loadExams();
showAlert('Sucesso', 'Avaliação duplicada com sucesso!', 'success');
} catch (e) {
console.error('Erro ao duplicar:', e);
showAlert('Erro', 'Falha ao duplicar a avaliação.', 'error');
}
setDuplicatingExam(null);
setTargetClassId('');
};
const handleAddQuestion = () => {
if (!editingExam) return;
setEditingExam({
...editingExam,
questions: [
...editingExam.questions,
{
id: Date.now().toString() + Math.random().toString(36).substring(7),
text: '',
options: ['', '', '', ''],
correctOptionIndex: 0
}
]
});
};
const handleRemoveQuestion = (qIndex: number) => {
if (!editingExam) return;
const newQuestions = [...editingExam.questions];
newQuestions.splice(qIndex, 1);
setEditingExam({ ...editingExam, questions: newQuestions });
};
const handleQuestionChange = (qIndex: number, field: keyof Question, value: any) => {
if (!editingExam) return;
const newQuestions = [...editingExam.questions];
newQuestions[qIndex] = { ...newQuestions[qIndex], [field]: value };
setEditingExam({ ...editingExam, questions: newQuestions });
};
const handleOptionChange = (qIndex: number, oIndex: number, value: string) => {
if (!editingExam) return;
const newQuestions = [...editingExam.questions];
const newOptions = [...newQuestions[qIndex].options];
newOptions[oIndex] = value;
newQuestions[qIndex].options = newOptions;
setEditingExam({ ...editingExam, questions: newQuestions });
};
const handleAddOption = (qIndex: number) => {
if (!editingExam) return;
const newQuestions = [...editingExam.questions];
newQuestions[qIndex].options.push('');
setEditingExam({ ...editingExam, questions: newQuestions });
};
const handleRemoveOption = (qIndex: number, oIndex: number) => {
if (!editingExam) return;
const newQuestions = [...editingExam.questions];
newQuestions[qIndex].options.splice(oIndex, 1);
// Adjust correctOptionIndex if needed
if (newQuestions[qIndex].correctOptionIndex >= newQuestions[qIndex].options.length) {
newQuestions[qIndex].correctOptionIndex = Math.max(0, newQuestions[qIndex].options.length - 1);
} else if (newQuestions[qIndex].correctOptionIndex === oIndex) {
newQuestions[qIndex].correctOptionIndex = 0;
}
setEditingExam({ ...editingExam, questions: newQuestions });
};
const handleImageUpload = async (qIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
const url = await uploadExamImage(file);
if (url) {
handleQuestionChange(qIndex, 'imageUrl', url);
} else {
alert('Falha ao obter URL pública da imagem após o upload.');
}
} catch (error: any) {
console.error(error);
const errorMessage = error.message || 'Erro desconhecido';
alert(`Erro ao enviar imagem: ${errorMessage}\n\nVerifique sua conexão ou a configuração do bucket "exames" no MinIO.`);
} finally {
setIsUploading(false);
if (event.target) {
event.target.value = ''; // Reset file input
}
}
};
const handleNotifyStudents = async (exam: Exam) => {
const classObj = (dbClasses || []).find(c => c.id === exam.classId);
if (!classObj) return;
showConfirm(
'Notificar Turma',
`Deseja enviar WhatsApp e notificação no portal para os alunos da turma ${classObj.name} informando sobre esta avaliação?`,
async () => {
try {
const resp = await fetch('/api/exames/notificar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ examId: exam.id })
});
const resData = await resp.json();
if (resp.ok) {
showAlert('Sucesso', 'Notificações enviadas com sucesso!', 'success');
} else {
showAlert('Erro', resData.error || 'Erro ao notificar turma.', 'error');
}
} catch (e) {
showAlert('Erro', 'Erro de conexão.', 'error');
}
}
);
};
const handleSave = async (status: 'draft' | 'published') => {
if (!editingExam) return;
if (!editingExam.title || !editingExam.classId) {
showAlert('Atenção', 'Preencha o título e a turma antes de salvar.', 'warning');
return;
}
if (status === 'published' && (!editingExam.subjectId || !editingExam.periodId)) {
showAlert(
'Vínculo Obrigatório',
'Para PUBLICAR a avaliação e permitir que as notas entrem no Boletim Escolar, você precisa vincular uma Disciplina e um Período.',
'warning'
);
return;
}
const finalExam = { ...editingExam, status };
const isNew = !dbExams.some(e => e.id === finalExam.id);
const endpoint = isNew ? '/api/provas' : `/api/provas/${finalExam.id}`;
const method = isNew ? 'POST' : 'PUT';
try {
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(finalExam)
});
if (!response.ok) throw new Error('Falha ao salvar avaliação');
await loadExams();
showAlert('Sucesso', status === 'published' ? 'Avaliação publicada com sucesso!' : 'Rascunho salvo com sucesso!', 'success');
} catch (e) {
console.error('Erro ao salvar prova:', e);
showAlert('Erro', 'Falha ao salvar a avaliação no servidor.', 'error');
return;
}
setCurrentView('list');
setEditingExam(null);
};
if (currentView === 'builder' && editingExam) {
return (
<div className="p-8 max-w-4xl mx-auto animate-in fade-in duration-500 pb-32">
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => setCurrentView('list')}
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
>
<ArrowLeft size={24} />
</button>
<div>
<h2 className="text-3xl font-black text-slate-800 tracking-tight flex items-center gap-3">
Criador de Provas
</h2>
<p className="text-slate-500 mt-1 font-medium">Configure os detalhes e as questões da avaliação.</p>
</div>
</div>
{/* Informações Básicas */}
<div className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200 mb-8">
<h3 className="text-lg font-bold text-slate-800 mb-6 flex items-center gap-2">
<BookOpen className="text-indigo-500" size={20} /> Informações Básicas
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className="block text-sm font-bold text-slate-700 mb-2">Título da Avaliação</label>
<input
type="text"
value={editingExam.title}
onChange={e => setEditingExam({ ...editingExam, title: e.target.value })}
placeholder="Ex: Prova Bimestral de Matemática"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Tipo de Avaliação</label>
<select
value={(editingExam as any).evaluationType || 'exam'}
onChange={e => setEditingExam({ ...editingExam, evaluationType: e.target.value } as any)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
>
<option value="exam">Prova</option>
<option value="activity">Atividade</option>
</select>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Valor (Pontuação Máxima)</label>
<input
type="number"
value={(editingExam as any).maxScore ?? 10}
onChange={e => setEditingExam({ ...editingExam, maxScore: parseFloat(e.target.value) || 0 } as any)}
min="0"
step="0.1"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Turma Associada</label>
<select
value={editingExam.classId}
onChange={e => setEditingExam({ ...editingExam, classId: e.target.value })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
>
<option value="" disabled>Selecione uma turma</option>
{dbClasses.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Duração (Minutos)</label>
<input
type="number"
value={editingExam.durationMinutes}
onChange={e => setEditingExam({ ...editingExam, durationMinutes: parseInt(e.target.value) || 0 })}
min="0"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Disciplina (Boletim) <span className="text-amber-500" title="Obrigatório para publicar">*</span>
</label>
<select
value={editingExam.subjectId || ''}
onChange={e => setEditingExam({ ...editingExam, subjectId: e.target.value || undefined })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
>
<option value="">Nenhuma (não vincular)</option>
{(dbSubjects || []).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<p className="text-[11px] text-amber-600 mt-1.5 font-semibold">Obrigatório para Publicar. A nota irá automaticamente para o boletim.</p>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Período (Boletim) <span className="text-amber-500" title="Obrigatório para publicar">*</span>
</label>
<select
value={editingExam.periodId || ''}
onChange={e => setEditingExam({ ...editingExam, periodId: e.target.value || undefined })}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800"
>
<option value="">Nenhum (não vincular)</option>
{(data.periods || []).map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<p className="text-[11px] text-amber-600 mt-1.5 font-semibold">Obrigatório para Publicar. Define em qual coluna do boletim a nota entra.</p>
</div>
</div>
</div>
{/* Questões */}
<div className="space-y-6 mb-8">
{editingExam.questions.map((question, qIndex) => (
<div key={question.id} className="bg-white rounded-2xl p-8 shadow-md border border-slate-200 relative group animate-slide-up">
<div className="absolute top-0 left-0 w-1.5 h-full bg-indigo-500 rounded-l-2xl"></div>
<div className="flex justify-between items-center mb-6">
<h4 className="text-lg font-black text-slate-800 flex items-center gap-2">
<span className="w-8 h-8 rounded-lg bg-indigo-100 text-indigo-700 flex items-center justify-center text-sm">{qIndex + 1}</span>
Questão
</h4>
<button
onClick={() => handleRemoveQuestion(qIndex)}
className="text-slate-400 hover:text-red-500 transition-colors p-2"
title="Remover Questão"
>
<Trash2 size={20} />
</button>
</div>
<div className="space-y-6">
{/* Enunciado */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Enunciado</label>
<textarea
value={question.text}
onChange={e => handleQuestionChange(qIndex, 'text', e.target.value)}
placeholder="Digite o enunciado da questão..."
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-medium text-slate-800 min-h-[100px] resize-y"
/>
</div>
{/* Imagem da Questão */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Imagem de Apoio (Opcional)</label>
{question.imageUrl ? (
<div className="relative inline-block mt-2 group/img">
<img src={normalizePhotoUrl(question.imageUrl)} alt="Apoio" className="max-w-full md:max-w-md h-auto rounded-xl border border-slate-200 shadow-sm" />
<button
onClick={() => handleQuestionChange(qIndex, 'imageUrl', undefined)}
className="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-lg opacity-0 group-hover/img:opacity-100 transition-opacity flex items-center justify-center hover:bg-red-600 shadow-lg"
>
<Trash2 size={16} />
</button>
</div>
) : (
<label className="flex items-center justify-center gap-2 w-full md:w-auto px-6 py-4 bg-slate-50 border-2 border-dashed border-slate-300 rounded-xl cursor-pointer hover:bg-slate-100 transition-colors">
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleImageUpload(qIndex, e)}
disabled={isUploading}
/>
{isUploading ? (
<span className="text-slate-500 font-bold text-sm">Enviando...</span>
) : (
<>
<ImageIcon size={20} className="text-slate-400" />
<span className="text-slate-500 font-bold text-sm">Adicionar Imagem</span>
</>
)}
</label>
)}
</div>
{/* Alternativas */}
<div className="pt-4 border-t border-slate-100">
<label className="block text-sm font-bold text-slate-700 mb-4">Alternativas</label>
<div className="space-y-3">
{question.options.map((option, oIndex) => (
<div key={oIndex} className={`flex items-center gap-3 p-2 rounded-xl transition-colors ${question.correctOptionIndex === oIndex ? 'bg-emerald-50 border border-emerald-200' : 'bg-slate-50 border border-transparent'}`}>
<div className="flex items-center justify-center w-10">
<input
type="radio"
name={`correct-${question.id}`}
checked={question.correctOptionIndex === oIndex}
onChange={() => handleQuestionChange(qIndex, 'correctOptionIndex', oIndex)}
className="w-5 h-5 text-emerald-600 focus:ring-emerald-500 cursor-pointer"
title="Marcar como correta"
/>
</div>
<input
type="text"
value={option}
onChange={e => handleOptionChange(qIndex, oIndex, e.target.value)}
placeholder={`Alternativa ${String.fromCharCode(65 + oIndex)}`}
className="flex-1 bg-transparent border-none focus:ring-0 p-2 font-medium text-slate-800 placeholder:text-slate-400"
/>
<button
onClick={() => handleRemoveOption(qIndex, oIndex)}
className="p-2 text-slate-400 hover:text-red-500 transition-colors"
disabled={question.options.length <= 2}
title={question.options.length <= 2 ? "Mínimo de 2 alternativas" : "Remover alternativa"}
>
<X size={18} />
</button>
</div>
))}
</div>
<button
onClick={() => handleAddOption(qIndex)}
className="mt-4 flex items-center gap-2 text-sm font-bold text-indigo-600 hover:text-indigo-800 transition-colors"
>
<Plus size={16} /> Adicionar Alternativa
</button>
</div>
</div>
</div>
))}
{/* Botão Adicionar Questão */}
<button
onClick={handleAddQuestion}
className="w-full flex flex-col items-center justify-center gap-3 p-8 border-2 border-dashed border-indigo-200 rounded-2xl text-indigo-600 hover:bg-indigo-50 hover:border-indigo-400 transition-all font-bold group"
>
<div className="w-12 h-12 bg-indigo-100 rounded-full flex items-center justify-center group-hover:scale-110 transition-transform">
<Plus size={24} className="text-indigo-600" />
</div>
Adicionar Nova Questão
</button>
</div>
{/* Sticky Actions Bar */}
<div className="fixed bottom-0 left-0 md:left-64 right-0 p-4 bg-white/80 backdrop-blur-md border-t border-slate-200 flex justify-end gap-4 shadow-[0_-10px_40px_-15px_rgba(0,0,0,0.1)] z-40">
<button
onClick={() => handleSave('draft')}
className="flex items-center gap-2 px-6 py-3 bg-slate-100 text-slate-700 rounded-xl font-bold hover:bg-slate-200 transition-colors"
>
<Save size={20} /> Salvar como Rascunho
</button>
<button
onClick={() => handleSave('published')}
className="flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-xl font-black tracking-wide hover:bg-emerald-700 shadow-lg shadow-emerald-200 transition-all"
>
<CheckCircle size={20} /> {(editingExam as any).evaluationType === 'activity' ? 'Publicar Atividade' : 'Publicar Prova'}
</button>
</div>
</div>
);
}
// LIST VIEW
return (
<div className="p-8 max-w-7xl mx-auto animate-in fade-in duration-500">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div>
<h2 className="text-3xl font-black text-slate-800 tracking-tight flex items-center gap-3">
<BookOpen className="text-indigo-600" size={32} /> Atividades e Provas
</h2>
<p className="text-slate-500 mt-2 font-medium">Gerencie as provas e testes das turmas.</p>
</div>
<div className="flex flex-col md:flex-row gap-4">
<div className="flex bg-slate-100 p-1 rounded-xl shrink-0 self-start md:self-auto">
<button
onClick={() => setActiveTab('ativos')}
className={`px-4 py-2.5 rounded-lg text-sm font-black transition-all ${activeTab === 'ativos' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Ativos
</button>
<button
onClick={() => setActiveTab('lixeira')}
className={`px-4 py-2.5 rounded-lg text-sm font-black transition-all ${activeTab === 'lixeira' ? 'bg-white text-red-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Lixeira
</button>
</div>
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input
type="text"
placeholder="Buscar avaliação..."
className="pl-12 pr-4 py-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none shadow-sm w-full md:w-64 transition-all"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button
onClick={handleStartCreate}
className="flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-200"
>
<Plus size={20} />
Nova Avaliação
</button>
</div>
</div>
{filteredExams.length === 0 ? (
<div className="bg-white rounded-2xl p-12 text-center shadow-sm border border-slate-100 flex flex-col items-center">
<div className="w-20 h-20 bg-indigo-50 rounded-full flex items-center justify-center mb-4">
<FileText size={32} className="text-indigo-300" />
</div>
<h3 className="text-xl font-bold text-slate-700 mb-2">Nenhuma avaliação encontrada</h3>
<p className="text-slate-500 mb-6 max-w-md">Você ainda não criou nenhuma prova ou os filtros não retornaram resultados.</p>
<button
onClick={handleStartCreate}
className="px-6 py-3 bg-indigo-50 text-indigo-700 rounded-xl font-bold hover:bg-indigo-100 transition-colors"
>
Criar Minha Primeira Avaliação
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredExams.map(exam => {
const classObj = dbClasses.find(c => c.id === exam.classId);
return (
<div key={exam.id} className={`rounded-2xl p-6 shadow-sm border transition-shadow relative overflow-hidden group ${exam.isDeleted ? 'bg-slate-50 border-slate-200 opacity-80' : 'bg-white border-slate-100 hover:shadow-md'}`}>
<div className={`absolute top-0 left-0 w-1.5 h-full rounded-l-2xl ${exam.isDeleted ? 'bg-slate-400' : 'bg-indigo-500'}`}></div>
<div className="flex justify-between items-start mb-4">
<div className="flex flex-col gap-1">
<h3 className="font-bold text-lg text-slate-800 line-clamp-2 pr-4">{exam.title}</h3>
<div className="flex items-center gap-2 mt-1">
<span className={`px-2 py-0.5 text-[10px] font-black uppercase tracking-wider rounded-md ${(exam as any).evaluationType === 'activity' ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'
}`}>
{(exam as any).evaluationType === 'activity' ? 'Atividade' : 'Prova'}
</span>
<span className="text-xs font-bold text-slate-500">
Vale: {(exam as any).maxScore ?? 10} pts
</span>
</div>
</div>
<span className={`px-2.5 py-1 text-[10px] font-black uppercase tracking-wider rounded-lg shrink-0 mt-1 ${exam.isDeleted ? 'bg-slate-200 text-slate-500' : (exam.status === 'published' ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700')
}`}>
{exam.isDeleted ? 'Excluída' : (exam.status === 'published' ? 'Publicada' : 'Rascunho')}
</span>
</div>
<div className="space-y-2 mb-6">
<p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Turma:</span>
{classObj?.name || 'Turma não encontrada'}
</p>
<p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Questões:</span>
{exam.questions?.length || 0}
</p>
<p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Duração:</span>
{exam.durationMinutes} min
</p>
{exam.subjectId && (
<p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Disciplina:</span>
{(dbSubjects || []).find(s => s.id === exam.subjectId)?.name || '—'}
</p>
)}
{exam.periodId && (
<p className="text-sm text-slate-500 flex items-center gap-2">
<span className="font-bold text-slate-700">Período:</span>
{(data.periods || []).find(p => p.id === exam.periodId)?.name || '—'}
</p>
)}
{/* ALERTA DE BOLETIM */}
{exam.status === 'published' && (!exam.subjectId || !exam.periodId) && (
<div className="flex items-start gap-2 bg-amber-50 p-3 rounded-xl border border-amber-200 mt-3">
<AlertTriangle size={16} className="text-amber-500 shrink-0 mt-0.5" />
<p className="text-[11px] font-medium text-amber-700 leading-tight">
<strong>Boletim Desconectado!</strong><br />
As notas desta avaliação não aparecerão no boletim do aluno porque faltou vincular a Disciplina ou o Período. Edite a prova para corrigir.
</p>
</div>
)}
</div>
<div className="border-t border-slate-100 pt-4 flex items-center justify-between">
{exam.isDeleted ? (
<div className="flex items-center gap-2 w-full">
<button
onClick={() => handleRestoreExam(exam.id)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-slate-200 text-slate-700 rounded-xl font-bold hover:bg-slate-300 transition-colors"
>
<RefreshCw size={18} /> Reativar
</button>
<button
onClick={() => handlePermanentDelete(exam.id)}
className="flex items-center justify-center gap-2 px-4 py-2 bg-red-100 text-red-600 rounded-xl font-bold hover:bg-red-200 transition-colors"
title="Excluir permanentemente"
>
<Trash2 size={18} />
</button>
</div>
) : (
<>
<div className="flex items-center gap-2">
<button
onClick={() => handleToggleRetake(exam.id)}
className={`p-2 rounded-lg transition-colors ${exam.allowRetake ? 'bg-emerald-50 text-emerald-600 hover:bg-emerald-100' : 'bg-slate-50 text-slate-400 hover:bg-slate-100'}`}
title={exam.allowRetake ? 'Refação Permitida (Clique para bloquear)' : 'Refação Bloqueada (Clique para permitir)'}
>
{exam.allowRetake ? <Unlock size={18} /> : <Lock size={18} />}
</button>
<button
onClick={() => {
setDuplicatingExam(exam);
setTargetClassId(exam.classId);
}}
className="p-2 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 rounded-lg transition-colors"
title="Duplicar para outra turma"
>
<Copy size={18} />
</button>
<button
onClick={() => handleNotifyStudents(exam)}
className="p-2 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 rounded-lg transition-colors"
title="Notificar Turma"
>
<Bell size={18} />
</button>
<button
onClick={() => handleDeleteExam(exam.id)}
className="p-2 bg-red-50 text-red-500 hover:bg-red-100 rounded-lg transition-colors"
title="Mover para Lixeira"
>
<Trash2 size={18} />
</button>
</div>
<button
onClick={() => handleEditExam(exam)}
className="text-sm font-bold text-indigo-600 hover:text-indigo-800 flex items-center gap-1 group-hover:translate-x-1 transition-transform"
>
Editar <ArrowLeft size={16} className="rotate-180" />
</button>
</>
)}
</div>
</div>
);
})}
</div>
)}
{/* MODAL DUPLICAR */}
{duplicatingExam && (
<div className="fixed inset-0 bg-transparent z-[60] flex items-center justify-center p-4 animate-in fade-in duration-200">
<div className="bg-white rounded-3xl w-full max-w-md p-8 shadow-2xl animate-slide-up">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-indigo-100 text-indigo-600 rounded-xl">
<Copy size={24} />
</div>
<h3 className="text-xl font-black text-slate-800">Duplicar Avaliação</h3>
</div>
<div className="space-y-4">
<p className="text-sm text-slate-500 font-medium">
Escolha a turma que receberá uma cópia de: <br />
<strong className="text-slate-800">{duplicatingExam.title}</strong>
</p>
<div>
<label className="block text-xs font-black text-slate-400 uppercase tracking-widest mb-2">Turma de Destino</label>
<select
value={targetClassId}
onChange={e => setTargetClassId(e.target.value)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none transition-all font-bold text-slate-700"
>
{dbClasses.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={() => setDuplicatingExam(null)}
className="flex-1 px-6 py-3 bg-slate-100 text-slate-600 rounded-xl font-bold hover:bg-slate-200 transition-all"
>
Cancelar
</button>
<button
onClick={handleDuplicateExam}
className="flex-1 px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100"
>
Duplicar
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default Exams;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,648 @@
import React, { useState } from 'react';
import {
Book,
Plus,
Trash2,
CheckCircle,
XCircle,
DollarSign,
Package,
Users,
ChevronRight,
Search,
AlertCircle,
Save,
X,
RefreshCw,
Edit
} from 'lucide-react';
import { SchoolData, Handout, HandoutDelivery, Class, Student } from '../types';
import { dbService } from '../services/dbService';
import { useDialog } from '../DialogContext';
import { supabase, isSupabaseConfigured } from '../services/supabase';
interface HandoutsProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const Handouts: React.FC<HandoutsProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [showAddHandout, setShowAddHandout] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [selectedClass, setSelectedClass] = useState<Class | null>(null);
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState<'classes' | 'individual'>('classes');
const [isSyncing, setIsSyncing] = useState(false);
const [editingHandoutId, setEditingHandoutId] = useState<string | null>(null);
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setShowAddHandout(false);
setSelectedClass(null);
setSelectedStudent(null);
setIsClosing(false);
setEditingHandoutId(null);
setNewHandout({ name: '', price: 0 });
}, 400);
};
// Sync Asaas payments on mount
React.useEffect(() => {
syncAsaasPayments();
}, []);
// Auto-sync handout payment status with Finance payments
React.useEffect(() => {
if (!data.payments || !data.handoutDeliveries) return;
const currentDeliveries = data.handoutDeliveries;
const currentPayments = data.payments;
const currentHandouts = data.handouts || [];
const updatedDeliveries = currentDeliveries.map(delivery => {
if (delivery.paymentStatus === 'pending') {
const handout = currentHandouts.find(h => h.id === delivery.handoutId);
const isPaidInFinance = currentPayments.some(p =>
p.studentId === delivery.studentId &&
p.status === 'paid' &&
(
(delivery.asaasPaymentId && p.asaasPaymentId === delivery.asaasPaymentId) ||
(handout && p.description && p.description.includes(handout.name))
)
);
if (isPaidInFinance) {
return {
...delivery,
paymentStatus: 'paid' as const,
paymentDate: delivery.paymentDate || new Date().toISOString()
};
}
}
return delivery;
});
const hasChanges = updatedDeliveries.some((d, i) => d.paymentStatus !== currentDeliveries[i].paymentStatus);
if (hasChanges) {
updateData({ handoutDeliveries: updatedDeliveries });
dbService.saveData({ ...data, handoutDeliveries: updatedDeliveries });
}
}, [data.payments, data.handoutDeliveries, data.handouts, updateData, data]);
// Sincronização automática removida conforme pedido
const syncAsaasPayments = () => {
// Função desativada para manter compatibilidade com render e evitar erros de referência
};
// Form state for new handout
const [newHandout, setNewHandout] = useState<Partial<Handout>>({
name: '',
price: 0,
description: '',
finePercentage: 0,
interestPercentage: 0
});
const handouts = data.handouts || [];
const deliveries = data.handoutDeliveries || [];
const classes = data.classes || [];
const students = data.students || [];
const handleAddHandout = () => {
if (!newHandout.name || newHandout.price === undefined) {
showAlert('Erro', 'Por favor, preencha o nome e o preço da apostila.', 'error');
return;
}
let updatedHandouts: Handout[];
if (editingHandoutId) {
updatedHandouts = handouts.map(h =>
h.id === editingHandoutId
? {
...h,
name: newHandout.name!,
price: newHandout.price!,
description: newHandout.description,
finePercentage: newHandout.finePercentage || 0,
interestPercentage: newHandout.interestPercentage || 0
}
: h
);
showAlert('Sucesso', 'Apostila atualizada com sucesso!', 'success');
} else {
const handout: Handout = {
id: crypto.randomUUID(),
name: newHandout.name,
price: newHandout.price,
description: newHandout.description,
finePercentage: newHandout.finePercentage || 0,
interestPercentage: newHandout.interestPercentage || 0
};
updatedHandouts = [...handouts, handout];
showAlert('Sucesso', 'Apostila adicionada com sucesso!', 'success');
}
updateData({ handouts: updatedHandouts });
dbService.saveData({ ...data, handouts: updatedHandouts });
setNewHandout({ name: '', price: 0, description: '', finePercentage: 0, interestPercentage: 0 });
setShowAddHandout(false);
setEditingHandoutId(null);
};
const handleEditHandout = (handout: Handout) => {
setNewHandout({
name: handout.name,
price: handout.price,
description: handout.description || '',
finePercentage: handout.finePercentage || 0,
interestPercentage: handout.interestPercentage || 0
});
setEditingHandoutId(handout.id);
setShowAddHandout(true);
};
const handleDeleteHandout = (id: string) => {
showConfirm(
'Excluir Apostila',
'Tem certeza que deseja excluir esta apostila? Isso removerá todos os registros de entrega vinculados.',
() => {
const updatedHandouts = handouts.filter(h => h.id !== id);
const updatedDeliveries = deliveries.filter(d => d.handoutId !== id);
updateData({ handouts: updatedHandouts, handoutDeliveries: updatedDeliveries });
dbService.saveData({ ...data, handouts: updatedHandouts, handoutDeliveries: updatedDeliveries });
}
);
};
const toggleDeliveryStatus = (studentId: string, handoutId: string) => {
const existing = deliveries.find(d => d.studentId === studentId && d.handoutId === handoutId);
let updatedDeliveries: HandoutDelivery[];
if (existing) {
updatedDeliveries = deliveries.map(d =>
(d.studentId === studentId && d.handoutId === handoutId)
? {
...d,
deliveryStatus: d.deliveryStatus === 'delivered' ? 'pending' : 'delivered',
deliveryDate: d.deliveryStatus === 'delivered' ? undefined : new Date().toISOString()
}
: d
);
} else {
updatedDeliveries = [
...deliveries,
{
id: crypto.randomUUID(),
studentId,
handoutId,
deliveryStatus: 'delivered',
paymentStatus: 'pending',
deliveryDate: new Date().toISOString()
}
];
}
updateData({ handoutDeliveries: updatedDeliveries });
dbService.saveData({ ...data, handoutDeliveries: updatedDeliveries });
};
const togglePaymentStatus = (studentId: string, handoutId: string) => {
const existing = deliveries.find(d => d.studentId === studentId && d.handoutId === handoutId);
let updatedDeliveries: HandoutDelivery[];
if (existing) {
updatedDeliveries = deliveries.map(d =>
(d.studentId === studentId && d.handoutId === handoutId)
? {
...d,
paymentStatus: d.paymentStatus === 'paid' ? 'pending' : 'paid',
paymentDate: d.paymentStatus === 'paid' ? undefined : new Date().toISOString()
}
: d
);
} else {
updatedDeliveries = [
...deliveries,
{
id: crypto.randomUUID(),
studentId,
handoutId,
deliveryStatus: 'pending',
paymentStatus: 'paid',
paymentDate: new Date().toISOString()
}
];
}
updateData({ handoutDeliveries: updatedDeliveries });
dbService.saveData({ ...data, handoutDeliveries: updatedDeliveries });
};
const getStudentDelivery = (studentId: string, handoutId: string) => {
return deliveries.find(d => d.studentId === studentId && d.handoutId === handoutId);
};
const filteredClasses = classes.filter(c =>
(c.name || '').toLowerCase().includes((searchTerm || '').toLowerCase())
);
const filteredStudents = students.filter(s =>
(s.name || '').toLowerCase().includes((searchTerm || '').toLowerCase()) ||
(s.cpf || '').includes(searchTerm || '')
);
return (
<div className="space-y-8 animate-in fade-in duration-300 pb-20">
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Gestão de Apostilas</h2>
<p className="text-slate-500 font-medium">Cadastre livros e gerencie entregas e pagamentos.</p>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex flex-wrap gap-2">
<button
onClick={() => setShowAddHandout(true)}
className="flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100"
>
<Plus size={20} /> Adicionar Apostila
</button>
</div>
</div>
</header>
{/* Handouts List */}
<section className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm space-y-6">
<div className="flex items-center gap-3 text-indigo-600">
<div className="p-2 bg-indigo-50 rounded-lg">
<Book size={20} />
</div>
<h3 className="text-lg font-black text-slate-800">Apostilas Cadastradas</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{handouts.map(handout => (
<div key={handout.id} className="p-4 bg-slate-50 rounded-xl border border-slate-100 flex justify-between items-start group">
<div>
<h4 className="font-bold text-slate-800">{handout.name}</h4>
<p className="text-xs text-slate-500">{handout.description || 'Sem descrição'}</p>
<p className="text-sm font-black text-indigo-600 mt-2">R$ {handout.price.toFixed(2)}</p>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-all">
<button
onClick={() => handleEditHandout(handout)}
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-all"
title="Editar"
>
<Edit size={16} />
</button>
<button
onClick={() => handleDeleteHandout(handout.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all"
title="Excluir"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
{handouts.length === 0 && (
<div className="col-span-full py-8 text-center text-slate-400 italic text-sm">Nenhuma apostila cadastrada.</div>
)}
</div>
</section>
{/* Management Tabs */}
<section className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex bg-slate-100 p-1 rounded-xl w-fit">
<button
onClick={() => { setActiveTab('classes'); setSearchTerm(''); }}
className={`px-6 py-2 rounded-lg text-xs font-black transition-all ${activeTab === 'classes' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
POR TURMA
</button>
<button
onClick={() => { setActiveTab('individual'); setSearchTerm(''); }}
className={`px-6 py-2 rounded-lg text-xs font-black transition-all ${activeTab === 'individual' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
POR ALUNO
</button>
</div>
<div className="relative w-full md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder={activeTab === 'classes' ? "Buscar turma..." : "Buscar aluno..."}
className="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{activeTab === 'classes' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredClasses.map(cls => (
<button
key={cls.id}
onClick={() => setSelectedClass(cls)}
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-md hover:border-indigo-200 transition-all text-left group"
>
<div className="flex justify-between items-start mb-4">
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-xl group-hover:bg-indigo-600 group-hover:text-white transition-colors">
<Users size={24} />
</div>
<ChevronRight size={20} className="text-slate-300 group-hover:text-indigo-400 transition-colors" />
</div>
<h4 className="text-lg font-black text-slate-800 mb-1">{cls.name}</h4>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">
{students.filter(s => s.classId === cls.id).length} Alunos
</p>
</button>
))}
{filteredClasses.length === 0 && (
<div className="col-span-full py-12 text-center text-slate-400 italic">Nenhuma turma encontrada.</div>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredStudents.map(student => (
<button
key={student.id}
onClick={() => setSelectedStudent(student)}
className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm hover:shadow-md hover:border-indigo-200 transition-all text-left flex items-center gap-3"
>
<div className="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center text-indigo-600 font-black">
{student.name.charAt(0)}
</div>
<div>
<h4 className="font-bold text-slate-800 text-sm">{student.name}</h4>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
{classes.find(c => c.id === student.classId)?.name || 'Sem Turma'}
</p>
</div>
</button>
))}
{filteredStudents.length === 0 && (
<div className="col-span-full py-12 text-center text-slate-400 italic">Nenhum aluno encontrado.</div>
)}
</div>
)}
</section>
{/* Add Handout Modal */}
{showAddHandout && (
<div className={`fixed inset-0 bg-transparent z-[100] flex items-center justify-center p-4 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white w-full max-w-md rounded-2xl shadow-2xl my-auto transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-2xl font-black text-slate-800 tracking-tight">{editingHandoutId ? 'Editar Apostila' : 'Nova Apostila'}</h3>
<p className="text-sm text-slate-500 font-medium">Cadastre e configure os dados do material.</p>
</div>
<button
onClick={closeModal}
className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90"
>
<X size={24} />
</button>
</div>
<div className="p-8 space-y-4">
<div>
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Nome da Apostila / Livro</label>
<input
type="text"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
placeholder="Ex: Apostila de Inglês Vol 1"
value={newHandout.name}
onChange={(e) => setNewHandout({...newHandout, name: e.target.value})}
/>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Preço (R$)</label>
<input
type="number"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
placeholder="0.00"
value={newHandout.price}
onChange={(e) => setNewHandout({...newHandout, price: parseFloat(e.target.value)})}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Multa (%)</label>
<input
type="number"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
placeholder="0"
value={newHandout.finePercentage}
onChange={(e) => setNewHandout({...newHandout, finePercentage: parseFloat(e.target.value)})}
/>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Juros ao Mês (%)</label>
<input
type="number"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
placeholder="0"
value={newHandout.interestPercentage}
onChange={(e) => setNewHandout({...newHandout, interestPercentage: parseFloat(e.target.value)})}
/>
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Descrição (Opcional)</label>
<textarea
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm h-24 resize-none"
placeholder="Detalhes sobre o material..."
value={newHandout.description}
onChange={(e) => setNewHandout({...newHandout, description: e.target.value})}
/>
</div>
<button
onClick={handleAddHandout}
className="w-full py-4 bg-indigo-600 text-white rounded-2xl font-bold text-sm hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 flex items-center justify-center gap-2"
>
<Save size={20} /> Salvar Apostila
</button>
</div>
</div>
</div>
)}
{/* Individual Student Management Modal */}
{selectedStudent && (
<div className={`fixed inset-0 bg-transparent z-[100] flex items-center justify-center p-4 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white w-full max-w-2xl rounded-2xl shadow-2xl flex flex-col my-auto transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-white shadow-md flex items-center justify-center text-indigo-600 font-black text-2xl border border-indigo-50">
{selectedStudent.name.charAt(0)}
</div>
<div>
<h3 className="text-2xl font-black text-slate-800 tracking-tight">{selectedStudent.name}</h3>
<p className="text-sm text-slate-500 font-bold uppercase tracking-widest">
{classes.find(c => c.id === selectedStudent.classId)?.name || 'Sem Turma'}
</p>
</div>
</div>
<button onClick={closeModal} className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90">
<X size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar space-y-6">
{handouts.length === 0 ? (
<div className="text-center py-12 text-slate-400 italic">Nenhuma apostila cadastrada.</div>
) : (
<div className="grid grid-cols-1 gap-4">
{handouts.map(handout => {
const delivery = getStudentDelivery(selectedStudent.id, handout.id);
return (
<div key={handout.id} className="bg-slate-50 p-4 rounded-xl border border-slate-100 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h5 className="font-bold text-slate-800">{handout.name}</h5>
<p className="text-xs font-black text-indigo-600">R$ {handout.price.toFixed(2)}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => toggleDeliveryStatus(selectedStudent.id, handout.id)}
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all flex items-center gap-2 ${
delivery?.deliveryStatus === 'delivered'
? 'bg-emerald-100 text-emerald-700 border border-emerald-200'
: 'bg-white text-slate-400 border border-slate-200 hover:bg-slate-50'
}`}
>
<Package size={14} />
{delivery?.deliveryStatus === 'delivered' ? 'Entregue' : 'Entrega'}
</button>
<button
onClick={() => togglePaymentStatus(selectedStudent.id, handout.id)}
className={`flex-1 md:flex-none px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all flex items-center gap-2 ${
delivery?.paymentStatus === 'paid'
? 'bg-amber-100 text-amber-700 border border-amber-200'
: 'bg-white text-slate-400 border border-slate-200 hover:bg-slate-50'
}`}
>
<DollarSign size={14} />
{delivery?.paymentStatus === 'paid' ? 'Pago' : 'Pagamento'}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
)}
{/* Class Management Modal */}
{selectedClass && (
<div className={`fixed inset-0 bg-transparent z-[100] flex items-center justify-center p-4 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white w-full max-w-5xl h-[85vh] rounded-2xl shadow-2xl flex flex-col my-auto transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-2xl font-black text-slate-800 tracking-tight">{selectedClass.name}</h3>
<p className="text-sm text-slate-500 font-bold uppercase tracking-widest">Controle de Entregas por Turma</p>
</div>
<button onClick={closeModal} className="p-3 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all hover:rotate-90">
<X size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
{handouts.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-slate-400 space-y-4">
<AlertCircle size={48} className="opacity-20" />
<p className="font-medium">Nenhuma apostila cadastrada para gerenciar.</p>
</div>
) : (
<div className="space-y-8">
{students.filter(s => s.classId === selectedClass.id).map(student => (
<div key={student.id} className="bg-slate-50 rounded-2xl p-6 border border-slate-100 space-y-4">
<div className="flex items-center gap-3 border-b border-slate-200 pb-4">
<div className="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-700 font-black">
{student.name.charAt(0)}
</div>
<h4 className="font-black text-slate-800">{student.name}</h4>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{handouts.map(handout => {
const delivery = getStudentDelivery(student.id, handout.id);
return (
<div key={handout.id} className="bg-white p-4 rounded-xl border border-slate-200 space-y-4 shadow-sm">
<div className="flex justify-between items-start">
<h5 className="font-bold text-slate-700 text-sm">{handout.name}</h5>
<span className="text-[10px] font-black text-indigo-600 bg-indigo-50 px-2 py-1 rounded">R$ {handout.price.toFixed(2)}</span>
</div>
<div className="flex gap-2">
<button
onClick={() => toggleDeliveryStatus(student.id, handout.id)}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
delivery?.deliveryStatus === 'delivered'
? 'bg-emerald-100 text-emerald-700 border border-emerald-200'
: 'bg-slate-100 text-slate-400 border border-slate-200 hover:bg-slate-200'
}`}
>
<Package size={14} />
{delivery?.deliveryStatus === 'delivered' ? 'Entregue' : 'Entrega'}
</button>
<button
onClick={() => togglePaymentStatus(student.id, handout.id)}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
delivery?.paymentStatus === 'paid'
? 'bg-amber-100 text-amber-700 border border-amber-200'
: 'bg-slate-100 text-slate-400 border border-slate-200 hover:bg-slate-200'
}`}
>
<DollarSign size={14} />
{delivery?.paymentStatus === 'paid' ? 'Pago' : 'Pagamento'}
</button>
</div>
{(delivery?.deliveryDate || delivery?.paymentDate) && (
<div className="pt-2 border-t border-slate-100 space-y-1">
{delivery.deliveryDate && (
<p className="text-[9px] text-slate-400 flex items-center gap-1">
<CheckCircle size={10} className="text-emerald-500" />
Entrega: {new Date(delivery.deliveryDate).toLocaleDateString()}
</p>
)}
{delivery.paymentDate && (
<p className="text-[9px] text-slate-400 flex items-center gap-1">
<CheckCircle size={10} className="text-amber-500" />
Pagamento: {new Date(delivery.paymentDate).toLocaleDateString()}
</p>
)}
</div>
)}
</div>
);
})}
</div>
</div>
))}
{students.filter(s => s.classId === selectedClass.id).length === 0 && (
<div className="text-center py-12 text-slate-400 italic">Nenhum aluno nesta turma.</div>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default Handouts;

View File

@ -0,0 +1,898 @@
import React, { useState } from 'react';
import { SchoolData, Class, Lesson, Notification } from '../types';
import { useDialog } from '../DialogContext';
import { Calendar, Plus, X, AlertCircle, RefreshCw, Send, CheckCircle, Search, Clock, Trash2 } from 'lucide-react';
import { dbService } from '../services/dbService';
interface LessonScheduleProps {
classObj: Class;
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
onClose: () => void;
}
const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateData, onClose }) => {
const { showAlert, showConfirm } = useDialog();
const [dbLessons, setDbLessons] = useState<Lesson[]>([]);
const loadLessons = async () => {
try {
const res = await fetch(`/api/aulas?turma_id=${classObj.id}&t=${Date.now()}`);
if (res.ok) {
const json = await res.json();
setDbLessons(json.aulas || []);
}
} catch(e) { console.error(e); }
};
React.useEffect(() => { loadLessons(); }, [classObj.id]);
const [dbClasses, setDbClasses] = useState<any[]>(data?.classes || []);
const [dbCourses, setDbCourses] = useState<any[]>(data?.courses || []);
React.useEffect(() => {
Promise.all([
fetch('/api/turmas').catch(() => ({ ok: false, json: async () => ({}) })),
fetch('/api/cursos').catch(() => ({ ok: false, json: async () => ({}) })),
]).then(async (responses) => {
const [resT, resC] = responses;
if (resT && resT.ok) {
const jsonT = await resT.json();
if (jsonT.turmas) setDbClasses(jsonT.turmas.map((t: any) => ({
id: t.id, name: t.nome, courseId: t.curso_id, maxStudents: Number(t.max_alunos || 0)
})));
}
if (resC && resC.ok) {
const jsonC = await resC.json();
if (jsonC.cursos) setDbCourses(jsonC.cursos.map((c: any) => ({
id: c.id, name: c.nome, monthlyFee: Number(c.mensalidade || 0), registrationFee: Number(c.taxa_matricula || 0)
})));
}
}).catch(console.error);
}, []);
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [showLessonDetail, setShowLessonDetail] = useState<Lesson | null>(null);
const [isClosing, setIsClosing] = useState(false);
// Form states for generation
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [dayOfWeek, setDayOfWeek] = useState('1');
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [extraCount, setExtraCount] = useState<number | ''>('');
React.useEffect(() => {
if (extraCount && startDate && dayOfWeek) {
let current = new Date(startDate + 'T12:00:00Z');
const day = parseInt(dayOfWeek, 10);
while (current.getUTCDay() !== day) {
current.setUTCDate(current.getUTCDate() + 1);
}
current.setUTCDate(current.getUTCDate() + (7 * (Number(extraCount) - 1)));
setEndDate(current.toISOString().split('T')[0]);
}
}, [extraCount, startDate, dayOfWeek]);
// Form states for cancellation
const [cancelReason, setCancelReason] = useState('');
const [wantReplacement, setWantReplacement] = useState(false);
const [replacementDate, setReplacementDate] = useState('');
const [replacementStartTime, setReplacementStartTime] = useState('');
const [replacementEndTime, setReplacementEndTime] = useState('');
const checkCollision = (date: string, start: string, end: string, ignoreLessonId?: string) => {
return dbLessons.find(l => {
// Ignore if it's the lesson being replaced (if any) or if it's cancelled
if (l.id === ignoreLessonId || l.status === 'cancelled') return false;
if (l.date !== date) return false;
if (!l.startTime || !l.endTime) return false;
// Só dá conflito se for na mesma TURMA ou com o mesmo PROFESSOR
const isSameClass = l.classId === classObj.id;
const otherClass = dbClasses.find(c => c.id === l.classId);
const isSameTeacher = otherClass && classObj.teacher && otherClass.teacher === classObj.teacher;
if (!isSameClass && !isSameTeacher) return false;
// Regra: NovoInicio < HorarioFimExistente AND NovoFim > HorarioInicioExistente
return (start < l.endTime) && (end > l.startTime);
});
};
const classLessons = dbLessons
.filter(l => l.classId === classObj.id)
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const handleGenerateLessons = () => {
if (!startDate || !endDate || !dayOfWeek || !startTime || !endTime) {
showAlert('Atenção', 'Preencha todos os campos, incluindo horários de início e término.', 'warning');
return;
}
if (startTime >= endTime) {
showAlert('Atenção', 'O horário de término deve ser maior que o de início.', 'warning');
return;
}
// Bloqueio de retroatividade removido conforme solicitado.
const start = new Date(startDate);
const end = new Date(endDate);
const day = parseInt(dayOfWeek, 10);
const newLessons: Lesson[] = [];
const ignoredDates: string[] = [];
// Increment date until finding the exact day
let current = new Date(start);
while (current.getUTCDay() !== day) {
current.setUTCDate(current.getUTCDate() + 1);
}
while (current <= end) {
const dateStr = current.toISOString().split('T')[0];
// Validação de Choque de Horários
if (checkCollision(dateStr, startTime, endTime)) {
ignoredDates.push(new Date(dateStr + 'T12:00:00Z').toLocaleDateString('pt-BR'));
} else {
newLessons.push({
id: crypto.randomUUID(),
classId: classObj.id,
date: dateStr,
startTime,
endTime,
status: 'scheduled',
type: 'extra'
});
}
current.setUTCDate(current.getUTCDate() + 7); // advance one week
}
if (newLessons.length === 0 && ignoredDates.length === 0) {
showAlert('Atenção', 'Nenhuma data encontrada nesse período para o dia da semana selecionado.', 'warning');
return;
}
if (newLessons.length === 0 && ignoredDates.length > 0) {
showAlert('⚠️ Choque de Horários!', `Nenhuma aula gerada. Todas as datas pretendidas deram choque com horários existentes: ${ignoredDates.join(', ')}`, 'warning');
return;
}
// Salvar no Banco
fetch('/api/aulas/lote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aulas: newLessons })
}).then(() => loadLessons());
// Notificar alunos sobre novas aulas extras geradas
const datesList = newLessons.map(l => new Date(l.date + 'T12:00:00Z').toLocaleDateString('pt-BR')).join(', ');
const notifMsg = `Novas aulas extras foram agendadas para os dias: ${datesList} (${startTime} às ${endTime}).`;
const waMsg = `📅 *Novas Aulas Extras Agendadas!*\n\nOlá, {nome}!\nInformamos que foram agendadas novas aulas extras para a turma *${classObj.name}*.\n\n*Datas:* ${datesList}\n*Horário:* ${startTime} às ${endTime}\n\nAguardamos você!`;
const newNotifs = notifyLessonAction('Aulas Extras Agendadas', notifMsg, waMsg);
const updatedNotifications = [...(data.notifications || []), ...newNotifs];
updateData({ notifications: updatedNotifications });
dbService.saveData({ ...data, notifications: updatedNotifications });
setShowGenerateModal(false);
if (ignoredDates.length > 0) {
showAlert('Aviso de Agendamento Parcial', `Aulas geradas, porém os dias ${ignoredDates.join(', ')} foram ignorados devido a choque de horário no mesmo intervalo (⚠️ Choque de Horários!).`, 'warning');
} else {
showAlert('Sucesso', `${newLessons.length} aulas extras geradas e alunos notificados!`, 'success');
}
};
const notifyLessonAction = (title: string, notificationMessage: string, waMessage: string) => {
const students = data.students.filter(s => s.status === 'active' && s.classId === classObj.id);
// Notificações Portal do Aluno
const newNotifs: Notification[] = students.map(s => ({
id: crypto.randomUUID(),
studentId: s.id,
title,
message: notificationMessage,
read: false,
createdAt: new Date().toISOString()
}));
// Mensagens WhatsApp
try {
const payloadAlunos = students.flatMap(student => {
const birthDateStr = student.birthDate || '';
let age = 18;
if (birthDateStr && birthDateStr.includes('-')) {
const [year, month, day] = birthDateStr.split('-').map(Number);
const birthDate = new Date(year, month - 1, day);
const today = new Date();
age = today.getFullYear() - birthDate.getFullYear();
const m = today.getMonth() - birthDate.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) age--;
}
const isMinor = age < 18;
const targets = [];
if (isMinor) {
// 1. Envia para o Responsável
if (student.guardianPhone?.trim()) {
targets.push({
nome: student.guardianName?.trim() || 'Responsável',
telefone: student.guardianPhone.trim(),
nome_responsavel: student.guardianName,
telefone_responsavel: student.guardianPhone
});
}
// 2. Envia para o Aluno (se ele tiver celular próprio)
if (student.phone?.trim()) {
targets.push({
nome: student.name || 'Aluno',
telefone: student.phone.trim(),
nome_responsavel: student.guardianName,
telefone_responsavel: student.guardianPhone
});
}
} else {
// 3. Regra Maior de Idade (inalterada) - O foco é o próprio aluno
targets.push({
nome: student.name || 'Aluno',
telefone: student.phone?.trim() || student.guardianPhone?.trim() || '',
nome_responsavel: student.guardianName,
telefone_responsavel: student.guardianPhone
});
}
// Remove possíveis contatos sem telefone para não bugar a API
return targets.filter(t => t.telefone);
});
fetch('/api/enviar-massa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alunos: payloadAlunos, mensagem: waMessage })
}).catch(e => console.warn(e));
} catch (e) {
console.warn("Falha silenciosa api enviar-massa", e);
}
return newNotifs;
};
const handleCancelLesson = async (lesson: Lesson) => {
if (!cancelReason) {
showAlert('Atenção', 'Informe o motivo do cancelamento.', 'warning');
return;
}
if (wantReplacement) {
if (!replacementDate || !replacementStartTime || !replacementEndTime) {
showAlert('Atenção', 'Informe a data e os horários da reposição.', 'warning');
return;
}
if (replacementStartTime >= replacementEndTime) {
showAlert('Atenção', 'O horário de término da reposição deve ser maior que o de início.', 'warning');
return;
}
if (checkCollision(replacementDate, replacementStartTime, replacementEndTime, lesson.id)) {
showAlert('⚠️ Choque de Horários!', 'Já existe uma aula marcada para este dia neste intervalo de tempo. Por favor, escolha outro horário.', 'warning');
return;
}
}
setIsClosing(true);
const updatedLessons: Lesson[] = dbLessons.map(l =>
l.id === lesson.id ? { ...l, status: 'cancelled', cancelReason } : l
);
let replacementStr = '';
if (wantReplacement && replacementDate) {
updatedLessons.push({
id: crypto.randomUUID(),
classId: classObj.id,
date: replacementDate,
startTime: replacementStartTime,
endTime: replacementEndTime,
status: 'scheduled',
type: 'reposicao',
originalLessonId: lesson.id
});
replacementStr = `\n✅ *Reposição agendada:* ${new Date(replacementDate + 'T12:00:00Z').toLocaleDateString('pt-BR')}`;
}
const lessonDateStr = new Date(lesson.date + 'T12:00:00Z').toLocaleDateString('pt-BR');
const notifMsg = `A aula do dia ${lessonDateStr} foi cancelada. Motivo: ${cancelReason}. ${wantReplacement ? `Uma reposição foi agendada para o dia ${new Date(replacementDate + 'T12:00:00Z').toLocaleDateString('pt-BR')}.` : ''}`;
const waMsg = `🚨 *Aviso Importante: Aula Cancelada*\n\nOlá, {nome}!\nInformamos que a aula da turma *${classObj.name}* do dia *${lessonDateStr}* foi cancelada.\n\n*Motivo:* ${cancelReason}${replacementStr}\n\nAgradecemos a compreensão.`;
const newNotifs = notifyLessonAction('Aula Cancelada', notifMsg, waMsg);
const updatedNotifications = [...(data.notifications || []), ...newNotifs];
fetch('/api/aulas/lote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aulas: updatedLessons })
}).then(() => loadLessons());
updateData({ notifications: updatedNotifications });
await dbService.saveData({ ...data, notifications: updatedNotifications });
setTimeout(() => {
setShowLessonDetail(null);
setIsClosing(false);
setCancelReason('');
setWantReplacement(false);
setReplacementDate('');
setReplacementStartTime('');
setReplacementEndTime('');
showAlert('Sucesso', 'Aula cancelada e alunos notificados.', 'success');
}, 400);
};
const handleUncancelLesson = async (lesson: Lesson) => {
setIsClosing(true);
const updatedLessons: Lesson[] = dbLessons.map(l =>
l.id === lesson.id ? { ...l, status: 'scheduled', cancelReason: undefined } : l
);
fetch('/api/aulas/lote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aulas: updatedLessons })
}).then(() => loadLessons());
setTimeout(() => {
setShowLessonDetail(null);
setIsClosing(false);
showAlert('Sucesso', 'Aula reativada com sucesso.', 'success');
}, 400);
};
const handleRescheduleLesson = async (lesson: Lesson) => {
if (!replacementDate || !replacementStartTime || !replacementEndTime) {
showAlert('Atenção', 'Informe nova data e horários.', 'warning');
return;
}
if (replacementStartTime >= replacementEndTime) {
showAlert('Atenção', 'Horário de término deve ser maior que o de início.', 'warning');
return;
}
if (checkCollision(replacementDate, replacementStartTime, replacementEndTime, lesson.id)) {
showAlert('⚠️ Choque de Horários!', 'Já existe uma aula marcada para este intervalo de tempo.', 'warning');
return;
}
if (!cancelReason) {
showAlert('Atenção', 'Informe o motivo do reagendamento.', 'warning');
return;
}
setIsClosing(true);
const updatedClassLessons: Lesson[] = dbLessons.map(l =>
l.id === lesson.id ? { ...l, date: replacementDate, startTime: replacementStartTime, endTime: replacementEndTime, status: 'rescheduled', type: l.type, cancelReason: undefined } : l
);
// Remove qualquer registro de falta (absence) gerado automaticamente para esta aula (Regra 21)
const updatedAttendance = (data.attendance || []).filter(
a => !(a.lessonId === lesson.id && a.type === 'absence')
);
const oldDateStr = new Date(lesson.date + 'T12:00:00Z').toLocaleDateString('pt-BR');
const newDateStr = new Date(replacementDate + 'T12:00:00Z').toLocaleDateString('pt-BR');
const notifMsg = `A aula do dia ${oldDateStr} foi reagendada para ${newDateStr} (${replacementStartTime} às ${replacementEndTime}). Motivo: ${cancelReason}.`;
const waMsg = `📅 *Aviso de Reagendamento*\n\nOlá, {nome}!\nInformamos que a aula da turma *${classObj.name}* originalmente do dia *${oldDateStr}* foi reagendada.\n\n*Nova Data:* ${newDateStr}\n*Novo Horário:* ${replacementStartTime} às ${replacementEndTime}\n*Motivo:* ${cancelReason}\n\nAgradecemos a compreensão!`;
const newNotifs = notifyLessonAction('Aula Reagendada', notifMsg, waMsg);
const updatedNotifications = [...(data.notifications || []), ...newNotifs];
fetch('/api/aulas/lote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aulas: updatedClassLessons })
}).then(() => loadLessons());
updateData({ notifications: updatedNotifications, attendance: updatedAttendance });
await dbService.saveData({ ...data, notifications: updatedNotifications, attendance: updatedAttendance });
setTimeout(() => {
setShowLessonDetail(null);
setIsClosing(false);
setReplacementDate('');
setReplacementStartTime('');
setReplacementEndTime('');
showAlert('Sucesso', 'Aula reagendada com sucesso.', 'success');
}, 400);
};
const handleCancelAllFuture = () => {
showConfirm('Cancelar Cronograma', 'Deseja realmente cancelar TODAS as aulas futuras não realizadas? Não haverá reposição e a ação atualizará todas para Cancelada.', async () => {
const today = new Date().toISOString().split('T')[0];
const updatedLessons = dbLessons.map(l => {
if (l.status === 'scheduled' && l.date >= today) {
return { ...l, status: 'cancelled', cancelReason: 'Cancelamento Geral de Cronograma' };
}
return l;
});
fetch('/api/aulas/lote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aulas: updatedLessons })
}).then(() => loadLessons());
showAlert('Sucesso', 'Cronograma futuro cancelado.', 'success');
});
};
const handleUncancelAllFuture = () => {
showConfirm('Reativar Cronograma', 'Deseja realmente reativar TODAS as aulas futuras que estavam canceladas?', async () => {
const today = new Date().toISOString().split('T')[0];
const updatedLessons = dbLessons.map(l => {
if (l.status === 'cancelled' && l.date >= today) {
return { ...l, status: 'scheduled', cancelReason: undefined };
}
return l;
});
fetch('/api/aulas/lote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aulas: updatedLessons })
}).then(() => loadLessons());
showAlert('Sucesso', 'Cronograma futuro reativado com sucesso.', 'success');
});
};
const handleDeleteAllSchedule = () => {
showConfirm('Excluir Cronograma Completo', '⚠️ Tem certeza? Isso removerá TODAS as aulas desta turma permanentemente (agendadas, canceladas e reposições). Esta ação NÃO pode ser desfeita.', async () => {
// Deletar do PostgreSQL
const idsToDelete = dbLessons.map(l => l.id);
if (idsToDelete.length > 0) {
await fetch('/api/aulas/lote', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: idsToDelete })
});
}
await loadLessons();
showAlert('Sucesso', 'Cronograma completo excluído.', 'success');
});
};
const closeLessonDetail = () => {
setIsClosing(true);
setTimeout(() => {
setShowLessonDetail(null);
setIsClosing(false);
setCancelReason('');
setWantReplacement(false);
setReplacementDate('');
setReplacementStartTime('');
setReplacementEndTime('');
}, 400);
};
return (
<div className="fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto animate-in fade-in duration-300">
<div className="bg-slate-50 rounded-2xl w-full max-w-4xl shadow-2xl relative overflow-hidden flex flex-col max-h-[90vh] animate-slide-up">
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-20"></div>
{/* Header */}
<div className="p-4 md:p-6 border-b border-slate-200 bg-white z-10 sticky top-0">
<div className="flex justify-between items-start mb-3">
<div>
<h3 className="text-2xl font-black text-slate-800 tracking-tight flex items-center gap-2">
<Calendar className="text-indigo-600" /> Cronograma de Aulas
</h3>
<p className="text-sm text-slate-500 font-medium">Turma: {classObj.name}</p>
</div>
<button onClick={onClose} className="p-2.5 bg-slate-100 text-slate-500 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all flex-shrink-0">
<X size={20} />
</button>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={handleDeleteAllSchedule}
className="px-3 py-1.5 bg-red-500 text-white font-bold rounded-lg hover:bg-red-600 transition-colors flex items-center gap-1.5 shadow-sm text-xs"
title="Exclui todo o cronograma permanentemente"
>
<Trash2 size={14} /> Excluir Tudo
</button>
<button
onClick={handleCancelAllFuture}
className="px-3 py-1.5 bg-red-100 text-red-700 font-bold rounded-lg hover:bg-red-200 transition-colors flex items-center gap-1.5 text-xs"
title="Cancela todas as próximas aulas da turma"
>
<AlertCircle size={14} /> Cancelar Todas
</button>
<button
onClick={handleUncancelAllFuture}
className="px-3 py-1.5 bg-emerald-100 text-emerald-700 font-bold rounded-lg hover:bg-emerald-200 transition-colors flex items-center gap-1.5 text-xs"
title="Reativa todas as próximas aulas canceladas"
>
<RefreshCw size={14} /> Reativar Todas
</button>
<button
onClick={() => setShowGenerateModal(true)}
className="px-3 py-1.5 bg-indigo-100 text-indigo-700 font-bold rounded-lg hover:bg-indigo-200 transition-colors flex items-center gap-1.5 text-xs"
>
<Plus size={14} /> Adicionar Extra
</button>
</div>
</div>
{/* Lesson Stats Bar */}
{classLessons.length > 0 && (() => {
const now = new Date();
const totalLessons = classLessons.length;
const completedLessons = classLessons.filter(l => {
if (l.status === 'cancelled') return false;
const lDate = new Date(l.date + 'T12:00:00Z');
if (!l.endTime) return lDate < now;
const [eh, em] = l.endTime.split(':').map(Number);
const lEnd = new Date(lDate);
lEnd.setUTCHours(eh, em, 0, 0);
return now > lEnd;
}).length;
const cancelledLessons = classLessons.filter(l => l.status === 'cancelled').length;
const remainingLessons = totalLessons - completedLessons - cancelledLessons;
const progressPercent = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
return (
<div className="px-6 py-3 bg-white border-b border-slate-100 flex flex-wrap items-center gap-3">
<div className="flex flex-wrap gap-2 text-[11px] font-black flex-1">
<span className="px-2.5 py-1 bg-indigo-50 text-indigo-700 rounded-lg flex items-center gap-1">
<Calendar size={12} /> {totalLessons} Total
</span>
<span className="px-2.5 py-1 bg-emerald-50 text-emerald-700 rounded-lg flex items-center gap-1">
<CheckCircle size={12} /> {completedLessons} Concluídas
</span>
<span className="px-2.5 py-1 bg-amber-50 text-amber-700 rounded-lg flex items-center gap-1">
<Clock size={12} /> {remainingLessons} Restantes
</span>
{cancelledLessons > 0 && (
<span className="px-2.5 py-1 bg-red-50 text-red-600 rounded-lg flex items-center gap-1">
<AlertCircle size={12} /> {cancelledLessons} Canceladas
</span>
)}
</div>
<div className="flex items-center gap-2 min-w-[140px]">
<div className="flex-1 bg-slate-100 h-2 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-[10px] font-black text-slate-500 whitespace-nowrap">{progressPercent}%</span>
</div>
</div>
);
})()}
{/* Content */}
<div className="p-6 overflow-y-auto flex-1">
{classLessons.length === 0 ? (
<div className="py-20 text-center text-slate-400">
<Calendar size={64} className="mx-auto mb-4 opacity-20" />
<p className="font-bold text-xl">Nenhuma aula gerada ainda.</p>
<p className="text-sm mt-2">Clique em "Gerar Aulas do Ano" para preencher o cronograma.</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{classLessons.map(lesson => {
const dateObj = new Date(lesson.date);
const displayDate = new Date(dateObj.getTime() + dateObj.getTimezoneOffset() * 60000);
const now = new Date();
const [startH, startM] = (lesson.startTime || "00:00").split(':').map(Number);
const [endH, endM] = (lesson.endTime || "23:59").split(':').map(Number);
const lessonStart = new Date(displayDate);
lessonStart.setHours(startH, startM, 0);
const lessonEnd = new Date(displayDate);
lessonEnd.setHours(endH, endM, 0);
const isCancelled = lesson.status === 'cancelled';
const isRescheduled = lesson.status === 'rescheduled';
const isCompletedStatus = lesson.status === 'completed' || (now > lessonEnd && !isCancelled);
const isInProgress = !isCancelled && now >= lessonStart && now <= lessonEnd;
const isReposicao = lesson.type === 'reposicao';
const isExtra = lesson.type === 'extra';
const isPast = lessonEnd < now && !isInProgress;
return (
<div
key={lesson.id}
onClick={() => setShowLessonDetail(lesson)}
className={`p-4 rounded-xl border-2 cursor-pointer transition-all hover:scale-105 ${
isCancelled
? 'bg-red-50 border-red-200 opacity-80'
: isInProgress
? 'bg-indigo-50 border-indigo-400 shadow-indigo-100 shadow-lg'
: isPast || isCompletedStatus
? 'bg-slate-50 border-slate-200 opacity-60 grayscale-[0.3]'
: isRescheduled
? 'bg-orange-50 border-orange-300 shadow-sm'
: isExtra
? 'bg-purple-50 border-purple-200'
: isReposicao
? 'bg-emerald-100 border-emerald-300'
: 'bg-emerald-50 border-emerald-100 hover:border-emerald-300'
}`}
>
<div className="text-center">
<p className={`text-2xl font-black mb-0 ${isCancelled ? 'text-red-600 line-through' : 'text-slate-800'}`}>
{displayDate.getDate().toString().padStart(2, '0')}
</p>
<p className={`text-[10px] uppercase font-bold tracking-widest ${isCancelled ? 'text-red-400' : 'text-slate-400'}`}>
{displayDate.toLocaleString('pt-BR', { month: 'short' })} {displayDate.getFullYear()}
</p>
{lesson.startTime && lesson.endTime && (
<p className={`text-[9px] font-black tracking-wider mt-1 ${isCancelled ? 'text-red-400' : isInProgress ? 'text-indigo-600' : 'text-indigo-500'}`}>
{lesson.startTime} - {lesson.endTime}
</p>
)}
<div className="flex flex-wrap justify-center gap-1 mt-2">
{isCancelled && (
<span className="px-2 py-0.5 bg-red-100 text-red-700 text-[9px] font-black uppercase rounded-full">
Cancelada
</span>
)}
{isInProgress && (
<span className="px-2 py-0.5 bg-blue-600 text-white text-[9px] font-black uppercase rounded-full animate-pulse flex items-center gap-1">
<Clock size={8} /> Em andamento
</span>
)}
{isCompletedStatus && !isCancelled && (
<span className="px-2 py-0.5 bg-slate-200 text-slate-600 text-[9px] font-black uppercase rounded-full">
Concluída
</span>
)}
{isRescheduled && !isCancelled && !isReposicao && !isInProgress && !isCompletedStatus && (
<span className="px-2 py-0.5 bg-orange-100 text-orange-700 text-[9px] font-black uppercase rounded-full">
Reagendada
</span>
)}
{isExtra && !isCancelled && (
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 text-[9px] font-black uppercase rounded-full">
Aula Extra
</span>
)}
{isReposicao && !isCancelled && (
<span className="px-2 py-0.5 bg-emerald-100 text-emerald-700 text-[9px] font-black uppercase rounded-full">
Reposição
</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Generate Lessons Modal */}
{showGenerateModal && (
<div className="fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 animate-in fade-in duration-300">
<div className="bg-white rounded-2xl w-full max-w-sm p-6 shadow-2xl animate-slide-up">
<h3 className="text-xl font-black text-slate-800 mb-4">Adicionar Aula Extra</h3>
<div className="space-y-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase">Quantidade de Aulas Adicionais</label>
<input type="number" min="1" className="w-full mt-1 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm"
value={extraCount} onChange={e => setExtraCount(parseInt(e.target.value) || '')} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase">Data Início</label>
<input type="date" className="w-full mt-1 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm"
value={startDate} onChange={e => setStartDate(e.target.value)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase">Data Fim (Automática)</label>
<input type="date" className="w-full mt-1 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm"
value={endDate} onChange={e => setEndDate(e.target.value)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase">Dia da Semana</label>
<select className="w-full mt-1 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm"
value={dayOfWeek} onChange={e => setDayOfWeek(e.target.value)}>
<option value="0">Domingo</option>
<option value="1">Segunda-feira</option>
<option value="2">Terça-feira</option>
<option value="3">Quarta-feira</option>
<option value="4">Quinta-feira</option>
<option value="5">Sexta-feira</option>
<option value="6">Sábado</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase">Horário Início</label>
<input type="time" className="w-full mt-1 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm"
value={startTime} onChange={e => setStartTime(e.target.value)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase">Horário Fim</label>
<input type="time" className="w-full mt-1 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm"
value={endTime} onChange={e => setEndTime(e.target.value)} />
</div>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-100">
<button onClick={() => setShowGenerateModal(false)} className="flex-1 py-3 text-slate-500 font-bold hover:bg-slate-50 rounded-lg transition-colors">Cancelar</button>
<button onClick={handleGenerateLessons} className="flex-1 py-3 bg-indigo-600 text-white font-bold rounded-lg hover:bg-indigo-700 transition-colors">Adicionar</button>
</div>
</div>
</div>
</div>
)}
{/* Lesson Details & Cancellation Modal */}
{showLessonDetail && (
<div className={`fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-2xl w-full max-w-sm overflow-hidden shadow-2xl transition-all duration-400 ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
<div className={`p-6 border-b flex justify-between items-center ${showLessonDetail.status === 'cancelled' ? 'bg-red-50 border-red-100' : 'bg-slate-50 border-slate-100'}`}>
<h3 className="text-xl font-black text-slate-800">Detalhes da Aula</h3>
<button onClick={closeLessonDetail} className="text-slate-400 hover:text-red-500 transition-colors"><X size={20}/></button>
</div>
<div className="p-6">
<p className="text-sm font-bold text-slate-500 mb-1">Data Agendada</p>
<p className="text-2xl font-black text-slate-800">
{new Date(showLessonDetail.date + 'T12:00:00Z').toLocaleDateString('pt-BR')}
</p>
{showLessonDetail.startTime && showLessonDetail.endTime && (
<p className="text-indigo-600 font-bold mb-6 mt-1 flex items-center gap-1.5 text-sm">
<Clock size={16} /> {showLessonDetail.startTime} às {showLessonDetail.endTime}
</p>
)}
{!showLessonDetail.startTime && <div className="mb-6"></div>}
{showLessonDetail.status === 'cancelled' ? (
<div className="space-y-4">
<div className="p-4 bg-red-50 rounded-xl border border-red-100 text-red-800">
<div className="flex items-center gap-2 mb-2">
<AlertCircle size={18} /> <span className="font-bold">Aula Cancelada</span>
</div>
<p className="text-sm"><strong>Motivo:</strong> {showLessonDetail.cancelReason}</p>
</div>
{!wantReplacement ? (
<div className="flex gap-2">
<button
onClick={() => handleUncancelLesson(showLessonDetail)}
className="flex-1 py-4 bg-emerald-500 text-white rounded-xl font-black flex items-center justify-center gap-2 hover:bg-emerald-600 transition-colors shadow-sm"
>
<RefreshCw size={18} /> Reativar
</button>
<button
onClick={() => setWantReplacement(true)}
className="flex-1 py-4 bg-indigo-500 text-white rounded-xl font-black flex items-center justify-center gap-2 hover:bg-indigo-600 transition-colors shadow-sm"
>
<Calendar size={18} /> Reagendar
</button>
</div>
) : (
<div className="p-4 bg-slate-50 rounded-xl border border-slate-200 mt-2 animate-in fade-in slide-in-from-top-2 space-y-4">
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-bold text-slate-700">Reagendar Aula Cancelada</label>
<button onClick={() => setWantReplacement(false)} className="text-slate-400 hover:text-red-500"><X size={16}/></button>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Nova Data</label>
<input type="date" className="w-full p-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
value={replacementDate} onChange={e => setReplacementDate(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Início</label>
<input type="time" className="w-full p-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
value={replacementStartTime} onChange={e => setReplacementStartTime(e.target.value)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Fim</label>
<input type="time" className="w-full p-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
value={replacementEndTime} onChange={e => setReplacementEndTime(e.target.value)} />
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-indigo-500 uppercase tracking-widest mb-1">Motivo do Reagendamento</label>
<textarea
className="w-full p-3 bg-white border border-indigo-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm placeholder-slate-300"
placeholder="Ex: Confirmação de disponibilidade..."
value={cancelReason}
onChange={e => setCancelReason(e.target.value)}
/>
</div>
<button
onClick={() => handleRescheduleLesson(showLessonDetail)}
className="w-full py-3 bg-indigo-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-indigo-700 transition-colors"
>
Salvar e Notificar Alunos
</button>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="p-4 bg-slate-50 rounded-xl border border-slate-200">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" className="w-4 h-4 text-indigo-600 rounded"
checked={wantReplacement} onChange={e => {
setWantReplacement(e.target.checked);
if (e.target.checked) setCancelReason('');
}} />
<span className="text-sm font-bold text-slate-700">Reagendar esta aula (manter existente, alterar dia)</span>
</label>
<p className="text-[10px] text-slate-500 mt-1 mb-2 leading-tight">Marque se deseja apenas trocar a data/horário. Os alunos serão notificados do reagendamento.</p>
{wantReplacement && (
<div className="mt-2 animate-in fade-in slide-in-from-top-2 space-y-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Nova Data</label>
<input type="date" className="w-full p-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
value={replacementDate} onChange={e => setReplacementDate(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Início</label>
<input type="time" className="w-full p-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
value={replacementStartTime} onChange={e => setReplacementStartTime(e.target.value)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Fim</label>
<input type="time" className="w-full p-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
value={replacementEndTime} onChange={e => setReplacementEndTime(e.target.value)} />
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-indigo-500 uppercase tracking-widest mb-1">Motivo da Alteração</label>
<textarea
className="w-full p-3 bg-white border border-indigo-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm placeholder-slate-300"
placeholder="Ex: Mudança a pedido da turma..."
value={cancelReason}
onChange={e => setCancelReason(e.target.value)}
/>
</div>
<button
onClick={() => handleRescheduleLesson(showLessonDetail)}
className="w-full py-3 bg-indigo-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-indigo-700 transition-colors"
>
Salvar e Notificar Alunos
</button>
</div>
)}
</div>
{!wantReplacement && (
<div className="p-4 bg-red-50/50 rounded-xl border border-red-100 mt-4 animate-in fade-in">
<label className="block text-[10px] font-bold text-red-500 uppercase tracking-widest mb-1">Cancelar Aula - Informe o Motivo</label>
<textarea
className="w-full p-3 bg-white border border-red-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-red-500 text-sm placeholder-slate-300"
placeholder="Ex: Doença do professor..."
value={cancelReason}
onChange={e => setCancelReason(e.target.value)}
/>
<button
onClick={() => handleCancelLesson(showLessonDetail)}
className="w-full mt-4 py-4 bg-red-500 text-white rounded-xl font-black flex items-center justify-center gap-2 hover:bg-red-600 transition-colors shadow-lg shadow-red-200"
>
<AlertCircle size={20} /> Cancelar e Notificar
</button>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default LessonSchedule;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,896 @@
import React, { useState, useEffect, useCallback } from 'react';
import { SchoolData, PreMatriculaCampo, PreMatriculaConfig, PreMatriculaInscricao } from '../types';
import {
Plus, Trash2, Save, Eye, EyeOff, Download, GripVertical, Link2,
ClipboardPen, Copy, Check, ChevronDown, ChevronRight, Users, RefreshCw,
FileText, X, Settings2, ArrowLeft, ExternalLink, AlertCircle, UserCheck, Edit, Upload
} from 'lucide-react';
interface Props {
data: SchoolData;
updateData: (d: Partial<SchoolData>) => void;
onConvert?: (preData: any) => void;
}
const FIELD_TYPES: Record<string, string> = {
text: 'Texto', email: 'Email', phone: 'Telefone', cpf: 'CPF',
date: 'Data', select: 'Seleção', textarea: 'Texto Longo', number: 'Número',
file: 'Imagem / Documento (Upload)',
banner: 'Folder / Panfleto de Promoção (Banner)'
};
const getFriendlyLabel = (key: string, value: any): string => {
if (key === 'da80bac4-4018-4ee9-a9c4-a25551411269' || key === 'da80bac4-4018-4ee9-a9c4-a25551411269'.replace(/-/g, '')) return 'Sexo';
if (key === '09e08119-02e1-49cf-8a48-eb7b51a35758' || key === '09e08119-02e1-49cf-8a48-eb7b51a35758'.replace(/-/g, '')) return 'Sexo';
if (key === '82bcc4ce-8ece-4a1b-8131-e64585119cf4' || key === '82bcc4ce-8ece-4a1b-8131-e64585119cf4'.replace(/-/g, '')) return 'CPF';
const valStr = String(value || '');
if (valStr === 'Feminino' || valStr === 'Masculino' || valStr === 'Outro') {
return 'Sexo';
}
if (/^\d{11}$/.test(valStr) || /^\d{3}\.\d{3}\.\d{3}-\d{2}$/.test(valStr)) {
return 'CPF';
}
return 'Campo Excluído';
};
const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'];
const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
const [config, setConfig] = useState<PreMatriculaConfig | null>(null);
const [campos, setCampos] = useState<PreMatriculaCampo[]>([]);
const [todosCampos, setTodosCampos] = useState<PreMatriculaCampo[]>([]);
const [inscricoes, setInscricoes] = useState<PreMatriculaInscricao[]>([]);
const [turmas, setTurmas] = useState<any[]>([]);
const [activeTab, setActiveTab] = useState<'editor' | 'inscricoes'>('editor');
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [copied, setCopied] = useState(false);
const [selectedTurma, setSelectedTurma] = useState<string>('all');
const [editingCampo, setEditingCampo] = useState<PreMatriculaCampo | null>(null);
const [showNewField, setShowNewField] = useState(false);
const [newField, setNewField] = useState({ label: '', tipo: 'text', placeholder: '', obrigatorio: false, opcoes: '' });
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [cfgRes, camposRes, inscRes, turmasRes] = await Promise.all([
fetch('/api/prematricula/config'),
fetch('/api/prematricula/campos'),
fetch('/api/prematricula/inscricoes'),
fetch('/api/turmas')
]);
if (cfgRes.ok) { const j = await cfgRes.json(); setConfig(j.config); }
if (camposRes.ok) { const j = await camposRes.json(); setCampos(j.campos || []); setTodosCampos(j.todosCampos || j.campos || []); }
if (inscRes.ok) { const j = await inscRes.json(); setInscricoes(j.inscricoes || []); }
if (turmasRes.ok) { const j = await turmasRes.json(); setTurmas(j.turmas || []); }
} catch (e) { console.error(e); }
finally { setIsLoading(false); }
}, []);
useEffect(() => { loadData(); }, [loadData]);
const saveConfig = async () => {
if (!config) return;
setIsSaving(true);
try {
await fetch('/api/prematricula/config', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
} catch (e) { console.error(e); }
finally { setIsSaving(false); }
};
const togglePublish = async () => {
if (!config) return;
const newStatus = config.status === 'published' ? 'draft' : 'published';
const updated = { ...config, status: newStatus as 'draft' | 'published' };
setConfig(updated);
await fetch('/api/prematricula/config', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updated)
});
};
const addField = async () => {
let label = newField.label.trim();
if (newField.tipo === 'banner' && !label) {
label = 'Folder Promocional';
}
if (!label) return;
const campo = {
id: crypto.randomUUID(),
label: label,
tipo: newField.tipo,
placeholder: newField.placeholder,
obrigatorio: newField.obrigatorio,
opcoes: newField.tipo === 'select' ? newField.opcoes.split(',').map(o => o.trim()).filter(Boolean) : [],
ordem: campos.length,
ativo: true
};
try {
const res = await fetch('/api/prematricula/campos', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(campo)
});
if (res.ok) {
setCampos(prev => [...prev, campo as PreMatriculaCampo]);
setNewField({ label: '', tipo: 'text', placeholder: '', obrigatorio: false, opcoes: '' });
setShowNewField(false);
}
} catch (e) { console.error(e); }
};
const deleteField = async (id: string) => {
try {
const res = await fetch(`/api/prematricula/campos/${id}`, { method: 'DELETE' });
if (res.ok) setCampos(prev => prev.filter(c => c.id !== id));
} catch (e) { console.error(e); }
};
const updateField = async (campo: PreMatriculaCampo) => {
let label = campo.label.trim();
if (campo.tipo === 'banner' && !label) {
label = 'Folder Promocional';
}
const updatedCampo = { ...campo, label };
try {
await fetch(`/api/prematricula/campos/${campo.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedCampo)
});
setCampos(prev => prev.map(c => c.id === campo.id ? updatedCampo : c));
setEditingCampo(null);
} catch (e) { console.error(e); }
};
const moveField = async (index: number, direction: 'up' | 'down') => {
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= campos.length) return;
const reordered = [...campos].sort((a, b) => a.ordem - b.ordem);
const temp = reordered[index];
reordered[index] = reordered[targetIndex];
reordered[targetIndex] = temp;
const updated = reordered.map((c, i) => ({ ...c, ordem: i }));
setCampos(updated);
try {
await fetch('/api/prematricula/campos/reordenar', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ordem: updated.map(c => ({ id: c.id, ordem: c.ordem }))
})
});
} catch (e) {
console.error(e);
}
};
const deleteInscricao = async (id: string) => {
try {
const res = await fetch(`/api/prematricula/inscricoes/${id}`, { method: 'DELETE' });
if (res.ok) setInscricoes(prev => prev.filter(i => i.id !== id));
} catch (e) { console.error(e); }
};
const exportCSV = (turmaId: string) => {
const filtered = turmaId === 'all' ? inscricoes : inscricoes.filter(i => i.turmaId === turmaId);
if (filtered.length === 0) return;
const allKeys = new Set<string>();
filtered.forEach(i => Object.keys(i.respostas || {}).forEach(k => allKeys.add(k)));
const headers = ['Nome', 'Email', 'Telefone', 'Turma', 'Status', 'Data', ...Array.from(allKeys).map(k => {
const field = todosCampos.find(c => c.id === k);
return field ? field.label : getFriendlyLabel(k, null);
})];
const rows = filtered.map(i => [
i.nome, i.email, i.telefone, i.turmaNome, i.status,
new Date(i.createdAt).toLocaleDateString('pt-BR'),
...Array.from(allKeys).map(k => {
const val = (i.respostas || {})[k] || '';
return typeof val === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(val)
? val.split('-').reverse().join('/')
: val;
})
]);
const csv = [headers.join(';'), ...rows.map(r => r.join(';'))].join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
const turmaName = turmaId === 'all' ? 'todas' : turmas.find(t => t.id === turmaId)?.nome || turmaId;
a.download = `pre-matriculas-${turmaName}.csv`; a.click();
URL.revokeObjectURL(url);
};
const copyLink = () => {
if (!config) return;
const url = `${window.location.origin}/${config.slug}`;
navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const filteredInscricoes = selectedTurma === 'all'
? inscricoes : inscricoes.filter(i => i.turmaId === selectedTurma);
const turmasComInscritos = turmas.map(t => ({
...t, count: inscricoes.filter(i => i.turmaId === t.id).length
}));
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw size={32} className="text-indigo-600 animate-spin" />
</div>
);
}
return (
<div className="space-y-6 animate-in fade-in duration-500 pb-10">
{/* Header */}
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight flex items-center gap-3">
<ClipboardPen className="text-indigo-600" size={28} />
Pré-Matrícula
</h2>
<p className="text-slate-500 font-medium">Gerencie o formulário público de pré-matrícula e visualize inscrições.</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={togglePublish}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl font-bold text-sm transition-all active:scale-95 shadow-sm ${
config?.status === 'published'
? 'bg-emerald-600 text-white hover:bg-emerald-700'
: 'bg-amber-500 text-white hover:bg-amber-600'
}`}
>
{config?.status === 'published' ? <><Eye size={18} /> Publicado</> : <><EyeOff size={18} /> Rascunho</>}
</button>
<button onClick={copyLink}
className="flex items-center gap-2 px-4 py-2.5 bg-slate-100 text-slate-700 rounded-xl font-bold text-sm hover:bg-slate-200 transition-all active:scale-95"
>
{copied ? <><Check size={18} className="text-emerald-600" /> Copiado!</> : <><Link2 size={18} /> Copiar Link</>}
</button>
</div>
</header>
{/* Tab Switcher */}
<div className="flex bg-slate-100 p-1 rounded-xl border border-slate-200 w-fit">
<button onClick={() => setActiveTab('editor')}
className={`px-5 py-2.5 rounded-lg text-sm font-bold transition-all ${activeTab === 'editor' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
<Settings2 size={16} className="inline mr-2 -mt-0.5" />Editor do Formulário
</button>
<button onClick={() => setActiveTab('inscricoes')}
className={`px-5 py-2.5 rounded-lg text-sm font-bold transition-all ${activeTab === 'inscricoes' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
<Users size={16} className="inline mr-2 -mt-0.5" />Inscrições ({inscricoes.length})
</button>
</div>
{activeTab === 'editor' ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left: Config */}
<div className="lg:col-span-1 space-y-6">
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm space-y-5">
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Configurações</h3>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Título da Página</label>
<input className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium"
value={config?.titulo || ''} onChange={e => setConfig(prev => prev ? { ...prev, titulo: e.target.value } : prev)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Descrição</label>
<textarea className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium resize-none" rows={3}
value={config?.descricao || ''} onChange={e => setConfig(prev => prev ? { ...prev, descricao: e.target.value } : prev)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Slug do Link Personalizado</label>
<div className="flex items-center bg-slate-50 border border-slate-200 rounded-lg overflow-hidden">
<span className="px-3 text-xs text-slate-400 font-mono whitespace-nowrap">/</span>
<input className="flex-1 px-2 py-3 bg-transparent focus:outline-none text-sm font-mono font-bold text-indigo-700"
value={config?.slug || ''}
onChange={e => setConfig(prev => prev ? { ...prev, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') } : prev)}
/>
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Cor Primária</label>
<div className="flex items-center gap-3">
<input type="color" className="w-10 h-10 rounded-lg border border-slate-200 cursor-pointer"
value={config?.corPrimaria || '#4f46e5'} onChange={e => setConfig(prev => prev ? { ...prev, corPrimaria: e.target.value } : prev)} />
<span className="text-xs font-mono text-slate-500">{config?.corPrimaria}</span>
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-2">Turmas Disponíveis no Formulário</label>
{/* Selected Classes Chips with grey Tags/Chips UI */}
<div className="flex flex-wrap gap-2 mb-3 p-2 bg-slate-100 border border-slate-200 rounded-xl min-h-[44px] items-center">
{(config?.turmasPermitidas || []).map(id => {
const turma = turmas.find(t => t.id === id);
if (!turma) return null;
return (
<span key={id} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-200 border border-slate-300 rounded-lg text-xs font-semibold text-slate-700 shadow-sm transition-all hover:bg-slate-300">
{turma.nome}
<button
type="button"
className="hover:bg-slate-400 p-0.5 rounded-full text-slate-400 hover:text-slate-600 transition-colors focus:outline-none"
onClick={() => {
const current = config?.turmasPermitidas || [];
const next = current.filter(x => x !== id);
setConfig(prev => prev ? { ...prev, turmasPermitidas: next } : prev);
}}
>
<X size={12} strokeWidth={2.5} />
</button>
</span>
);
})}
{(config?.turmasPermitidas || []).length === 0 && (
<span className="text-xs font-medium text-slate-400 italic px-2">Todas as turmas disponíveis (padrão)</span>
)}
</div>
{/* Dropdown to add a class */}
<div className="relative">
<select
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none text-xs font-bold text-slate-700 appearance-none cursor-pointer"
value=""
onChange={(e) => {
const id = e.target.value;
if (!id) return;
const current = config?.turmasPermitidas || [];
if (!current.includes(id)) {
const next = [...current, id];
setConfig(prev => prev ? { ...prev, turmasPermitidas: next } : prev);
}
e.target.value = ""; // Reset select selection
}}
>
<option value="">+ Adicionar Turma...</option>
{turmas
.filter(t => !(config?.turmasPermitidas || []).includes(t.id))
.map(t => (
<option key={t.id} value={t.id}>
{t.nome}
</option>
))}
</select>
<div className="absolute right-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400">
<ChevronDown size={14} />
</div>
</div>
{/* Configurar Vagas e Horários por Turma */}
{config?.turmasPermitidas && config.turmasPermitidas.length > 0 && (
<div className="mt-5 space-y-3">
<label className="block text-[10px] font-black text-slate-600 uppercase tracking-wider">Configurar Vagas e Horários</label>
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
{config.turmasPermitidas.map(id => {
const turma = turmas.find(t => t.id === id);
if (!turma) return null;
const horarioStr = turma.horario_inicio_padrao && turma.horario_fim_padrao
? `${turma.horario_inicio_padrao} às ${turma.horario_fim_padrao}`
: 'Horário não definido';
return (
<div key={id} className="flex items-center justify-between p-3.5 bg-slate-50 border border-slate-100 rounded-2xl shadow-sm text-xs group hover:border-indigo-100 transition-colors">
<div className="flex-1 min-w-0 mr-3">
<p className="font-bold text-slate-800 truncate">{turma.nome}</p>
<p className="text-[10px] text-slate-400 font-bold mt-0.5 tracking-wide uppercase">
Horário: <span className="text-indigo-600 normal-case">{horarioStr}</span>
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Vagas:</span>
<input
type="number"
min="0"
className="w-16 px-2 py-1.5 bg-white border border-slate-200 rounded-xl text-center font-bold text-slate-800 focus:ring-2 focus:ring-indigo-500 focus:outline-none shadow-sm"
value={turma.vagas_prematricula ?? ''}
placeholder="∞"
onChange={(e) => {
const val = e.target.value === '' ? null : parseInt(e.target.value);
setTurmas(prev => prev.map(t => t.id === id ? { ...t, vagas_prematricula: val } : t));
}}
onBlur={async (e) => {
const val = e.target.value === '' ? null : parseInt(e.target.value);
try {
await fetch(`/api/turmas/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vagas_prematricula: val })
});
} catch (err) {
console.error('Erro ao atualizar vagas:', err);
}
}}
/>
</div>
</div>
);
})}
</div>
</div>
)}
<p className="text-[10px] text-slate-400 mt-2 font-medium leading-normal">
Se nenhuma turma for adicionada, todas as turmas ativas aparecerão para seleção do aluno. Defina o limite de vagas (deixe em branco para ilimitado).
</p>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Mensagem de Sucesso</label>
<textarea className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium resize-none" rows={2}
value={config?.mensagemSucesso || ''} onChange={e => setConfig(prev => prev ? { ...prev, mensagemSucesso: e.target.value } : prev)} />
</div>
<button onClick={saveConfig} disabled={isSaving}
className="w-full py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all active:scale-95 flex items-center justify-center gap-2 disabled:opacity-50"
>
{isSaving ? <RefreshCw size={16} className="animate-spin" /> : <Save size={16} />} Salvar Configurações
</button>
</div>
{/* Preview Link */}
<div className="bg-gradient-to-br from-indigo-50 to-violet-50 p-5 rounded-2xl border border-indigo-100">
<p className="text-[10px] font-black text-indigo-600 uppercase tracking-widest mb-2">Link Público</p>
<p className="text-xs font-mono text-indigo-800 break-all mb-3">
{window.location.origin}/{config?.slug}
</p>
<a href={`/${config?.slug}`} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 text-xs font-bold text-indigo-600 hover:text-indigo-700"
>
<ExternalLink size={14} /> Abrir Prévia
</a>
</div>
</div>
{/* Right: Fields Editor */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Campos do Formulário</h3>
<button onClick={() => setShowNewField(!showNewField)}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-xl font-bold text-xs hover:bg-indigo-700 transition-all active:scale-95"
>
<Plus size={16} /> Novo Campo
</button>
</div>
{/* New Field Form */}
{showNewField && (
<div className="bg-indigo-50 p-5 rounded-xl border border-indigo-100 mb-6 space-y-4 animate-in slide-in-from-top-2 duration-300">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Nome do Campo</label>
<input className="w-full px-4 py-3 bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium"
placeholder="Ex: Nome Completo" value={newField.label} onChange={e => setNewField({ ...newField, label: e.target.value })} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Tipo</label>
<select className="w-full px-4 py-3 bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium"
value={newField.tipo} onChange={e => setNewField({ ...newField, tipo: e.target.value })}>
{Object.entries(FIELD_TYPES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{newField.tipo === 'banner' ? (
<div className="col-span-2">
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Folder / Panfleto de Promoção (Banner)</label>
{newField.placeholder ? (
<div className="relative group border border-slate-200 rounded-xl overflow-hidden shadow-sm flex justify-center items-center bg-slate-50">
<img src={newField.placeholder} alt="Banner" className="w-full max-h-[350px] object-contain rounded-xl" />
<div className="absolute inset-0 bg-slate-900/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-xl">
<button
type="button"
onClick={() => setNewField(prev => ({ ...prev, placeholder: '' }))}
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-[10px] font-bold rounded-lg transition-all shadow-md"
>
Remover Folder
</button>
</div>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-slate-200 border-dashed rounded-xl cursor-pointer bg-white hover:bg-slate-50 border-slate-300 transition-all">
<div className="flex flex-col items-center justify-center pt-3 pb-3">
<Upload className="w-6 h-6 text-slate-400 mb-1 animate-bounce" />
<p className="text-[10px] text-slate-500 font-bold">Upload do Folder/Flyer</p>
<p className="text-[8px] text-slate-400 mt-0.5">PNG, JPG ou WEBP até 10MB</p>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/prematricula/upload', { method: 'POST', body: formData });
if (res.ok) {
const result = await res.json();
setNewField(prev => ({ ...prev, placeholder: result.url }));
}
} catch (err) { console.error(err); }
}}
/>
</label>
)}
</div>
) : (
<>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Placeholder</label>
<input className="w-full px-4 py-3 bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium"
placeholder="Texto de ajuda" value={newField.placeholder} onChange={e => setNewField({ ...newField, placeholder: e.target.value })} />
</div>
{newField.tipo === 'select' && (
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Opções (separadas por vírgula)</label>
<input className="w-full px-4 py-3 bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium"
placeholder="Opção 1, Opção 2, Opção 3" value={newField.opcoes} onChange={e => setNewField({ ...newField, opcoes: e.target.value })} />
</div>
)}
</>
)}
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm font-bold text-slate-600 cursor-pointer">
<input type="checkbox" className="w-4 h-4 text-indigo-600 rounded"
checked={newField.obrigatorio} onChange={e => setNewField({ ...newField, obrigatorio: e.target.checked })} />
Campo Obrigatório
</label>
<div className="flex gap-2">
<button onClick={() => setShowNewField(false)} className="px-4 py-2 text-slate-600 font-bold text-xs rounded-lg hover:bg-slate-100">Cancelar</button>
<button onClick={addField} className="px-4 py-2 bg-indigo-600 text-white font-bold text-xs rounded-xl hover:bg-indigo-700 active:scale-95 transition-all">Adicionar</button>
</div>
</div>
</div>
)}
{/* Fields List */}
<div className="space-y-3">
{campos.length === 0 && (
<div className="text-center py-12 text-slate-400">
<ClipboardPen size={48} className="mx-auto mb-3 opacity-30" />
<p className="font-bold">Nenhum campo criado</p>
<p className="text-xs mt-1">Clique em "Novo Campo" para começar a montar seu formulário.</p>
</div>
)}
{campos.sort((a, b) => a.ordem - b.ordem).map((campo, idx) => {
const isDefaultField = campo.id.startsWith('_');
return (
<div key={campo.id} className="flex items-center gap-3 p-4 bg-slate-50 rounded-xl border border-slate-200 group hover:border-indigo-200 transition-colors">
{/* Move Up/Down Controls for premium reordering */}
<div className="flex flex-col gap-0.5 flex-shrink-0">
<button
disabled={idx === 0}
onClick={() => moveField(idx, 'up')}
type="button"
className="p-1 text-slate-400 hover:text-indigo-600 disabled:opacity-30 disabled:hover:text-slate-400 rounded transition-colors"
>
<ChevronDown size={14} className="rotate-180" />
</button>
<button
disabled={idx === campos.length - 1}
onClick={() => moveField(idx, 'down')}
type="button"
className="p-1 text-slate-400 hover:text-indigo-600 disabled:opacity-30 disabled:hover:text-slate-400 rounded transition-colors"
>
<ChevronDown size={14} />
</button>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-bold text-slate-800 text-sm truncate">{campo.label}</p>
{campo.obrigatorio && <span className="text-red-500 text-xs font-black">*</span>}
{isDefaultField && (
<span className="px-1.5 py-0.5 bg-slate-200 border border-slate-300 rounded text-[9px] font-black text-slate-600 uppercase tracking-wider">
Padrão
</span>
)}
</div>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">
{FIELD_TYPES[campo.tipo] || campo.tipo}
{campo.tipo === 'select' && campo.opcoes?.length ? ` · ${campo.opcoes.length} opções` : ''}
</p>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => setEditingCampo(campo)}
type="button"
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
title="Editar campo"
>
<Edit size={16} />
</button>
{campo.id !== '_nome' && (
<button onClick={() => deleteField(campo.id)}
type="button"
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="Excluir campo"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
);
})}
</div>
{/* Floating Edit Field Modal - Premium Floating Principle (Rule 11 & 17) */}
{editingCampo && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-transparent animate-fade-in">
<div className="fixed inset-0 bg-slate-900/35 backdrop-blur-none" onClick={() => setEditingCampo(null)} />
<div className="relative bg-white w-full max-w-md p-6 rounded-3xl shadow-2xl border border-slate-100 animate-in zoom-in-95 duration-200">
<button onClick={() => setEditingCampo(null)} className="absolute right-4 top-4 p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-full transition-colors">
<X size={18} />
</button>
<h3 className="text-base font-black text-slate-800 uppercase tracking-wider mb-5">Editar Campo</h3>
<div className="space-y-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Nome do Campo</label>
<input
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-semibold text-slate-800 shadow-sm"
value={editingCampo.label}
onChange={e => setEditingCampo({ ...editingCampo, label: e.target.value })}
/>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Tipo</label>
<select
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-semibold text-slate-800 cursor-pointer shadow-sm"
value={editingCampo.tipo}
onChange={e => setEditingCampo({ ...editingCampo, tipo: e.target.value })}
>
{Object.entries(FIELD_TYPES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
{editingCampo.tipo === 'banner' ? (
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Folder / Panfleto de Promoção (Banner)</label>
{editingCampo.placeholder ? (
<div className="relative group border border-slate-200 rounded-xl overflow-hidden shadow-sm flex justify-center items-center bg-slate-50">
<img src={editingCampo.placeholder} alt="Banner" className="w-full max-h-[350px] object-contain rounded-xl" />
<div className="absolute inset-0 bg-slate-900/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-xl">
<button
type="button"
onClick={() => setEditingCampo(prev => prev ? { ...prev, placeholder: '' } : null)}
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-[10px] font-bold rounded-lg transition-all shadow-md"
>
Remover Folder
</button>
</div>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-slate-200 border-dashed rounded-xl cursor-pointer bg-slate-50 hover:bg-slate-100 border-slate-300 transition-all">
<div className="flex flex-col items-center justify-center pt-3 pb-3">
<Upload className="w-6 h-6 text-slate-400 mb-1 animate-bounce" />
<p className="text-[10px] text-slate-500 font-bold">Upload do Folder/Flyer</p>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/prematricula/upload', { method: 'POST', body: formData });
if (res.ok) {
const result = await res.json();
setEditingCampo(prev => prev ? { ...prev, placeholder: result.url } : null);
}
} catch (err) { console.error(err); }
}}
/>
</label>
)}
</div>
) : (
<>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Placeholder</label>
<input
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-semibold text-slate-800 shadow-sm"
value={editingCampo.placeholder || ''}
onChange={e => setEditingCampo({ ...editingCampo, placeholder: e.target.value })}
/>
</div>
{editingCampo.tipo === 'select' && (
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Opções (separadas por vírgula)</label>
<input
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-semibold text-slate-800 shadow-sm"
value={Array.isArray(editingCampo.opcoes) ? editingCampo.opcoes.join(', ') : editingCampo.opcoes || ''}
onChange={e => setEditingCampo({ ...editingCampo, opcoes: e.target.value.split(',').map(x => x.trim()) })}
/>
</div>
)}
</>
)}
<label className="flex items-center gap-2.5 text-sm font-bold text-slate-600 cursor-pointer py-1">
<input
type="checkbox"
className="w-4 h-4 text-indigo-600 rounded focus:ring-indigo-500"
checked={editingCampo.obrigatorio}
onChange={e => setEditingCampo({ ...editingCampo, obrigatorio: e.target.checked })}
/>
Campo Obrigatório
</label>
</div>
<div className="flex justify-end gap-2 mt-7">
<button
onClick={() => setEditingCampo(null)}
className="px-4 py-2.5 text-xs font-bold text-slate-500 rounded-xl hover:bg-slate-100 transition-colors"
>
Cancelar
</button>
<button
onClick={() => updateField(editingCampo)}
className="px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-extrabold rounded-xl shadow-md transition-all active:scale-95"
>
Salvar Alterações
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
) : (
/* Inscricoes Tab */
<div className="space-y-6">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-wrap">
<select className="px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm font-bold text-slate-700 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={selectedTurma} onChange={e => setSelectedTurma(e.target.value)}>
<option value="all">Todas as Turmas ({inscricoes.length})</option>
{turmasComInscritos.map(t => (
<option key={t.id} value={t.id}>{t.nome} ({t.count})</option>
))}
</select>
<button onClick={() => loadData()} className="p-2.5 bg-slate-100 rounded-xl text-slate-600 hover:bg-slate-200 transition-all" title="Atualizar">
<RefreshCw size={16} />
</button>
</div>
<button onClick={() => exportCSV(selectedTurma)}
className="flex items-center gap-2 px-4 py-2.5 bg-emerald-600 text-white rounded-xl font-bold text-sm hover:bg-emerald-700 transition-all active:scale-95"
>
<Download size={16} /> Exportar CSV
</button>
</div>
{filteredInscricoes.length === 0 ? (
<div className="bg-white p-12 rounded-2xl border border-slate-200 text-center">
<Users size={48} className="mx-auto mb-3 text-slate-300" />
<p className="font-bold text-slate-500">Nenhuma inscrição encontrada</p>
<p className="text-xs text-slate-400 mt-1">As pré-matrículas aparecerão aqui quando forem preenchidas.</p>
</div>
) : (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-100 text-xs uppercase text-slate-500 font-bold tracking-wider">
<th className="p-4">Nome</th>
<th className="p-4">Contato</th>
<th className="p-4">Turma</th>
<th className="p-4">Status</th>
<th className="p-4">Data</th>
<th className="p-4 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50 text-sm">
{filteredInscricoes.map(insc => (
<tr key={insc.id} className="hover:bg-slate-50 transition-colors">
<td className="p-4">
<p className="font-bold text-slate-800">{insc.nome}</p>
{insc.respostas && Object.keys(insc.respostas).length > 0 && (
<div className="mt-2 space-y-1 bg-slate-50 p-2.5 rounded-xl border border-slate-100 text-[11px] max-w-xs">
{Object.entries(insc.respostas).map(([key, value]) => {
const field = todosCampos.find(c => c.id === key);
const label = field ? field.label : getFriendlyLabel(key, value);
const isUrl = typeof value === 'string' && (value.startsWith('http') || value.startsWith('/storage/'));
return (
<div key={key} className="flex flex-col sm:flex-row sm:items-center gap-1">
<span className="font-bold text-slate-500">{label}:</span>
{isUrl ? (
<a href={value} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-700 hover:underline font-extrabold flex items-center gap-1">
📄 Ver Documento
</a>
) : (
<span className="text-slate-800 font-semibold truncate">
{(() => {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
const dateFormatted = value.split('-').reverse().join('/');
const isBirthDateField = label.toLowerCase().includes('nasc') || label.toLowerCase().includes('nascimento');
if (isBirthDateField) {
const parts = value.split('-');
const birthYear = parseInt(parts[0], 10);
const birthMonth = parseInt(parts[1], 10);
const birthDay = parseInt(parts[2], 10);
// Forçando fuso brasileiro para calcular a idade exata hoje (Regra 45)
const todayStr = new Date().toLocaleString('en-US', { timeZone: 'America/Sao_Paulo' });
const today = new Date(todayStr);
let age = today.getFullYear() - birthYear;
const monthDiff = (today.getMonth() + 1) - birthMonth;
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDay)) {
age--;
}
return `${dateFormatted} (${age} anos)`;
}
return dateFormatted;
}
return String(value);
})()}
</span>
)}
</div>
);
})}
</div>
)}
</td>
<td className="p-4">
<p className="text-slate-600">{insc.email || '-'}</p>
<p className="text-xs text-slate-400">{insc.telefone || '-'}</p>
</td>
<td className="p-4 text-slate-600 font-medium">{insc.turmaNome || '-'}</td>
<td className="p-4">
<span className={`px-2.5 py-1 rounded-full text-[10px] font-bold uppercase ${
insc.status === 'pendente' ? 'bg-amber-100 text-amber-700' :
insc.status === 'aprovado' ? 'bg-emerald-100 text-emerald-700' :
insc.status === 'rejeitado' ? 'bg-red-100 text-red-700' :
'bg-indigo-100 text-indigo-700'
}`}>
{insc.status}
</span>
</td>
<td className="p-4 text-slate-500 text-xs">{new Date(insc.createdAt).toLocaleDateString('pt-BR')}</td>
<td className="p-4 text-right flex items-center justify-end gap-2">
<button
onClick={() => onConvert && onConvert(insc)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 rounded-lg font-bold text-xs transition-colors"
title="Converter em Matrícula"
>
<UserCheck size={14} /> Matricular
</button>
<button onClick={() => deleteInscricao(insc.id)} className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
);
};
export default PreMatricula;

View File

@ -0,0 +1,894 @@
import React, { useState } from 'react';
import { SchoolData, Class, Student, Subject, Grade, Period } from '../types';
import { dbService } from '../services/dbService';
import { useDialog } from '../DialogContext';
import {
FileText,
Plus,
Trash2,
ChevronRight,
Save,
GraduationCap,
BookOpen,
User,
X,
Search,
CheckCircle2,
AlertCircle,
Calendar,
Calculator
} from 'lucide-react';
interface ReportCardProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [selectedClass, setSelectedClass] = useState<Class | null>(null);
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
const [newSubjectName, setNewSubjectName] = useState('');
const [newPeriodName, setNewPeriodName] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [showConfigManager, setShowConfigManager] = useState(false);
const [configTab, setConfigTab] = useState<'subjects' | 'periods'>('subjects');
const [studentGrades, setStudentGrades] = useState<Record<string, Record<string, any>>>({}); // subjectId -> periodId -> { examId: value }
const [studentSubmissions, setStudentSubmissions] = useState<Record<string, {acertos: number, erros: number}>>({}); // examId -> { acertos, erros }
const [classGrades, setClassGrades] = useState<Grade[]>([]);
const [subjects, setSubjects] = useState<Subject[]>([]);
const periods = data.periods || [];
const grades = data.grades || [];
const [dbClasses, setDbClasses] = useState<Class[]>(data.classes || []);
const [dbCourses, setDbCourses] = useState<any[]>(data.courses || []);
const [dbExams, setDbExams] = useState<any[]>(data.exams || []);
const loadClassesAndCourses = async () => {
try {
const [clsRes, crsRes, examsRes] = await Promise.all([
fetch('/api/turmas'),
fetch('/api/cursos'),
fetch('/api/provas')
]);
const clsData = await clsRes.json();
const crsData = await crsRes.json();
const examsData = await examsRes.json();
if (clsData.turmas) {
setDbClasses(clsData.turmas.map((t: any) => ({
id: t.id,
name: t.nome,
courseId: t.curso_id
})));
}
if (crsData.cursos) {
setDbCourses(crsData.cursos);
}
if (examsData.provas) {
setDbExams(examsData.provas.map((p: any) => ({
id: p.id,
classId: p.classId || p.turma_id,
subjectId: p.subjectId || p.disciplina_id,
periodId: p.periodId || p.periodo_id,
title: p.title || p.titulo,
evaluationType: p.evaluationType || p.evaluation_type || 'exam',
maxScore: p.maxScore !== undefined && p.maxScore !== null ? Number(p.maxScore) : (p.max_score !== undefined && p.max_score !== null ? Number(p.max_score) : 10),
isDeleted: p.isDeleted ?? p.is_deleted ?? false
})));
}
} catch(e) {
console.error('Erro ao buscar dados:', e);
}
};
const loadSubjects = async () => {
try {
const res = await fetch('/api/disciplinas');
if (res.ok) {
const json = await res.json();
const mappedSubjects = (json.disciplinas || []).map((d: any) => ({
id: d.id,
name: d.nome
}));
setSubjects(mappedSubjects);
}
} catch (e) {
console.error('Erro ao buscar disciplinas:', e);
}
};
React.useEffect(() => {
loadSubjects();
loadClassesAndCourses();
}, []);
// Buscar todas as notas da turma para mostrar médias na lista
React.useEffect(() => {
if (selectedClass) {
const fetchClassGrades = async () => {
try {
const studentIds = data.students.filter(s => s.classId === selectedClass.id).map(s => s.id);
if (studentIds.length === 0) return;
const allGrades: Grade[] = [];
for (const id of studentIds) {
const res = await fetch(`/api/notas/${id}?t=${Date.now()}`);
if (res.ok) {
const json = await res.json();
(json.notas || []).forEach((n: any) => {
allGrades.push({
id: n.id,
studentId: n.aluno_id,
subjectId: n.disciplina_id,
period: n.periodo_id,
examId: n.prova_id,
value: Number(n.valor)
});
});
}
}
setClassGrades(allGrades);
} catch (e) {
console.error('Erro ao buscar notas da turma:', e);
}
};
fetchClassGrades();
} else {
setClassGrades([]);
}
}, [selectedClass, data.students]);
// Helper para normalizar URLs de fotos (vacina contra cache antigo)
const normalizePhotoUrl = (url?: string) => {
if (!url || typeof url !== 'string') return '';
if (url.startsWith('data:image') || url.startsWith('blob:')) return url;
if (url.startsWith('/storage/')) return url;
try {
const match = url.match(/^https?:\/\/[^\/]+\/(.+)$/);
if (match) return `/storage/${match[1]}`;
} catch (e) { }
return url;
};
const handleAddSubject = async () => {
if (!newSubjectName.trim()) {
showAlert('Atenção', '⚠️ Por favor, informe o nome da disciplina.', 'warning');
return;
}
try {
const payload = {
id: crypto.randomUUID(),
nome: newSubjectName.trim()
};
const res = await fetch('/api/disciplinas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
await loadSubjects();
setNewSubjectName('');
} else {
showAlert('Erro', 'Falha ao salvar disciplina no banco.', 'error');
}
} catch (e) {
showAlert('Erro', 'Ocorreu um erro de conexão.', 'error');
}
};
const handleAddPeriod = () => {
if (!newPeriodName.trim()) {
showAlert('Atenção', '⚠️ Por favor, informe o nome do período.', 'warning');
return;
}
const newPeriod: Period = {
id: crypto.randomUUID(),
name: newPeriodName.trim()
};
const updatedPeriods = [...periods, newPeriod];
updateData({ periods: updatedPeriods });
dbService.saveData({ ...data, periods: updatedPeriods });
setNewPeriodName('');
};
const handleDeleteSubject = (id: string) => {
showConfirm(
'Excluir Disciplina',
'⚠️ Tem certeza que deseja excluir esta disciplina? Todas as notas vinculadas serão perdidas.',
async () => {
try {
const res = await fetch(`/api/disciplinas/${id}`, { method: 'DELETE' });
if (res.ok) {
await loadSubjects();
// Notas dependem da disciplina, mas a exclusão das notas sql pode ser em cascade no postgres
// Se ainda usar JSON para notas manuais:
const updatedGrades = grades.filter(g => g.subjectId !== id);
updateData({ grades: updatedGrades });
dbService.saveData({ ...data, grades: updatedGrades });
} else {
showAlert('Erro', 'Falha ao deletar disciplina.', 'error');
}
} catch(e) {
showAlert('Erro', 'Ocorreu um erro ao comunicar com a API.', 'error');
}
}
);
};
const handleDeletePeriod = (id: string) => {
showConfirm(
'Excluir Período',
'⚠️ Tem certeza que deseja excluir este período? Todas as notas vinculadas serão perdidas.',
() => {
const updatedPeriods = periods.filter(p => p.id !== id);
const updatedGrades = grades.filter(g => g.period !== id);
updateData({ periods: updatedPeriods, grades: updatedGrades });
dbService.saveData({ ...data, periods: updatedPeriods, grades: updatedGrades });
}
);
};
const handleOpenStudentGrades = async (student: Student) => {
setSelectedStudent(student);
const initialGrades: Record<string, Record<string, any>> = {};
// Buscar notas do Postgres (com cache busting)
let dbNotas: any[] = [];
try {
const resNotas = await fetch(`/api/notas/${student.id}?t=${new Date().getTime()}`);
if (resNotas.ok) {
const json = await resNotas.json();
dbNotas = json.notas || [];
}
} catch(e) {
console.error('Error fetching notas:', e);
}
let subsMap: Record<string, {acertos: number, erros: number}> = {};
try {
const res = await fetch(`/api/student-submissions/${student.id}?t=${new Date().getTime()}`);
if (res.ok) {
const { submissions } = await res.json();
(submissions || []).forEach((s: any) => {
// Normalização agressiva para garantir o vínculo
const pId = String(s.prova_id || '').trim();
if (pId) {
subsMap[pId] = { acertos: s.acertos, erros: s.erros };
}
});
setStudentSubmissions(subsMap);
}
} catch(e) {
console.error('Error fetching submissions:', e);
}
subjects.forEach(subject => {
initialGrades[subject.id] = {};
periods.forEach(period => {
const periodGrades: any = {};
const linkedExams = (dbExams || []).filter(e =>
String(e.subjectId).trim() === String(subject.id).trim() &&
String(e.periodId).trim() === String(period.id).trim()
);
if (linkedExams.length > 0) {
linkedExams.forEach(exam => {
const existingGrade = dbNotas.find(g =>
String(g.disciplina_id).trim() === String(subject.id).trim() &&
(String(g.periodo_id).trim() === String(period.id).trim() || String(g.periodo_id).trim() === String(period.name).trim()) &&
String(g.prova_id).trim() === String(exam.id).trim()
);
periodGrades[exam.id] = existingGrade ? Number(existingGrade.valor) : '';
});
} else {
const existingGrade = dbNotas.find(g =>
String(g.disciplina_id).trim() === String(subject.id).trim() &&
(String(g.periodo_id).trim() === String(period.id).trim() || String(g.periodo_id).trim() === String(period.name).trim()) &&
!g.prova_id
);
periodGrades['direct'] = existingGrade ? Number(existingGrade.valor) : '';
}
initialGrades[subject.id][period.id] = periodGrades;
});
});
setStudentGrades(initialGrades);
};
const handleSaveGrades = async () => {
if (!selectedStudent) return;
const notasPayload: any[] = [];
Object.entries(studentGrades).forEach(([subjectId, periodGrades]) => {
Object.entries(periodGrades).forEach(([periodId, examValues]) => {
Object.entries(examValues).forEach(([examId, value]) => {
const numValue = Number(value);
if (numValue > 0 || (value !== '' && numValue === 0)) {
notasPayload.push({
aluno_id: selectedStudent.id,
disciplina_id: subjectId,
periodo_id: periodId,
prova_id: examId !== 'direct' ? examId : null,
valor: numValue
});
}
});
});
});
try {
const res = await fetch('/api/notas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notas: notasPayload })
});
if (res.ok) {
setSelectedStudent(null);
showAlert('Sucesso', '✅ Notas salvas com sucesso no banco de dados!', 'success');
} else {
showAlert('Erro', '❌ Falha ao salvar notas.', 'error');
}
} catch(e) {
console.error(e);
showAlert('Erro', '❌ Erro de conexão ao salvar notas.', 'error');
}
};
const calculateGeneralAverage = () => {
const periodAverages: Record<string, number> = {};
const periodsWithGrades = new Set<string>();
periods.forEach(period => {
let sumForPeriod = 0;
let countForPeriod = 0;
subjects.forEach(subject => {
const periodGrades = studentGrades[subject.id]?.[period.id] || {};
const validValues = Object.values(periodGrades).filter(v => v !== '');
if (validValues.length > 0) {
const subjectPeriodGrade = Math.min(10, validValues.reduce<number>((a, b: any) => a + Number(b), 0));
sumForPeriod += subjectPeriodGrade;
countForPeriod++;
}
});
if (countForPeriod > 0) {
periodAverages[period.id] = sumForPeriod / countForPeriod;
periodsWithGrades.add(period.id);
}
});
if (periodsWithGrades.size === 0) return '0.00';
if (periodsWithGrades.size === 1) {
const singlePeriodId = Array.from(periodsWithGrades)[0];
return periodAverages[singlePeriodId].toFixed(2);
}
const sumOfPeriods = Array.from(periodsWithGrades).reduce((acc, pId) => acc + periodAverages[pId], 0);
return (sumOfPeriods / periodsWithGrades.size).toFixed(2);
};
const getStudentGeneralAverage = (studentId: string) => {
// Priorizar notas do Postgres (classGrades) sobre o JSON
const studentGradesList = classGrades.length > 0
? classGrades.filter(g => g.studentId === studentId)
: grades.filter(g => g.studentId === studentId);
if (studentGradesList.length === 0) return '0.00';
const periodAverages: Record<string, { sum: number, count: number }> = {};
const subjectPeriodGrades: Record<string, Record<string, number[]>> = {};
studentGradesList.forEach(g => {
if (!subjectPeriodGrades[g.subjectId]) subjectPeriodGrades[g.subjectId] = {};
if (!subjectPeriodGrades[g.subjectId][g.period]) subjectPeriodGrades[g.subjectId][g.period] = [];
subjectPeriodGrades[g.subjectId][g.period].push(g.value);
});
Object.entries(subjectPeriodGrades).forEach(([subjectId, periodMap]) => {
Object.entries(periodMap).forEach(([periodId, values]) => {
if (values.length > 0) {
const subjectPeriodSum = Math.min(10, values.reduce((a, b) => a + b, 0));
if (!periodAverages[periodId]) {
periodAverages[periodId] = { sum: 0, count: 0 };
}
periodAverages[periodId].sum += subjectPeriodSum;
periodAverages[periodId].count++;
}
});
});
const activePeriods = Object.keys(periodAverages);
if (activePeriods.length === 0) return '0.00';
const periodAvgValues = activePeriods.map(pId => periodAverages[pId].sum / periodAverages[pId].count);
if (activePeriods.length === 1) {
return periodAvgValues[0].toFixed(2);
}
const totalSum = periodAvgValues.reduce((a, b) => a + b, 0);
return (totalSum / activePeriods.length).toFixed(2);
};
const filteredClasses = dbClasses.filter(c =>
(c.name || '').toLowerCase().includes((searchTerm || '').toLowerCase())
);
return (
<div className="space-y-8 animate-in fade-in duration-300 pb-20">
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Boletim Escolar</h2>
<p className="text-slate-500 font-medium">Gerencie as notas e o desempenho dos alunos.</p>
</div>
<button
onClick={() => setShowConfigManager(!showConfigManager)}
className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-colors font-bold text-sm flex items-center gap-2"
>
<Plus size={18} /> {showConfigManager ? 'Ver Boletins' : 'Configurações'}
</button>
</header>
{showConfigManager ? (
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xl space-y-6 animate-in slide-in-from-top-4">
<div className="flex items-center justify-between border-b border-slate-100 pb-4">
<div className="flex items-center gap-3 text-indigo-600">
<div className="p-2 bg-indigo-50 rounded-lg">
<Plus size={20} />
</div>
<h3 className="text-lg font-black text-slate-800">Gerenciar Configurações</h3>
</div>
<div className="flex bg-slate-100 p-1 rounded-xl">
<button
onClick={() => setConfigTab('subjects')}
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'subjects' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
DISCIPLINAS
</button>
<button
onClick={() => setConfigTab('periods')}
className={`px-4 py-2 rounded-lg text-xs font-black transition-all ${configTab === 'periods' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
PERÍODOS
</button>
</div>
</div>
{configTab === 'subjects' ? (
<div className="space-y-6">
<div className="flex gap-2">
<input
type="text"
placeholder="Nome da disciplina (ex: Matemática, Inglês...)"
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
value={newSubjectName}
onChange={(e) => setNewSubjectName(e.target.value)}
/>
<button
onClick={handleAddSubject}
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2"
>
<Plus size={18} /> Adicionar
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{subjects.map(subject => (
<div key={subject.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group">
<span className="font-bold text-slate-700">{subject.name}</span>
<button
onClick={() => handleDeleteSubject(subject.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 size={16} />
</button>
</div>
))}
{subjects.length === 0 && (
<div className="col-span-full py-8 text-center text-slate-400 italic text-sm">Nenhuma disciplina cadastrada.</div>
)}
</div>
</div>
) : (
<div className="space-y-6">
<div className="flex gap-2">
<input
type="text"
placeholder="Nome do período (ex: 1º Bimestre, Recuperação...)"
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm"
value={newPeriodName}
onChange={(e) => setNewPeriodName(e.target.value)}
/>
<button
onClick={handleAddPeriod}
className="px-6 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all flex items-center gap-2"
>
<Plus size={18} /> Adicionar
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{periods.map(period => (
<div key={period.id} className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100 group">
<span className="font-bold text-slate-700">{period.name}</span>
<button
onClick={() => handleDeletePeriod(period.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 size={16} />
</button>
</div>
))}
{periods.length === 0 && (
<div className="col-span-full py-8 text-center text-slate-400 italic text-sm">Nenhum período cadastrado.</div>
)}
</div>
</div>
)}
</div>
) : (
<div className="space-y-6">
{!selectedClass ? (
<>
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input
type="text"
placeholder="Buscar turmas..."
className="w-full pl-10 pr-4 py-3 bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredClasses.map(cls => {
const course = dbCourses.find(c => c.id === cls.courseId);
const studentCount = data.students.filter(s => s.classId === cls.id).length;
return (
<div
key={cls.id}
onClick={() => setSelectedClass(cls)}
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-xl hover:border-indigo-200 transition-all cursor-pointer group relative overflow-hidden"
>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<BookOpen size={80} />
</div>
<div className="flex items-center gap-4 mb-4">
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-xl group-hover:bg-indigo-600 group-hover:text-white transition-colors">
<GraduationCap size={24} />
</div>
<div>
<h3 className="font-black text-slate-800 text-lg">{cls.name}</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">{course?.nome || course?.name || 'Curso não encontrado'}</p>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500 font-medium">{studentCount} Alunos Matriculados</span>
<ChevronRight size={20} className="text-slate-300 group-hover:text-indigo-500 transform group-hover:translate-x-1 transition-all" />
</div>
</div>
);
})}
</div>
</>
) : (
<div className="space-y-6 animate-in slide-in-from-left-4">
<button
onClick={() => setSelectedClass(null)}
className="flex items-center gap-2 text-slate-500 hover:text-indigo-600 font-bold text-sm transition-colors"
>
<X size={18} /> Voltar para Turmas
</button>
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xl">
<div className="flex items-center justify-between mb-8">
<div>
<h3 className="text-2xl font-black text-slate-800">{selectedClass.name}</h3>
<p className="text-slate-500 font-medium">Selecione um aluno para preencher as notas.</p>
</div>
<div className="px-4 py-2 bg-indigo-50 text-indigo-600 rounded-xl font-bold text-sm">
{data.students.filter(s => s.classId === selectedClass.id).length} Alunos
</div>
</div>
<div className="space-y-3">
{data.students
.filter(s => s.classId === selectedClass.id)
.sort((a, b) => a.name.localeCompare(b.name))
.map(student => (
<div
key={student.id}
className="p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-indigo-200 transition-all flex items-center justify-between group"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-10 h-10 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 flex-shrink-0 overflow-hidden">
{student.photo ? (
<img src={normalizePhotoUrl(student.photo)} alt={student.name} className="w-full h-full object-cover" />
) : (
<User size={20} />
)}
</div>
<span className="font-bold text-slate-700 text-sm">{student.name}</span>
</div>
<div className="flex items-center gap-4 flex-shrink-0 ml-4">
<div className="hidden sm:flex flex-col items-end">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Média Geral</span>
<span className={`text-sm font-black ${parseFloat(getStudentGeneralAverage(student.id)) >= 6 ? 'text-emerald-600' : 'text-red-600'}`}>
{getStudentGeneralAverage(student.id)}
</span>
</div>
<button
onClick={() => handleOpenStudentGrades(student)}
className="px-3 py-1.5 bg-white text-indigo-600 border border-indigo-100 rounded-lg hover:bg-indigo-600 hover:text-white transition-all font-bold text-xs flex items-center gap-1.5 shadow-sm"
>
<FileText size={14} /> Notas
</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
{/* GRADES MODAL */}
{selectedStudent && (
<div className="fixed inset-0 bg-transparent z-50 flex items-center justify-center p-4 animate-in fade-in duration-300">
<div className="bg-white rounded-3xl w-full max-w-4xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh] animate-slide-up">
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-white shadow-sm flex items-center justify-center text-indigo-600">
<GraduationCap size={28} />
</div>
<div>
<h3 className="text-xl font-black text-slate-800">{selectedStudent.name}</h3>
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Boletim Escolar {selectedClass?.name}</p>
</div>
</div>
<button
onClick={() => setSelectedStudent(null)}
className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-xl shadow-sm transition-all"
>
<X size={20} />
</button>
</div>
<div className="p-6 overflow-y-auto space-y-8 custom-scrollbar">
{subjects.length === 0 || periods.length === 0 ? (
<div className="text-center py-12 space-y-4">
<AlertCircle size={48} className="mx-auto text-amber-500 opacity-50" />
<p className="text-slate-500 font-medium">
{subjects.length === 0 ? 'Nenhuma disciplina cadastrada.' : 'Nenhum período cadastrado.'}
Por favor, complete as configurações primeiro.
</p>
<button
onClick={() => { setSelectedStudent(null); setShowConfigManager(true); }}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg font-bold text-sm"
>
Ir para Configurações
</button>
</div>
) : (
<div className="space-y-6">
{subjects.map(subject => {
// Encontrar provas ativas vinculadas a esta disciplina
const linkedExams = (dbExams || []).filter(e => String(e.subjectId).trim() === String(subject.id).trim());
return (
<div key={subject.id} className="bg-slate-50 rounded-2xl p-6 border border-slate-100 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-indigo-600">
<BookOpen size={18} />
<h4 className="font-black text-slate-800 uppercase tracking-wider text-sm">{subject.name}</h4>
</div>
<div className="flex items-center gap-2">
{(() => {
const linkedExams = (dbExams || []).filter(e =>
String(e.subjectId).trim() === String(subject.id).trim()
);
const provasCount = linkedExams.filter(e => (e as any).evaluationType !== 'activity').length;
const atividadesCount = linkedExams.filter(e => (e as any).evaluationType === 'activity').length;
return (
<>
{provasCount > 0 && (
<div className="px-3 py-1 bg-violet-50 border border-violet-200 rounded-lg text-[10px] font-black text-violet-600 flex items-center gap-1">
<FileText size={12} />
{provasCount} {provasCount === 1 ? 'Prova' : 'Provas'}
</div>
)}
{atividadesCount > 0 && (
<div className="px-3 py-1 bg-sky-50 border border-sky-200 rounded-lg text-[10px] font-black text-sky-600 flex items-center gap-1">
<FileText size={12} />
{atividadesCount} {atividadesCount === 1 ? 'Atividade' : 'Atividades'}
</div>
)}
</>
);
})()}
<div className="px-3 py-1 bg-white border border-slate-200 rounded-lg text-[10px] font-black text-slate-500">
MÉDIA: {(() => {
const subjectGrades = studentGrades[subject.id] || {};
const pAvgs: number[] = [];
Object.values(subjectGrades).forEach((exVals: any) => {
const validValues = Object.values(exVals).filter(v => v !== '');
if (validValues.length > 0) {
pAvgs.push(Math.min(10, validValues.reduce<number>((a, b: any) => a + Number(b), 0)));
}
});
return pAvgs.length > 0 ? (pAvgs.reduce((a, b) => a + b, 0) / pAvgs.length).toFixed(1) : '0.0';
})()}
</div>
</div>
</div>
<div className="flex flex-col gap-6">
{periods.map(period => {
const linkedExams = (dbExams || []).filter(e =>
String(e.subjectId).trim() === String(subject.id).trim() &&
String(e.periodId).trim() === String(period.id).trim()
);
const periodGrades = studentGrades[subject.id]?.[period.id] || {};
const validPeriodValues = Object.values(periodGrades).filter(v => v !== '');
const periodAvg: number = validPeriodValues.length > 0
? Math.min(10, validPeriodValues.reduce<number>((a, b: any) => a + Number(b), 0))
: 0;
return (
<div key={period.id} className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm space-y-3 relative">
<div className="flex items-center justify-between border-b border-slate-100 pb-2 mb-2">
<label className="block text-xs font-black text-slate-700 uppercase tracking-widest">{period.name}</label>
<span className="text-[10px] font-bold bg-slate-100 text-slate-600 px-2 py-1 rounded-md">Média: {periodAvg.toFixed(1)}</span>
</div>
{linkedExams.length > 0 ? (
<div className="space-y-4">
{linkedExams.map(exam => {
const isActivity = (exam as any).evaluationType === 'activity';
const maxScore = (exam as any).maxScore ?? 10;
return (
<div key={exam.id} className={`p-4 rounded-xl border flex flex-col md:flex-row md:items-center justify-between gap-4 ${isActivity ? 'bg-sky-50/50 border-sky-100' : 'bg-violet-50/50 border-violet-100'}`}>
<div className="flex flex-col">
<div className="text-sm font-bold text-slate-800 leading-tight mb-1 flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-[9px] uppercase tracking-wider font-black shrink-0 ${isActivity ? 'bg-sky-200 text-sky-800' : 'bg-violet-200 text-violet-800'}`}>
{isActivity ? 'Atividade' : 'Prova'}
</span>
{exam.title}
</div>
<div className="flex flex-col gap-1">
{exam.description && (
<p className="text-xs text-slate-500 leading-snug pr-2">{exam.description}</p>
)}
{(() => {
const stats = studentSubmissions[String(exam.id).trim()];
if (!stats) return null;
return (
<div className="flex gap-2 mt-2">
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 border border-emerald-200 flex items-center gap-1">
<CheckCircle2 size={10} /> {stats.acertos} Acertos
</span>
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-rose-100 text-rose-700 border border-rose-200 flex items-center gap-1">
<X size={10} /> {stats.erros} Erros
</span>
</div>
);
})()}
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-[10px] font-bold text-slate-500 uppercase">Nota (Máx {maxScore})</span>
<input
type="number"
min="0"
max={maxScore}
step="0.1"
placeholder="—"
className={`w-24 px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 transition-all text-sm font-black text-center ${isActivity ? 'bg-white border-sky-200 focus:ring-sky-500' : 'bg-white border-violet-200 focus:ring-violet-500'}`}
value={studentGrades[subject.id]?.[period.id]?.[exam.id] ?? ''}
onChange={(e) => {
let val = parseFloat(e.target.value);
if (val > maxScore) val = maxScore;
if (val < 0) val = 0;
setStudentGrades(prev => ({
...prev,
[subject.id]: {
...prev[subject.id],
[period.id]: {
...prev[subject.id]?.[period.id],
[exam.id]: isNaN(val) ? '' : val
}
}
}));
}}
/>
</div>
</div>
)
})}
</div>
) : (
<div className="space-y-1.5 mt-2">
<p className="text-[9px] text-slate-400 font-medium mb-1">Nota Direta (Sem avaliação vinculada)</p>
<input
type="number"
min="0"
max="10"
step="0.1"
placeholder="—"
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-bold text-center"
value={studentGrades[subject.id]?.[period.id]?.direct ?? ''}
onChange={(e) => {
let val = parseFloat(e.target.value);
if (val > 10) val = 10;
if (val < 0) val = 0;
setStudentGrades(prev => ({
...prev,
[subject.id]: {
...prev[subject.id],
[period.id]: {
direct: isNaN(val) ? '' : val
}
}
}));
}}
/>
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
{/* General Average Summary */}
<div className="bg-indigo-600 rounded-2xl p-6 text-white flex items-center justify-between shadow-xl shadow-indigo-100">
<div className="flex items-center gap-3">
<div className="p-3 bg-white/20 rounded-xl">
<Calculator size={24} />
</div>
<div>
<h4 className="text-lg font-black">Média Geral</h4>
<p className="text-xs text-indigo-100 font-medium">Calculada automaticamente com base em todas as disciplinas.</p>
</div>
</div>
<div className="text-4xl font-black">
{calculateGeneralAverage()}
</div>
</div>
</div>
)}
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex justify-end gap-3">
<button
onClick={() => setSelectedStudent(null)}
className="px-6 py-3 bg-white text-slate-600 border border-slate-200 rounded-xl font-bold text-sm hover:bg-slate-100 transition-all"
>
Cancelar
</button>
<button
onClick={handleSaveGrades}
className="px-8 py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 flex items-center gap-2"
>
<Save size={18} /> Salvar Notas
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default ReportCard;

View File

@ -0,0 +1,115 @@
import React, { useState, useRef, useEffect } from 'react';
import { Search, ChevronDown, X } from 'lucide-react';
interface Option {
id: string;
name: string;
subtext?: string;
}
interface SearchableSelectProps {
options: Option[];
value: string;
onChange: (value: string) => void;
placeholder: string;
label: string;
required?: boolean;
}
const SearchableSelect: React.FC<SearchableSelectProps> = ({
options,
value,
onChange,
placeholder,
label,
required = false
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const selectedOption = options.find(opt => opt.id === value);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredOptions = options.filter(opt =>
(opt.name || '').toLowerCase().includes((searchTerm || '').toLowerCase()) ||
(opt.subtext && (opt.subtext || '').toLowerCase().includes((searchTerm || '').toLowerCase()))
);
return (
<div className="relative" ref={containerRef}>
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
<div
onClick={() => setIsOpen(!isOpen)}
className={`w-full px-4 py-3 bg-slate-50 border ${isOpen ? 'border-indigo-500 ring-2 ring-indigo-500/10' : 'border-slate-200'} rounded-xl cursor-pointer flex items-center justify-between transition-all text-sm`}
>
<span className={selectedOption ? 'text-slate-800 font-medium' : 'text-slate-400'}>
{selectedOption ? selectedOption.name : placeholder}
</span>
<ChevronDown size={18} className={`text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</div>
{isOpen && (
<div className="absolute z-[110] w-full mt-2 bg-white border border-slate-200 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="p-3 border-b border-slate-100 bg-slate-50">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
<input
autoFocus
type="text"
className="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
placeholder="Pesquisar..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
<div className="max-h-60 overflow-y-auto custom-scrollbar">
{filteredOptions.length > 0 ? (
filteredOptions.map(option => (
<div
key={option.id}
onClick={() => {
onChange(option.id);
setIsOpen(false);
setSearchTerm('');
}}
className={`px-4 py-3 hover:bg-indigo-50 cursor-pointer transition-colors flex flex-col ${value === option.id ? 'bg-indigo-50 border-l-4 border-indigo-600' : ''}`}
>
<span className={`text-sm ${value === option.id ? 'font-bold text-indigo-600' : 'font-medium text-slate-700'}`}>
{option.name}
</span>
{option.subtext && (
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5">
{option.subtext}
</span>
)}
</div>
))
) : (
<div className="px-4 py-6 text-center text-slate-400 text-sm italic">
Nenhum resultado encontrado.
</div>
)}
</div>
</div>
)}
</div>
);
};
export default SearchableSelect;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,176 @@
import React, { useState } from 'react';
import {
LayoutDashboard,
Users,
BookOpen,
CircleDollarSign,
Settings,
FileSignature,
Award,
Camera,
ListChecks,
ChevronLeft,
ChevronRight,
Menu,
X,
GraduationCap,
Shield,
FileText,
Cloud,
CloudOff,
Library,
Briefcase,
LogOut,
MessageSquare,
ClipboardList,
ClipboardPen
} from 'lucide-react';
import { isSupabaseConfigured } from '../services/supabase';
import { View, User } from '../types';
interface SidebarProps {
currentView: View;
setView: (view: View) => void;
user: User | null;
logo?: string;
onLogout: () => void;
}
const Sidebar: React.FC<SidebarProps> = ({ currentView, setView, user, logo, onLogout }) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const items = [
{ id: View.Dashboard, icon: LayoutDashboard, label: 'Dashboard' },
{ id: View.Courses, icon: GraduationCap, label: 'Cursos' },
{ id: View.Students, icon: Users, label: 'Alunos' },
{ id: View.Classes, icon: BookOpen, label: 'Turmas' },
{ id: View.Exams, icon: ClipboardList, label: 'Atividades e Provas' },
{ id: View.ReportCard, icon: FileText, label: 'Boletim Escolar' },
{ id: View.Finance, icon: CircleDollarSign, label: 'Financeiro' },
{ id: View.Contracts, icon: FileSignature, label: 'Contratos' },
{ id: View.Certificates, icon: Award, label: 'Certificados' },
{ id: View.Attendance, icon: Camera, label: 'Frequência' },
{ id: View.AttendanceQuery, icon: ListChecks, label: 'Registro de Frequência' },
{ id: View.Handouts, icon: Library, label: 'Apostilas' },
{ id: View.Employees, icon: Briefcase, label: 'Funcionários' },
{ id: View.Users, icon: Shield, label: 'Usuários' },
{ id: View.Messages, icon: MessageSquare, label: 'Mensagens' },
{ id: View.PreMatricula, icon: ClipboardPen, label: 'Pré-Matrícula' },
{ id: View.Settings, icon: Settings, label: 'Configurações' },
];
const toggleMobile = () => setIsMobileOpen(!isMobileOpen);
const supabaseConfigured = isSupabaseConfigured();
return (
<>
{/* Mobile Toggle */}
<div className="md:hidden fixed top-0 left-0 right-0 bg-white border-b border-slate-200 p-4 flex justify-between items-center z-40">
<h1 className="text-xl font-bold text-indigo-600 flex items-center gap-2">
{logo ? <img src={logo} alt="Logo" className="h-10 w-10 object-contain flex-shrink-0" /> : <BookOpen size={24} />}
<span>EduManager</span>
</h1>
<button onClick={toggleMobile} className="p-2 text-slate-600">
{isMobileOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{/* Sidebar Overlay for Mobile */}
{isMobileOpen && (
<div
className="md:hidden fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-40"
onClick={() => setIsMobileOpen(false)}
/>
)}
{/* Sidebar Container */}
<aside className={`
fixed md:static inset-y-0 left-0 z-50 bg-white border-r border-slate-200 flex flex-col transition-all duration-300
${isMobileOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0'}
${isCollapsed ? 'md:w-20' : 'md:w-64'}
`}>
<div className={`p-6 border-b border-slate-200 flex items-center ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
{(!isCollapsed || isMobileOpen) && (
<h1 className="text-xl font-bold text-indigo-600 flex items-center gap-2 overflow-hidden whitespace-nowrap">
{logo ? <img src={logo} alt="Logo" className="h-10 w-10 object-contain flex-shrink-0" /> : <BookOpen size={24} className="flex-shrink-0" />}
<span>EduManager</span>
</h1>
)}
{isCollapsed && !isMobileOpen && (logo ? <img src={logo} alt="Logo" className="h-10 w-10 object-contain flex-shrink-0" /> : <BookOpen size={24} className="text-indigo-600" />)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="hidden md:flex p-1.5 rounded-lg hover:bg-slate-100 text-slate-400 ml-2"
>
{isCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
<nav className="flex-1 p-4 space-y-2 overflow-y-auto">
{items.map((item) => (
<button
key={item.id}
onClick={() => {
setView(item.id);
setIsMobileOpen(false);
}}
title={isCollapsed ? item.label : ''}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all duration-200 ${
currentView === item.id
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
} ${isCollapsed && !isMobileOpen ? 'justify-center px-0' : ''}`}
>
<item.icon size={22} className="flex-shrink-0" />
{(!isCollapsed || isMobileOpen) && <span>{item.label}</span>}
</button>
))}
</nav>
<div className="p-4 border-t border-slate-100 space-y-3">
<div className={`flex items-center gap-3 ${isCollapsed ? 'justify-center' : 'px-4 py-2'}`}>
{user?.photoURL ? (
<img
src={user.photoURL}
alt={user.displayName || user.name}
className="w-8 h-8 rounded-full object-cover border border-slate-200"
referrerPolicy="no-referrer"
/>
) : (
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-700 font-bold text-xs">
{user?.name?.substring(0, 2).toUpperCase() || 'AD'}
</div>
)}
{!isCollapsed && (
<div className="overflow-hidden flex-1">
<p className="text-xs font-bold text-slate-900 truncate">{user?.displayName || user?.name || 'Administrador'}</p>
<p className="text-[10px] text-slate-500 truncate uppercase tracking-tighter">{user?.role === 'admin' ? 'Administrador' : 'Usuário'}</p>
</div>
)}
{!isCollapsed && (
<button
onClick={onLogout}
className="p-1.5 text-slate-400 hover:text-red-500 rounded-lg transition-all"
title="Sair"
>
<LogOut size={16} />
</button>
)}
</div>
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg ${supabaseConfigured ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-50 text-slate-400'} ${isCollapsed ? 'justify-center px-0' : ''}`}>
{supabaseConfigured ? <Cloud size={16} /> : <CloudOff size={16} />}
{!isCollapsed && (
<span className="text-[10px] font-black uppercase tracking-widest">
{supabaseConfigured ? 'Nuvem Ativa' : 'Nuvem Inativa'}
</span>
)}
</div>
</div>
</aside>
</>
);
};
export default Sidebar;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,355 @@
import React, { useState, useRef } from 'react';
import { SchoolData, User } from '../types';
import { useDialog } from '../DialogContext';
import { Plus, Edit2, Trash2, X, Shield, Lock, User as UserIcon, AlertTriangle, Camera, Loader2 } from 'lucide-react';
import imageCompression from 'browser-image-compression';
import { uploadProfilePicture } from '../services/supabase';
interface UserManagementProps {
data: SchoolData;
updateData: (newData: Partial<SchoolData>) => void;
}
const UserManagement: React.FC<UserManagementProps> = ({ data, updateData }) => {
const { showAlert, showConfirm } = useDialog();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [formData, setFormData] = useState({
name: '',
displayName: '',
password: '',
cpf: '',
photoURL: '',
role: 'user' as 'admin' | 'user'
});
const closeModal = () => {
setIsClosing(true);
setTimeout(() => {
setIsModalOpen(false);
setIsClosing(false);
setEditingUser(null);
setFormData({ name: '', displayName: '', password: '', cpf: '', photoURL: '', role: 'user' });
}, 400);
};
const handleEdit = (user: User) => {
setEditingUser(user);
setFormData({
name: user.name,
displayName: user.displayName || '',
password: user.password,
cpf: user.cpf || '',
photoURL: user.photoURL || '',
role: user.role || 'user'
});
setIsModalOpen(true);
};
const handleDelete = (user: User) => {
if (data.users.length <= 1) {
showAlert('Atenção', '⚠️ Você não pode excluir o último usuário do sistema.', 'warning');
return;
}
showConfirm(
'Remover Usuário',
`Tem certeza que deseja remover o acesso de ${user.name}?`,
() => {
const updatedUsers = data.users.filter(u => u.id !== user.id);
updateData({ users: updatedUsers });
}
);
};
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setIsUploading(true);
// Compression options
const options = {
maxSizeMB: 0.1,
maxWidthOrHeight: 400,
useWebWorker: true
};
const compressedFile = await imageCompression(file, options);
// Upload to Supabase
const url = await uploadProfilePicture(editingUser?.id || 'new-user', compressedFile);
if (url) {
setFormData(prev => ({ ...prev, photoURL: url }));
} else {
showAlert('Erro', 'Não foi possível fazer o upload da imagem. Verifique a configuração do Supabase.', 'error');
}
} catch (error) {
console.error('Compression/Upload error:', error);
showAlert('Erro', 'Erro ao processar imagem.', 'error');
} finally {
setIsUploading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Basic validation
if (formData.name.length < 3) {
showAlert('Atenção', '⚠️ O nome deve ter no mínimo 3 caracteres.', 'warning');
return;
}
if (formData.password.length < 3) {
showAlert('Atenção', '⚠️ A senha deve ter no mínimo 3 caracteres.', 'warning');
return;
}
if (!formData.cpf || formData.cpf.replace(/\D/g, '').length !== 11) {
showAlert('Atenção', '⚠️ O CPF é obrigatório e deve ter 11 dígitos.', 'warning');
return;
}
if (editingUser) {
// Check if name is taken by another user
const exists = data.users.some(u => (u.name || '').toLowerCase() === (formData.name || '').toLowerCase() && u.id !== editingUser.id);
if (exists) {
showAlert('Atenção', '⚠️ Este nome de usuário já está em uso.', 'warning');
return;
}
const updatedUsers = data.users.map(u =>
u.id === editingUser.id ? {
...u,
name: formData.name,
displayName: formData.displayName,
password: formData.password,
cpf: formData.cpf,
photoURL: formData.photoURL,
role: formData.role
} : u
);
updateData({ users: updatedUsers });
} else {
// Check if name is taken
const exists = data.users.some(u => (u.name || '').toLowerCase() === (formData.name || '').toLowerCase());
if (exists) {
showAlert('Atenção', '⚠️ Este nome de usuário já está em uso.', 'warning');
return;
}
const newUser: User = {
id: crypto.randomUUID(),
name: formData.name,
displayName: formData.displayName,
password: formData.password,
cpf: formData.cpf,
photoURL: formData.photoURL,
role: formData.role
};
updateData({ users: [...data.users, newUser] });
}
closeModal();
};
const inputClass = "w-full px-4 py-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm";
return (
<div className="space-y-6 animate-in fade-in duration-300">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Usuários do Sistema</h2>
<p className="text-slate-500">Gerencie quem tem acesso administrativo ao EduManager.</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="bg-indigo-600 text-white px-6 py-3 rounded-xl flex items-center gap-2 hover:bg-indigo-700 transition-all shadow-lg font-bold"
>
<Plus size={20} /> Novo Usuário
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.users.map(user => (
<div key={user.id} className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between group">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full overflow-hidden bg-indigo-50 text-indigo-600 flex items-center justify-center border border-slate-100">
{user.photoURL ? (
<img src={user.photoURL} alt={user.name} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
) : (
<Shield size={24} />
)}
</div>
<div>
<h3 className="font-bold text-slate-900">{user.displayName || user.name}</h3>
<p className="text-xs text-slate-400 font-mono">@{user.name} {user.role === 'admin' ? 'Admin' : 'Usuário'}</p>
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleEdit(user)}
className="p-2 text-slate-400 hover:text-indigo-600 rounded-lg hover:bg-indigo-50 transition-all"
title="Editar Senha/Nome"
>
<Edit2 size={18} />
</button>
<button
onClick={() => handleDelete(user)}
className="p-2 text-slate-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-all"
title="Remover Acesso"
>
<Trash2 size={18} />
</button>
</div>
</div>
))}
</div>
{/* CREATE/EDIT MODAL */}
{isModalOpen && (
<div className={`fixed inset-0 bg-transparent flex items-center justify-center p-4 z-50 overflow-y-auto transition-opacity duration-400 ${isClosing ? 'opacity-0' : 'opacity-100 animate-in fade-in'}`}>
<div className={`bg-white rounded-xl w-full max-w-md shadow-2xl my-auto transition-all duration-400 relative overflow-hidden ${isClosing ? 'animate-slide-down-fade-out' : 'animate-slide-up'}`}>
{/* Blue Top Bar */}
<div className="bg-indigo-600 h-1.5 w-full absolute top-0 left-0 z-10"></div>
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-indigo-50/30">
<div>
<h3 className="text-xl font-black text-slate-800 tracking-tight">
{editingUser ? 'Editar Usuário' : 'Novo Usuário'}
</h3>
<p className="text-xs text-slate-500">Defina as credenciais de acesso.</p>
</div>
<button onClick={closeModal} className="p-2 bg-white text-slate-400 hover:text-red-500 rounded-lg shadow-sm transition-all hover:rotate-90">
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* Photo Upload */}
<div className="flex flex-col items-center mb-6">
<div className="relative group">
<div className="w-24 h-24 rounded-full bg-slate-100 border-2 border-indigo-100 flex items-center justify-center overflow-hidden">
{formData.photoURL ? (
<img src={formData.photoURL} alt="Preview" className="w-full h-full object-cover" referrerPolicy="no-referrer" />
) : (
<UserIcon size={40} className="text-slate-300" />
)}
{isUploading && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Loader2 className="text-white animate-spin" size={24} />
</div>
)}
</div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute bottom-0 right-0 p-2 bg-indigo-600 text-white rounded-full shadow-lg hover:bg-indigo-700 transition-all"
>
<Camera size={16} />
</button>
</div>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handlePhotoUpload}
/>
<p className="text-[10px] text-slate-400 mt-2 uppercase font-bold tracking-widest">Foto de Perfil</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Nome de Usuário</label>
<div className="relative">
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
required
className={`${inputClass} pl-10`}
placeholder="Ex: admin"
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Nome Completo</label>
<div className="relative">
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
required
className={`${inputClass} pl-10`}
placeholder="Ex: João Silva"
value={formData.displayName}
onChange={e => setFormData({...formData, displayName: e.target.value})}
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">CPF do Usuário</label>
<div className="relative">
<Shield className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
required
className={`${inputClass} pl-10`}
placeholder="000.000.000-00"
value={formData.cpf}
onChange={e => {
const val = e.target.value.replace(/\D/g, '').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d{1,2})/, '$1-$2').slice(0, 14);
setFormData({...formData, cpf: val});
}}
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Nível de Acesso</label>
<select
className={inputClass}
value={formData.role}
onChange={e => setFormData({...formData, role: e.target.value as 'admin' | 'user'})}
>
<option value="user">Usuário Comum</option>
<option value="admin">Administrador</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2 ml-1">Senha de Acesso</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
required
className={`${inputClass} pl-10`}
placeholder="Defina a senha"
value={formData.password}
onChange={e => setFormData({...formData, password: e.target.value})}
/>
</div>
</div>
<div className="pt-4 flex gap-3">
<button type="button" onClick={closeModal} className="flex-1 py-3 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 font-bold text-sm">
Cancelar
</button>
<button type="submit" className="flex-1 py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 shadow-lg font-bold text-sm">
Salvar Usuário
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default UserManagement;

View File

@ -0,0 +1,25 @@
version: '3.8'
services:
edumanager:
image: ghcr.io/sidney201900/edumanager-pro2026/edumanager:latest
environment:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_KEY=${VITE_SUPABASE_KEY}
- ASAAS_API_KEY=${ASAAS_API_KEY}
- ASAAS_WEBHOOK_TOKEN=${ASAAS_WEBHOOK_TOKEN}
networks:
- traefik-public
deploy:
mode: replicated
replicas: 1
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.http.routers.edumanager.rule=Host(`edumanager.microtecinformaticacurso.com.br`)"
- "traefik.http.routers.edumanager.entrypoints=websecure"
- "traefik.http.routers.edumanager.tls.certresolver=letsencrypt"
- "traefik.http.services.edumanager.loadbalancer.server.port=3000"
networks:
traefik-public:
external: true

19
manager/fix_all_dates.cjs Normal file
View File

@ -0,0 +1,19 @@
const { Pool } = require('pg');
const pool = new Pool({ connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager' });
async function fixAll() {
const client = await pool.connect();
try {
const { rowCount } = await client.query(`
UPDATE frequencias
SET data = data - INTERVAL '3 hours'
WHERE EXTRACT(HOUR FROM data) >= 17
AND EXTRACT(HOUR FROM data) <= 19
AND tipo = 'presence'
`);
console.log(`Corrigidas ${rowCount} presenças deslocadas pelo fuso horário (UTC -> BRT).`);
} finally {
client.release();
pool.end();
}
}
fixAll().catch(console.error);

14
manager/fix_dates.cjs Normal file
View File

@ -0,0 +1,14 @@
const { Pool } = require('pg');
const pool = new Pool({ connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager' });
async function fix() {
const client = await pool.connect();
try {
await client.query("UPDATE frequencias SET data = '2026-05-02 14:37:10' WHERE id = 'b1961ef4-9a35-495a-86dc-4e6439d9670b'");
await client.query("UPDATE frequencias SET data = '2026-04-25 15:10:55' WHERE id = '810ac2f3-860b-4173-a3c9-203c45f3d061'");
console.log('Fixed dates for Napoleão');
} finally {
client.release();
pool.end();
}
}
fix().catch(console.error);

12
manager/fix_db.js Normal file
View File

@ -0,0 +1,12 @@
const pg = require('pg');
const pool = new pg.Pool({ connectionString: 'postgresql://edumanager:EduManager2026!Seguro@postgres:5432/edumanager' });
async function run() {
try {
await pool.query("ALTER TABLE provas ADD COLUMN IF NOT EXISTS allow_retake BOOLEAN DEFAULT FALSE");
await pool.query("ALTER TABLE provas ADD COLUMN IF NOT EXISTS evaluation_type TEXT DEFAULT 'exam'");
await pool.query("ALTER TABLE provas ADD COLUMN IF NOT EXISTS max_score NUMERIC(5,2) DEFAULT 10.0");
console.log('✅ Banco de dados atualizado!');
} catch (e) { console.error(e); } finally { await pool.end(); }
}
run();

50
manager/fix_db_tz.cjs Normal file
View File

@ -0,0 +1,50 @@
const { Pool } = require('pg');
const pool = new Pool({
user: 'postgres',
host: 'localhost',
database: 'edumanager',
password: 'admin',
port: 5432,
});
async function run() {
try {
// 1. Fix frequencias table
const { rows } = await pool.query("SELECT id, data FROM frequencias WHERE justificativa IS NOT NULL");
let fixedFrequencias = 0;
for (const row of rows) {
// row.data might be a Date object or string depending on pg.
const d = new Date(row.data);
// If the hour is wrong, maybe we can just identify if it's not a round number or something?
// Wait, we can't easily tell if it's wrong just by looking at it, because we don't know the original lesson time.
console.log('Frequencia:', row.id, d.toISOString(), d.toLocaleTimeString());
}
// 2. Fix school_data JSON
const { rows: sdRows } = await pool.query("SELECT data FROM school_data WHERE id = 1");
if (sdRows.length > 0) {
const data = sdRows[0].data;
let fixed = 0;
if (data.attendance) {
data.attendance.forEach(a => {
if (a.date && typeof a.date === 'string' && a.date.endsWith('Z')) {
const d = new Date(a.date);
const localIso = new Date(d.getTime() - (d.getTimezoneOffset() * 60000)).toISOString().split('.')[0];
a.date = localIso;
fixed++;
console.log('Fixed JSON date to:', localIso);
}
});
}
if (fixed > 0) {
await pool.query("UPDATE school_data SET data = $1 WHERE id = 1", [data]);
console.log(`Corrigidos ${fixed} registros no JSON school_data!`);
}
}
} catch(e) {
console.error(e);
} finally {
pool.end();
}
}
run();

29
manager/fix_tz.js Normal file
View File

@ -0,0 +1,29 @@
import fs from 'fs';
import path from 'path';
const file = path.join(process.cwd(), 'school_data.json');
if (fs.existsSync(file)) {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
let fixed = 0;
if (data.attendance) {
data.attendance.forEach(a => {
// Se tiver .000Z ou terminar com Z
if (a.date && typeof a.date === 'string' && a.date.endsWith('Z')) {
// Ajuste brusco: remove o .000Z e ajusta o horário subtraindo 3h (fuso BRT)
// Isso é apenas para limpar dados de teste que ficaram bugados hoje.
try {
const d = new Date(a.date);
const localIso = new Date(d.getTime() - (d.getTimezoneOffset() * 60000)).toISOString().split('.')[0];
a.date = localIso;
fixed++;
} catch(e) {}
}
});
}
if (fixed > 0) {
fs.writeFileSync(file, JSON.stringify(data, null, 2));
console.log(`Corrigidos ${fixed} registros com timezone bugado no JSON!`);
} else {
console.log('Nenhum registro bugado encontrado no JSON.');
}
}

46
manager/index.html Normal file
View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EduManager</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
@keyframes slideUpFade {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}
.animate-slide-up {
animation: slideUpFade 0.4s cubic-bezier(0.25, 0.1, 0.25, 1.0) forwards;
}
.animate-slide-down-fade-out {
animation: slideUpFade 0.4s cubic-bezier(0.25, 0.1, 0.25, 1.0) reverse forwards;
}
</style>
<script type="importmap">
{
"imports": {
"recharts": "https://esm.sh/recharts@^3.7.0",
"lucide-react": "https://esm.sh/lucide-react@^0.563.0",
"react/": "https://esm.sh/react@^19.2.4/",
"react": "https://esm.sh/react@^19.2.4",
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
"jspdf": "https://esm.sh/jspdf@^2.5.1",
"jspdf-autotable": "https://esm.sh/jspdf-autotable@^3.8.2",
"@google/genai": "https://esm.sh/@google/genai@^1.40.0",
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.39.0"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-slate-50 text-slate-900">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

387
manager/index.tsx Normal file
View File

@ -0,0 +1,387 @@
import React, { useState, useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { View, SchoolData, User } from './types';
import { dbService } from './services/dbService';
import Sidebar from './components/Sidebar';
import Dashboard from './components/Dashboard';
import Students from './components/Students';
import Classes from './components/Classes';
import Courses from './components/Courses';
import Finance from './components/Finance';
import Settings from './components/Settings';
import Contracts from './components/Contracts';
import Certificates from './components/Certificates';
import AttendanceCapture from './components/AttendanceCapture';
import AttendanceQuery from './components/AttendanceQuery';
import ReportCard from './components/ReportCard';
import Auth from './components/Auth';
import UserManagement from './components/UserManagement';
import Handouts from './components/Handouts';
import Employees from './components/Employees';
import Messages from './components/Messages';
import AdminNotifications from './components/AdminNotifications';
import Exams from './components/Exams';
import PreMatricula from './components/PreMatricula';
import { Cloud, CloudOff, RefreshCw, AlertCircle } from 'lucide-react';
import { supabase, isSupabaseConfigured } from './services/supabase';
import { DialogProvider } from './DialogContext';
const App = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const [currentView, setCurrentView] = useState<View>(() => {
return (localStorage.getItem('manager_active_tab') as View) || View.Dashboard;
});
useEffect(() => {
localStorage.setItem('manager_active_tab', currentView);
}, [currentView]);
const [deepLinkStudentId, setDeepLinkStudentId] = useState<string | null>(null);
const [deepLinkClassId, setDeepLinkClassId] = useState<string | null>(null);
const [deepLinkPreMatricula, setDeepLinkPreMatricula] = useState<any | null>(null);
// Initial load from LocalStorage for speed (fallback), then IDB
const [data, setData] = useState<SchoolData>(dbService.getData());
// Sync Status
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'saved' | 'error' | 'conflict'>('idle');
const [isCloudEnabled, setIsCloudEnabled] = useState(false);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isUpdatingFromSaveRef = useRef<boolean>(false);
// 0. Load from IndexedDB on mount
useEffect(() => {
const loadSessionAndData = async () => {
try {
const savedSession = localStorage.getItem('edumanager_session');
if (savedSession) {
const user = JSON.parse(savedSession);
setCurrentUser(user);
setIsAuthenticated(true);
}
const localData = await dbService.initData();
setData(prev => ({ ...prev, ...localData }));
} catch (e) {
console.error("Erro ao carregar sessão:", e);
} finally {
setIsCheckingAuth(false);
}
};
loadSessionAndData();
}, []);
// 1. Initial Cloud Fetch (Sync on Load)
useEffect(() => {
const initCloud = async () => {
if (isSupabaseConfigured()) {
setSyncStatus('syncing');
const cloudData = await dbService.fetchFromCloud();
if (cloudData) {
// If cloud data exists, it takes precedence.
setData(cloudData);
dbService.saveData(cloudData); // Update local cache
setSyncStatus('saved');
} else {
// If no cloud data, we might be starting fresh
setSyncStatus('idle');
}
// Only enable cloud saving AFTER the initial fetch is attempted
setIsCloudEnabled(true);
}
};
initCloud();
}, []);
// 2. Save Data Effect (Local + Debounced Cloud)
useEffect(() => {
// Immediate Local Save
dbService.saveData(data);
if (isUpdatingFromSaveRef.current) {
isUpdatingFromSaveRef.current = false;
return;
}
// Debounced Cloud Save
if (isCloudEnabled) {
setSyncStatus('syncing');
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(async () => {
try {
const result = await dbService.saveToCloud(data);
if (result.success && result.lastUpdated) {
isUpdatingFromSaveRef.current = true;
// Sincroniza o timestamp local com o do servidor para evitar conflitos no polling
setData(prev => ({ ...prev, lastUpdated: result.lastUpdated }));
setSyncStatus('saved');
} else if (result.reason === 'newer_version') {
setSyncStatus('conflict');
console.warn("⚠️ Conflito de versão detectado. Sincronizando com os dados mais recentes do servidor...");
forceSyncFromCloud();
} else {
setSyncStatus('error');
}
} catch (e) {
setSyncStatus('error');
}
}, 2000); // Save to cloud 2 seconds after last change
}
return () => {
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
};
}, [data, isCloudEnabled]);
// 3. Dynamic Rounded Favicon (Canvas cropped for perfect aesthetic alignment)
useEffect(() => {
const logoUrl = data.logo;
if (logoUrl) {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
try {
const canvas = document.createElement('canvas');
const size = Math.min(img.width, img.height);
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, (img.width - size) / 2, (img.height - size) / 2, size, size, 0, 0, size, size);
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!link) {
link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'icon';
document.getElementsByTagName('head')[0].appendChild(link);
}
link.href = canvas.toDataURL('image/png');
}
} catch (e) {
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!link) {
link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'icon';
document.getElementsByTagName('head')[0].appendChild(link);
}
link.href = logoUrl;
}
};
img.onerror = () => {
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!link) {
link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'icon';
document.getElementsByTagName('head')[0].appendChild(link);
}
link.href = logoUrl;
};
img.src = logoUrl;
}
}, [data.logo]);
// 4. Efeito para Realtime (Escuta mudanças do Portal em tempo real)
const dataRef = useRef(data);
useEffect(() => {
dataRef.current = data;
}, [data]);
// 4. Polling Inteligente (Substitui o Realtime do Supabase no ambiente Self-Hosted)
// Verifica mudanças no servidor a cada 30 segundos
useEffect(() => {
const pollInterval = setInterval(async () => {
// Pequena validação para não interromper ações do usuário
if (syncStatus === 'syncing') return;
try {
const cloudData = await dbService.fetchFromCloud();
if (cloudData && cloudData.lastUpdated && dataRef.current.lastUpdated) {
const cloudTime = new Date(cloudData.lastUpdated).getTime();
const localTime = new Date(dataRef.current.lastUpdated).getTime();
// Regra crucial: Só substitui o estado local se o servidor tiver dados ESTRITAMENTE mais novos.
// Isso impede que verificações durante o "debounce" (espera de 2 segs) sobrescrevam o estado
// local com dados velhos do servidor, fazendo itens recém-criados "sumirem".
if (cloudTime > localTime) {
console.log("🔔 Polling: Novos dados detectados no servidor!");
setData(cloudData);
dbService.saveData(cloudData);
setSyncStatus('saved');
}
}
} catch (e) {
// Silencioso em caso de erro de rede temporário
}
}, 30000); // 30 segundos
return () => clearInterval(pollInterval);
}, [syncStatus]);
const updateData = (newData: Partial<SchoolData>) => {
setData(prev => ({
...prev,
...newData,
lastUpdated: new Date().toISOString()
}));
};
const handleUpdateUsers = (newUsers: User[]) => {
updateData({ users: newUsers });
};
const forceSyncFromCloud = async () => {
setSyncStatus('syncing');
const cloudData = await dbService.fetchFromCloud();
if (cloudData) {
setData(cloudData);
dbService.saveData(cloudData);
setSyncStatus('saved');
} else {
setSyncStatus('error');
}
};
const handleNavigateToStudent = (studentId: string) => {
setDeepLinkStudentId(studentId);
setCurrentView(View.AttendanceQuery);
};
const handleNavigateToClass = (classId: string, studentId?: string) => {
setDeepLinkClassId(classId);
setDeepLinkStudentId(studentId || null);
setCurrentView(View.Students);
};
const handleNavigateToStudentsWithPreMatricula = (preData: any) => {
setDeepLinkPreMatricula(preData);
setCurrentView(View.Students);
};
const renderView = () => {
switch (currentView) {
case View.Dashboard:
return <Dashboard data={data} setView={setCurrentView} />;
case View.Courses:
return <Courses data={data} updateData={updateData} />;
case View.Students:
return (
<Students
data={data}
updateData={updateData}
deepLinkStudentId={deepLinkStudentId}
deepLinkClassId={deepLinkClassId}
deepLinkPreMatricula={deepLinkPreMatricula}
clearDeepLink={() => {
setDeepLinkStudentId(null);
setDeepLinkClassId(null);
setDeepLinkPreMatricula(null);
}}
/>
);
case View.Classes:
return <Classes data={data} updateData={updateData} onNavigateToClass={handleNavigateToClass} />;
case View.Finance:
return <Finance data={data} updateData={updateData} />;
case View.Contracts:
return <Contracts data={data} updateData={updateData} />;
case View.Certificates:
return <Certificates data={data} updateData={updateData} />;
case View.Attendance:
return <AttendanceCapture data={data} updateData={updateData} />;
case View.AttendanceQuery:
return <AttendanceQuery data={data} updateData={updateData} deepLinkStudentId={deepLinkStudentId} clearDeepLink={() => setDeepLinkStudentId(null)} />;
case View.ReportCard:
return <ReportCard data={data} updateData={updateData} />;
case View.Handouts:
return <Handouts data={data} updateData={updateData} />;
case View.Exams:
return <Exams data={data} updateData={updateData} />;
case View.Employees:
return <Employees data={data} updateData={updateData} />;
case View.Users:
return <UserManagement data={data} updateData={updateData} />;
case View.Messages:
return <Messages data={data} updateData={updateData} />;
case View.Settings:
return <Settings data={data} updateData={updateData} setData={setData} />;
case View.PreMatricula:
return <PreMatricula data={data} updateData={updateData} onConvert={handleNavigateToStudentsWithPreMatricula} />;
default:
return <Dashboard data={data} setView={setCurrentView} />;
}
};
if (isCheckingAuth) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<RefreshCw size={48} className="text-indigo-600 animate-spin" />
</div>
);
}
if (!isAuthenticated) {
return <Auth data={data} onLogin={(user) => {
localStorage.setItem('edumanager_session', JSON.stringify(user));
setCurrentUser(user);
setIsAuthenticated(true);
}} onUpdateUsers={handleUpdateUsers} />;
}
const handleLogout = () => {
localStorage.removeItem('edumanager_session');
setIsAuthenticated(false);
setCurrentUser(null);
setCurrentView(View.Dashboard);
};
return (
<div className="flex min-h-screen bg-slate-50 relative">
<Sidebar currentView={currentView} setView={setCurrentView} user={currentUser} logo={data.logo} onLogout={handleLogout} />
<main className="flex-1 w-full overflow-y-auto max-h-screen pt-16 md:pt-0 relative">
{/* Sync Indicator - Green Strip on the Right */}
{syncStatus === 'syncing' && (
<div className="fixed top-6 right-0 z-[100] flex flex-col items-end pointer-events-none animate-in slide-in-from-right duration-500">
<div className="bg-emerald-500 text-white py-2.5 px-6 shadow-2xl flex items-center gap-3 border-l-4 border-emerald-300">
<RefreshCw size={16} className="animate-spin" />
<span className="text-[10px] font-black uppercase tracking-[0.2em]">Sincronizando</span>
</div>
<div className="w-full h-1 bg-emerald-600/20 relative overflow-hidden">
<div className="absolute inset-0 bg-white/60 animate-pulse"></div>
</div>
</div>
)}
{/* Conflict Alert - Only show when there is a version mismatch */}
{syncStatus === 'conflict' && (
<div className="fixed bottom-6 right-6 z-[100] animate-in slide-in-from-bottom duration-500">
<button
onClick={forceSyncFromCloud}
className="flex items-center gap-3 px-6 py-3 bg-amber-500 text-white rounded-2xl font-black text-xs shadow-2xl hover:bg-amber-600 transition-all active:scale-95 border-2 border-white"
>
<AlertCircle size={18} />
<span>DADOS NOVOS NA NUVEM - CLIQUE PARA ATUALIZAR</span>
</button>
</div>
)}
<div className="max-w-7xl mx-auto p-4 md:p-8">
<AdminNotifications data={data} updateData={updateData} setView={setCurrentView} onNavigateToStudent={handleNavigateToStudent} />
{renderView()}
</div>
</main>
</div>
);
};
const root = createRoot(document.getElementById('root')!);
root.render(
<DialogProvider>
<App />
</DialogProvider>
);

49
manager/injetar_magia.ts Normal file
View File

@ -0,0 +1,49 @@
import fs from 'fs';
async function migrarPelaWeb() {
console.log('🚀 Preparando o envio de dados via Injeção Web (Driblando Firewall)...');
// 1. Lendo os arquivos
const sql = fs.readFileSync('../schema.sql', 'utf8');
// Pegue o seu arquivo que já foi salvo!
const arquivos = fs.readdirSync('.');
// Garante que vai pegar especificamente o arquivo migrado e limpo!
const arquivoBackup = arquivos.find(a => a.includes('_migrado.json'));
if (!arquivoBackup) {
console.log('❌ O JSON de backup não foi encontrado na pasta manager!');
return;
}
const jsonData = JSON.parse(fs.readFileSync(arquivoBackup, 'utf8'));
// 2. Fazendo o Disparo para a WEB!!!
console.log('🔥 Disparando os dados para a sua nuvem através da porta 443 liberada!');
try {
const payloadLength = JSON.stringify({ senha: 'magia2026', sql, jsonData }).length;
console.log(`📦 Tamanho da carga: ${(payloadLength / 1024 / 1024).toFixed(2)} MB`);
const response = await fetch('https://edumanager.microtecinformaticacurso.com.br/api/migracao-remota', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
senha: 'magia2026',
sql: sql,
jsonData: jsonData
})
});
const resultado = await response.json();
if (response.ok) {
console.log('✅ SUCESSO ABSOLUTO:', resultado.message);
console.log('O seu banco de dados na VPS está criado e populado! Pode abrir o sistema.');
} else {
console.log('⚠️ ALERTA:', resultado.error || resultado);
}
} catch (err) {
console.error('❌ Falha na conexão HTTP:', err.message);
}
}
migrarPelaWeb();

7
manager/metadata.json Normal file
View File

@ -0,0 +1,7 @@
{
"name": "Remix: EduManager - Sistema de Gestão Escolar para porteiner",
"description": "A comprehensive school management system for computer courses featuring student registration, class scheduling, financial tracking, contract generation, and employee management.",
"requestFramePermissions": [
"camera"
]
}

View File

@ -0,0 +1,147 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import * as Minio from 'minio';
import { fileURLToPath } from 'url';
// Ignora o erro de certificado SSL (Self-Signed) durante a migração local
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ============================================================================
// CONFIGURAÇÕES DO MINIO
// Substitua com as credenciais do seu Portainer/MinIO local
// ============================================================================
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'storageedu.microtecinformaticacurso.com.br';
const MINIO_PORT = parseInt(process.env.MINIO_PORT || '443');
const MINIO_USE_SSL = process.env.MINIO_USE_SSL !== 'false';
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || 'minioadmin';
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || 'MiniO2026!Seguro';
const MINIO_PUBLIC_URL = process.env.MINIO_PUBLIC_URL || `https://${MINIO_ENDPOINT}`;
const minioClient = new Minio.Client({
endPoint: MINIO_ENDPOINT,
port: MINIO_PORT,
useSSL: MINIO_USE_SSL,
accessKey: MINIO_ACCESS_KEY,
secretKey: MINIO_SECRET_KEY,
});
// ============================================================================
// FUNÇÃO AUXILIAR DE UPLOAD
// ============================================================================
async function uploadBase64ToMinio(base64String: string, bucketName: string, fileNamePrefix: string): Promise<string> {
if (!base64String || !base64String.startsWith('data:image')) {
return base64String; // Retorna como está se não for Base64 válido
}
// Extrai o MIME Type e os dados puros da string (Ex: data:image/jpeg;base64,...)
const matches = base64String.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/);
if (!matches || matches.length !== 3) {
return base64String;
}
const mimeType = matches[1];
const base64Data = matches[2];
const extension = mimeType.split('/')[1] || 'jpeg'; // Pega a extensão (jpeg, png, webp)
const buffer = Buffer.from(base64Data, 'base64');
const fileName = `${fileNamePrefix}-${Date.now()}.${extension}`;
// Garante que o Bucket existe no MinIO
const bucketExists = await minioClient.bucketExists(bucketName);
if (!bucketExists) {
await minioClient.makeBucket(bucketName, 'us-east-1');
console.log(`🪣 Bucket criado: ${bucketName}`);
}
// Faz o upload do Buffer
await minioClient.putObject(bucketName, fileName, buffer, buffer.length, {
'Content-Type': mimeType,
});
// Retorna a URL pública gerada
return `${MINIO_PUBLIC_URL}/${bucketName}/${fileName}`;
}
// ============================================================================
// FUNÇÃO PRINCIPAL DE MIGRAÇÃO
// ============================================================================
async function runMigration() {
console.log('🚀 Iniciando extração e migração de Base64 para MinIO...\n');
const inputFilePath = path.join(__dirname, 'backup_supabase_2026-04-19.json');
const outputFilePath = path.join(__dirname, 'backup_supabase_2026-04-19_migrado.json');
try {
// 1. Ler arquivo JSON
console.log('📦 Lendo arquivo de backup...');
const rawData = await fs.readFile(inputFilePath, 'utf-8');
const db = JSON.parse(rawData);
// 2. Migrar Logo da Escola (Tabela 'profile')
if (db.profile && db.profile.logo && db.profile.logo.startsWith('data:image')) {
console.log('🏢 Processando Logo da Escola...');
db.profile.logo = await uploadBase64ToMinio(db.profile.logo, 'escola', 'logo');
}
// 3. Migrar Fotos dos Alunos (Tabela 'students')
if (db.students && Array.isArray(db.students)) {
console.log(`🎓 Processando fotos de ${db.students.length} alunos...`);
for (let i = 0; i < db.students.length; i++) {
const student = db.students[i];
if (student.photo && student.photo.startsWith('data:image')) {
student.photo = await uploadBase64ToMinio(student.photo, 'alunos', `aluno-${student.id}`);
}
}
}
// 4. Migrar Fotos e Atestados da Frequência (Tabela 'attendance')
if (db.attendance && Array.isArray(db.attendance)) {
console.log(`📅 Processando ${db.attendance.length} registros de frequência...`);
for (let i = 0; i < db.attendance.length; i++) {
const record = db.attendance[i];
// 4.1 Foto de biometria/presença
if (record.photo && record.photo.startsWith('data:image')) {
record.photo = await uploadBase64ToMinio(record.photo, 'presenca', `presenca-${record.studentId}`);
}
// 4.2 Atestados médicos (Dentro do JSON stringificado em 'justification')
if (record.justification) {
try {
const justObj = JSON.parse(record.justification);
if (justObj.arquivo_base64 && justObj.arquivo_base64.startsWith('data:image')) {
// Upload da imagem do atestado
const newUrl = await uploadBase64ToMinio(
justObj.arquivo_base64,
'atestados',
`atestado-${record.studentId}`
);
// Substitui o Base64 pela URL limpa e recria a string JSON
justObj.arquivo_base64 = newUrl;
record.justification = JSON.stringify(justObj);
console.log(` ✅ Atestado migrado para aluno: ${record.studentId}`);
}
} catch (e) {
// Ignora se 'justification' não for um JSON válido
}
}
}
}
// 5. Salvar o novo banco de dados limpo
console.log('\n💾 Salvando novo backup JSON migrado...');
await fs.writeFile(outputFilePath, JSON.stringify(db, null, 2), 'utf-8');
console.log(`\n✅ MIGO CONCLUÍDA COM SUCESSO!`);
console.log(`✅ Arquivo gerado em: ${outputFilePath}`);
console.log(`⚠️ IMPORTANTE: Suas senhas e matrículas não foram alteradas.`);
} catch (error) {
console.error('❌ Ocorreu um erro durante a migração:', error);
}
}
runMigration();

561
manager/migrate_to_local.ts Normal file
View File

@ -0,0 +1,561 @@
/**
* ============================================================
* SCRIPT DE MIGRAÇÃO: SUPABASE CLOUD POSTGRESQL LOCAL
* ============================================================
*
* COMO USAR:
* 1. Certifique-se de que o PostgreSQL local está rodando (docker-compose up postgres)
* 2. Instale as dependências: npm install pg @supabase/supabase-js dotenv
* 3. Configure as variáveis de ambiente no arquivo .env.migration
* 4. Execute: npx tsx migrate_to_local.ts
*
* IMPORTANTE:
* - Este script NÃO altera nada no Supabase. Ele apenas .
* - Senhas são copiadas EXATAMENTE como estão, sem rehash.
* - O script usa transações atômicas: se falhar no meio, nada é salvo.
*/
import { createClient } from '@supabase/supabase-js';
import pg from 'pg';
import fs from 'fs';
// ============================================================
// CONFIGURAÇÃO — Altere aqui ou use .env.migration
// ============================================================
const SUPABASE_URL = process.env.VITE_SUPABASE_URL || 'https://ekbuvcjsfcczviqqlfit.supabase.co';
const SUPABASE_KEY = process.env.VITE_SUPABASE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVrYnV2Y2pzZmNjenZpcXFsZml0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA5OTU0MzIsImV4cCI6MjA4NjU3MTQzMn0.oIzBeGF-PjaviZejYb1TeOOEzMm-Jjth1XzvJrjD6us';
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager';
// ============================================================
// INICIALIZAÇÃO
// ============================================================
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
const pool = new pg.Pool({ connectionString: DATABASE_URL });
function log(emoji: string, msg: string) {
console.log(`${emoji} ${msg}`);
}
function logCount(table: string, count: number) {
log('📦', `${table}: ${count} registro(s) migrado(s)`);
}
// ============================================================
// FUNÇÕES DE MIGRAÇÃO POR ENTIDADE
// ============================================================
async function migrateConfiguracoes(client: pg.PoolClient, schoolData: any) {
const profile = schoolData.profile || {};
const evoConfig = schoolData.evolutionConfig || {};
const msgTemplates = schoolData.messageTemplates || {};
await client.query(`
INSERT INTO configuracoes (id, nome, endereco, cidade, estado, cep, cnpj, telefone, email, tipo, logo, evolution_api_url, evolution_instance_name, evolution_api_key, message_templates)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, endereco = EXCLUDED.endereco, cidade = EXCLUDED.cidade,
estado = EXCLUDED.estado, cep = EXCLUDED.cep, cnpj = EXCLUDED.cnpj,
telefone = EXCLUDED.telefone, email = EXCLUDED.email, tipo = EXCLUDED.tipo,
logo = EXCLUDED.logo, evolution_api_url = EXCLUDED.evolution_api_url,
evolution_instance_name = EXCLUDED.evolution_instance_name,
evolution_api_key = EXCLUDED.evolution_api_key,
message_templates = EXCLUDED.message_templates
`, [
profile.id || 'main-school',
profile.name || 'EduManager School',
profile.address || '',
profile.city || '',
profile.state || '',
profile.zip || '',
profile.cnpj || '',
profile.phone || '',
profile.email || '',
profile.type || 'matriz',
schoolData.logo || '',
evoConfig.apiUrl || null,
evoConfig.instanceName || null,
evoConfig.apiKey || null,
JSON.stringify(msgTemplates)
]);
logCount('configuracoes', 1);
}
async function migrateUsuarios(client: pg.PoolClient, users: any[]) {
for (const u of users) {
await client.query(`
INSERT INTO usuarios (id, username, display_name, photo_url, password, cpf, role)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
username = EXCLUDED.username, display_name = EXCLUDED.display_name,
password = EXCLUDED.password, cpf = EXCLUDED.cpf, role = EXCLUDED.role
`, [u.id, u.name, u.displayName || null, u.photoURL || null, u.password, u.cpf || '', u.role || 'admin']);
}
logCount('usuarios', users.length);
}
async function migrateCursos(client: pg.PoolClient, courses: any[]) {
for (const c of courses) {
await client.query(`
INSERT INTO cursos (id, nome, duracao, duracao_meses, taxa_matricula, mensalidade, descricao, multa_percentual, juros_percentual)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, duracao = EXCLUDED.duracao, duracao_meses = EXCLUDED.duracao_meses,
taxa_matricula = EXCLUDED.taxa_matricula, mensalidade = EXCLUDED.mensalidade,
descricao = EXCLUDED.descricao
`, [c.id, c.name, c.duration || '', c.durationMonths || 0, c.registrationFee || 0, c.monthlyFee || 0, c.description || '', c.finePercentage || 0, c.interestPercentage || 0]);
}
logCount('cursos', courses.length);
}
async function migrateTurmas(client: pg.PoolClient, classes: any[]) {
for (const c of classes) {
await client.query(`
INSERT INTO turmas (id, nome, curso_id, professor, horario, dia_semana, max_alunos, data_inicio, data_fim, horario_inicio_padrao, horario_fim_padrao)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, curso_id = EXCLUDED.curso_id, professor = EXCLUDED.professor,
horario = EXCLUDED.horario, dia_semana = EXCLUDED.dia_semana,
max_alunos = EXCLUDED.max_alunos
`, [
c.id, c.name, c.courseId || null, c.teacher || '', c.schedule || '',
c.scheduleDay || null, c.maxStudents || 30,
c.startDate || null, c.endDate || null,
c.defaultStartTime || null, c.defaultEndTime || null
]);
}
logCount('turmas', classes.length);
}
async function migrateAlunos(client: pg.PoolClient, students: any[]) {
for (const s of students) {
await client.query(`
INSERT INTO alunos (
id, nome, email, telefone, data_nascimento, cpf, rg, rg_data_emissao,
nome_responsavel, telefone_responsavel, cpf_responsavel, data_nascimento_responsavel,
turma_id, status, motivo_cancelamento, data_matricula, foto_url, face_descriptor,
cep, rua, numero, bairro, cidade, estado,
desconto, tem_responsavel, modelo_contrato_id,
numero_matricula, senha_portal
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10, $11, $12,
$13, $14, $15, $16, $17, $18,
$19, $20, $21, $22, $23, $24,
$25, $26, $27,
$28, $29
)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, email = EXCLUDED.email, telefone = EXCLUDED.telefone,
turma_id = EXCLUDED.turma_id, status = EXCLUDED.status,
numero_matricula = EXCLUDED.numero_matricula, senha_portal = EXCLUDED.senha_portal
`, [
s.id, s.name, s.email || '', s.phone || '',
s.birthDate || null, s.cpf || '', s.rg || null, s.rgIssueDate || null,
s.guardianName || null, s.guardianPhone || null, s.guardianCpf || null, s.guardianBirthDate || null,
s.classId || null, s.status || 'active', s.cancellationReason || null,
s.registrationDate || null,
// FOTO: Copia a URL (se já migrou para Storage) ou o base64 temporariamente
s.photo || null,
// FACE DESCRIPTOR: Array de números para reconhecimento facial
s.faceDescriptor ? JSON.stringify(s.faceDescriptor) : null,
s.addressZip || '', s.addressStreet || '', s.addressNumber || '',
s.addressNeighborhood || '', s.addressCity || '', s.addressState || '',
s.discount || 0, s.hasGuardian || false, s.contractTemplateId || null,
// CRÍTICO: Matrícula e Senha copiadas EXATAMENTE como estão
s.enrollmentNumber || null, s.portalPassword || null
]);
}
logCount('alunos', students.length);
}
async function migrateAulas(client: pg.PoolClient, lessons: any[]) {
for (const l of lessons) {
await client.query(`
INSERT INTO aulas (id, turma_id, data, horario_inicio, horario_fim, status, tipo, motivo_cancelamento, aula_original_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status, horario_inicio = EXCLUDED.horario_inicio,
horario_fim = EXCLUDED.horario_fim
`, [
l.id, l.classId, l.date, l.startTime || null, l.endTime || null,
l.status || 'scheduled', l.type || 'regular',
l.cancelReason || null, l.originalLessonId || null
]);
}
logCount('aulas', lessons.length);
}
async function migrateFrequencias(client: pg.PoolClient, attendance: any[]) {
for (const a of attendance) {
await client.query(`
INSERT INTO frequencias (id, aluno_id, turma_id, data, foto, verificado, tipo, justificativa, justificativa_aceita)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO UPDATE SET
tipo = EXCLUDED.tipo, justificativa = EXCLUDED.justificativa,
justificativa_aceita = EXCLUDED.justificativa_aceita
`, [
a.id, a.studentId, a.classId, a.date,
a.photo || null, a.verified || false,
a.type || 'presence', a.justification || null, a.justificationAccepted ?? null
]);
}
logCount('frequencias', attendance.length);
}
async function migrateDisciplinas(client: pg.PoolClient, subjects: any[]) {
for (const s of subjects) {
await client.query(`
INSERT INTO disciplinas (id, nome, turma_id)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome
`, [s.id, s.name, s.classId || null]);
}
logCount('disciplinas', subjects.length);
}
async function migratePeriodos(client: pg.PoolClient, periods: any[]) {
for (const p of periods) {
await client.query(`
INSERT INTO periodos (id, nome)
VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome
`, [p.id, p.name]);
}
logCount('periodos', periods.length);
}
async function migrateNotas(client: pg.PoolClient, grades: any[]) {
for (const g of grades) {
await client.query(`
INSERT INTO notas (id, aluno_id, disciplina_id, valor, periodo)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET valor = EXCLUDED.valor
`, [g.id, g.studentId, g.subjectId, g.value || 0, g.period]);
}
logCount('notas', grades.length);
}
async function migratePagamentos(client: pg.PoolClient, payments: any[]) {
for (const p of payments) {
await client.query(`
INSERT INTO pagamentos (
id, aluno_id, contrato_id, valor, desconto, tipo_desconto, multa, juros,
vencimento, status, data_pagamento, tipo, numero_parcela, total_parcelas,
descricao, asaas_payment_id, asaas_payment_url, installment_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, data_pagamento = EXCLUDED.data_pagamento
`, [
p.id, p.studentId, p.contractId || null, p.amount, p.discount || 0,
p.discountType || null, p.lateFee || 0, p.interest || 0,
p.dueDate, p.status || 'pending', p.paidDate || null,
p.type || 'monthly', p.installmentNumber || null, p.totalInstallments || null,
p.description || null, p.asaasPaymentId || null, p.asaasPaymentUrl || null,
p.installmentId || null
]);
}
logCount('pagamentos', payments.length);
}
async function migrateContratos(client: pg.PoolClient, contracts: any[]) {
for (const c of contracts) {
await client.query(`
INSERT INTO contratos (id, aluno_id, titulo, conteudo, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET titulo = EXCLUDED.titulo, conteudo = EXCLUDED.conteudo
`, [c.id, c.studentId, c.title, c.content, c.createdAt || new Date().toISOString()]);
}
logCount('contratos', contracts.length);
}
async function migrateModelosContrato(client: pg.PoolClient, templates: any[]) {
for (const t of templates) {
await client.query(`
INSERT INTO modelos_contrato (id, nome, conteudo)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, conteudo = EXCLUDED.conteudo
`, [t.id, t.name, t.content]);
}
logCount('modelos_contrato', templates.length);
}
async function migrateNotificacoes(client: pg.PoolClient, notifications: any[]) {
for (const n of notifications) {
await client.query(`
INSERT INTO notificacoes (id, aluno_id, titulo, mensagem, lida, anexo, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET lida = EXCLUDED.lida
`, [n.id, n.studentId, n.title, n.message, n.read || false, n.attachment || null, n.createdAt || new Date().toISOString()]);
}
logCount('notificacoes', notifications.length);
}
async function migrateCertificados(client: pg.PoolClient, certificates: any[]) {
for (const c of certificates) {
await client.query(`
INSERT INTO certificados (id, aluno_id, descricao, imagem_frente, imagem_verso, data_emissao, overlays_frente, overlays_verso)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO NOTHING
`, [
c.id, c.studentId, c.description || null, c.frontImage, c.backImage || null,
c.issueDate, JSON.stringify(c.frontOverlays || []), JSON.stringify(c.backOverlays || [])
]);
}
logCount('certificados', certificates.length);
}
async function migrateApostilas(client: pg.PoolClient, handouts: any[]) {
for (const h of handouts) {
await client.query(`
INSERT INTO apostilas (id, nome, preco, descricao, multa_percentual, juros_percentual)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, preco = EXCLUDED.preco
`, [h.id, h.name, h.price || 0, h.description || null, h.finePercentage || 0, h.interestPercentage || 0]);
}
logCount('apostilas', handouts.length);
}
async function migrateEntregasApostilas(client: pg.PoolClient, deliveries: any[]) {
for (const d of deliveries) {
await client.query(`
INSERT INTO entregas_apostilas (id, aluno_id, apostila_id, status_entrega, status_pagamento, data_entrega, data_pagamento, asaas_payment_id, asaas_payment_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO NOTHING
`, [
d.id, d.studentId, d.handoutId, d.deliveryStatus || 'pending',
d.paymentStatus || 'pending', d.deliveryDate || null, d.paymentDate || null,
d.asaasPaymentId || null, d.asaasPaymentUrl || null
]);
}
logCount('entregas_apostilas', deliveries.length);
}
async function migrateFuncionarios(client: pg.PoolClient, categories: any[], employees: any[]) {
for (const c of categories) {
await client.query(`
INSERT INTO categorias_funcionarios (id, nome)
VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome
`, [c.id, c.name]);
}
logCount('categorias_funcionarios', categories.length);
for (const e of employees) {
await client.query(`
INSERT INTO funcionarios (id, nome, cpf, telefone, email, data_admissao, categoria_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome
`, [e.id, e.name, e.cpf || '', e.phone || '', e.email || '', e.admissionDate || null, e.categoryId || null]);
}
logCount('funcionarios', employees.length);
}
async function migrateProvas(client: pg.PoolClient, exams: any[]) {
for (const e of exams) {
await client.query(`
INSERT INTO provas (id, turma_id, disciplina_id, periodo_id, titulo, duracao_minutos, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET titulo = EXCLUDED.titulo, status = EXCLUDED.status
`, [e.id, e.classId, e.subjectId || null, e.periodId || null, e.title, e.durationMinutes || 60, e.status || 'draft']);
// Migrar questões da prova
const questions = e.questions || [];
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
await client.query(`
INSERT INTO questoes_provas (id, prova_id, texto, imagem_url, opcoes, indice_correto, ordem)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET texto = EXCLUDED.texto, opcoes = EXCLUDED.opcoes
`, [q.id, e.id, q.text, q.imageUrl || null, JSON.stringify(q.options || []), q.correctOptionIndex || 0, i]);
}
}
logCount('provas + questoes', exams.length);
}
// ============================================================
// MIGRAR TABELAS SEPARADAS DO SUPABASE
// ============================================================
async function migrateCobrancasAsaas(client: pg.PoolClient) {
log('🔄', 'Buscando tabela alunos_cobrancas do Supabase...');
const { data, error } = await supabase
.from('alunos_cobrancas')
.select('*');
if (error) {
log('⚠️', `Erro ao buscar alunos_cobrancas: ${error.message}. Pulando...`);
return;
}
const cobrancas = data || [];
for (const c of cobrancas) {
await client.query(`
INSERT INTO alunos_cobrancas (id, aluno_id, asaas_customer_id, asaas_payment_id, asaas_installment_id, installment, valor, vencimento, status, data_pagamento, link_boleto, link_carne, transaction_receipt_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (id) DO NOTHING
`, [
c.id, c.aluno_id, c.asaas_customer_id || null, c.asaas_payment_id || null,
c.asaas_installment_id || null, c.installment || null,
c.valor, c.vencimento, c.status || 'PENDENTE', c.data_pagamento || null,
c.link_boleto || null, c.link_carne || null, c.transaction_receipt_url || null
]);
}
logCount('alunos_cobrancas', cobrancas.length);
}
async function migrateSubmissoesProvas(client: pg.PoolClient) {
log('🔄', 'Buscando tabela provas_submissoes do Supabase...');
const { data, error } = await supabase
.from('provas_submissoes')
.select('*');
if (error) {
log('⚠️', `Erro ao buscar provas_submissoes: ${error.message}. Pulando...`);
return;
}
const subs = data || [];
for (const s of subs) {
await client.query(`
INSERT INTO provas_submissoes (id, aluno_id, prova_id, total_questoes, acertos, erros, percentual, nota_final, respostas, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (id) DO NOTHING
`, [
s.id || `sub-${Date.now()}`, s.aluno_id, s.exam_id,
s.total_questions || 0, s.correct_count || 0, s.wrong_count || 0,
s.percentage || 0, s.final_score || 0,
JSON.stringify(s.answers_json || {}), s.created_at || new Date().toISOString()
]);
}
logCount('provas_submissoes', subs.length);
}
// ============================================================
// BACKUP DO JSON COMPLETO (Segurança)
// ============================================================
async function saveJsonBackup(schoolData: any) {
const fileName = `backup_supabase_${new Date().toISOString().split('T')[0]}.json`;
fs.writeFileSync(fileName, JSON.stringify(schoolData, null, 2), 'utf8');
log('💾', `Backup completo salvo em: ${fileName}`);
}
// ============================================================
// FUNÇÃO PRINCIPAL
// ============================================================
async function main() {
console.log('');
console.log('╔══════════════════════════════════════════════════════════╗');
console.log('║ MIGRAÇÃO EDUMANAGER: SUPABASE → POSTGRESQL LOCAL ║');
console.log('╚══════════════════════════════════════════════════════════╝');
console.log('');
// 1. Buscar o JSON blob do Supabase
log('🌐', 'Conectando ao Supabase Cloud...');
const { data: schoolRow, error: fetchError } = await supabase
.from('school_data')
.select('data')
.eq('id', 1)
.single();
if (fetchError || !schoolRow?.data) {
log('❌', `FALHA AO CONECTAR AO SUPABASE: ${fetchError?.message || 'Dados não encontrados'}`);
process.exit(1);
}
const schoolData = schoolRow.data;
log('✅', 'Dados baixados do Supabase com sucesso!');
// 2. Salvar backup local primeiro (segurança)
await saveJsonBackup(schoolData);
// 3. Conectar ao PostgreSQL local
log('🔌', 'Conectando ao PostgreSQL local...');
const client = await pool.connect();
try {
// TRANSAÇÃO ATÔMICA: Tudo ou nada
await client.query('BEGIN');
log('🔒', 'Transação iniciada (modo atômico)');
// 3.5 Criar tabelas se não existirem
log('🏗️', 'Rodando schema.sql para garantir que as tabelas existem...');
const schemaSql = fs.readFileSync('../schema.sql', 'utf8');
await client.query(schemaSql);
log('✅', 'Tabelas verificadas/criadas no Postgres local!');
// 4. Também salvar o JSON completo na tabela legada para ponte
log('📋', 'Salvando JSON blob na tabela school_data (ponte)...');
await client.query(`
INSERT INTO school_data (id, data, updated_at)
VALUES (1, $1, NOW())
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data, updated_at = NOW()
`, [JSON.stringify(schoolData)]);
// 5. Migrar entidade por entidade
console.log('');
log('🚀', '═══ INICIANDO MIGRAÇÃO TABELA POR TABELA ═══');
console.log('');
await migrateConfiguracoes(client, schoolData);
await migrateUsuarios(client, schoolData.users || []);
await migrateCursos(client, schoolData.courses || []);
await migrateTurmas(client, schoolData.classes || []);
await migrateAlunos(client, schoolData.students || []);
await migrateAulas(client, schoolData.lessons || []);
await migrateFrequencias(client, schoolData.attendance || []);
await migrateDisciplinas(client, schoolData.subjects || []);
await migratePeriodos(client, schoolData.periods || []);
await migrateNotas(client, schoolData.grades || []);
await migratePagamentos(client, schoolData.payments || []);
await migrateContratos(client, schoolData.contracts || []);
await migrateModelosContrato(client, schoolData.contractTemplates || []);
await migrateNotificacoes(client, schoolData.notifications || []);
await migrateCertificados(client, schoolData.certificates || []);
await migrateApostilas(client, schoolData.handouts || []);
await migrateEntregasApostilas(client, schoolData.handoutDeliveries || []);
await migrateFuncionarios(client, schoolData.employeeCategories || [], schoolData.employees || []);
await migrateProvas(client, schoolData.exams || []);
// 6. Migrar tabelas separadas do Supabase
console.log('');
log('🔄', '═══ MIGRANDO TABELAS SEPARADAS ═══');
console.log('');
await migrateCobrancasAsaas(client);
await migrateSubmissoesProvas(client);
// 7. Commit da transação
await client.query('COMMIT');
console.log('');
console.log('╔══════════════════════════════════════════════════════════╗');
console.log('║ ✅ MIGRAÇÃO CONCLUÍDA COM SUCESSO! ║');
console.log('║ ║');
console.log('║ • Todos os dados foram copiados com integridade ║');
console.log('║ • Senhas mantidas EXATAMENTE como estavam ║');
console.log('║ • Backup JSON salvo localmente ║');
console.log('║ • Tabela school_data (legada) populada como ponte ║');
console.log('╚══════════════════════════════════════════════════════════╝');
console.log('');
} catch (error: any) {
// ROLLBACK: Se qualquer coisa falhar, NADA é salvo
await client.query('ROLLBACK');
console.log('');
log('❌', '══════════════════════════════════════════════════');
log('❌', `ERRO NA MIGRAÇÃO: ${error.message}`);
log('❌', 'ROLLBACK executado. Nenhum dado foi alterado no PostgreSQL.');
log('❌', '══════════════════════════════════════════════════');
console.log('');
console.error(error);
} finally {
client.release();
await pool.end();
}
}
// Execução
main().catch(console.error);

7431
manager/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
manager/package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "edumanager---sistema-de-gestão-escolar",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "NODE_ENV=development tsx server.selfhosted.js",
"build": "vite build",
"start": "node server.selfhosted.js",
"preview": "vite preview",
"lint": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@google/genai": "^1.40.0",
"@supabase/supabase-js": "^2.39.0",
"@vladmandic/face-api": "^1.7.15",
"browser-image-compression": "^2.0.2",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"lucide-react": "^0.563.0",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
"pg": "^8.20.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"recharts": "^3.7.0",
"sharp": "^0.34.5",
"tsx": "^4.21.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.4.1"
}
}

View File

@ -0,0 +1,23 @@
const { Pool } = require('pg');
const pool = new Pool({
host: '150.230.87.131',
port: 5432,
database: 'edumanager',
user: 'edumanager',
password: 'EduManager2026!Seguro',
ssl: false
});
async function runAlter() {
try {
console.log("Adicionando a coluna 'max_score' na tabela 'provas'...");
await pool.query("ALTER TABLE provas ADD COLUMN IF NOT EXISTS max_score NUMERIC DEFAULT 10.0");
console.log("Coluna 'max_score' adicionada com sucesso!");
} catch(e) {
console.error("Erro ao alterar tabela:", e.message);
} finally {
await pool.end();
}
}
runAlter();

View File

@ -0,0 +1,16 @@
const { Pool } = require('pg');
const pool = new Pool({ connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager' });
async function run() {
try {
console.log('Adicionando coluna "sexo" na tabela "alunos"...');
await pool.query('ALTER TABLE alunos ADD COLUMN IF NOT EXISTS sexo text;');
console.log('Coluna "sexo" adicionada ou já existente com sucesso!');
} catch (err) {
console.error('Erro ao adicionar coluna:', err);
} finally {
await pool.end();
}
}
run();

View File

@ -0,0 +1,23 @@
const { Pool } = require('pg');
const pool = new Pool({
host: '150.230.87.131',
port: 5432,
database: 'edumanager',
user: 'edumanager',
password: 'EduManager2026!Seguro',
ssl: false
});
async function runAlter() {
try {
await pool.query("ALTER TABLE provas ADD COLUMN is_deleted BOOLEAN DEFAULT false");
await pool.query("ALTER TABLE provas ADD COLUMN evaluation_type TEXT DEFAULT 'exam'");
console.log("Colunas adicionadas!");
} catch(e) {
console.log(e.message);
} finally {
await pool.end();
}
}
runAlter();

View File

@ -0,0 +1,14 @@
const { pool } = require('./../services/database.js');
async function run() {
try {
await pool.query("ALTER TABLE provas ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN DEFAULT FALSE");
await pool.query("ALTER TABLE provas ADD COLUMN IF NOT EXISTS evaluation_type VARCHAR(50) DEFAULT 'exam'");
console.log("Colunas is_deleted e evaluation_type adicionadas na tabela provas.");
} catch(e) {
console.error(e);
} finally {
process.exit(0);
}
}
run();

View File

@ -0,0 +1,14 @@
import { pool } from '../services/database.js';
async function run() {
try {
await pool.query("ALTER TABLE provas ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN DEFAULT FALSE");
await pool.query("ALTER TABLE provas ADD COLUMN IF NOT EXISTS evaluation_type VARCHAR(50) DEFAULT 'exam'");
console.log("Colunas is_deleted e evaluation_type adicionadas na tabela provas.");
} catch(e) {
console.error(e);
} finally {
process.exit(0);
}
}
run();

View File

@ -0,0 +1,13 @@
import pg from 'pg';
const DATABASE_URL = 'postgresql://edumanager:EduManager2026!Seguro@127.0.0.1:5432/edumanager';
const pool = new pg.Pool({ connectionString: DATABASE_URL });
async function check() {
try {
const { rows } = await pool.query(\"SELECT (data->'grades') as grades FROM school_data WHERE id = 1\");
console.log('GRADES_JSON_COUNT:' + (rows[0]?.grades?.length || 0));
const { rows: n } = await pool.query(\"SELECT count(*) FROM notas_boletim\");
console.log('NOTAS_TABLE_COUNT:' + n[0].count);
} catch (err) { console.log('ERROR:' + err.message); }
finally { await pool.end(); }
}
check();

View File

@ -0,0 +1,30 @@
import pg from 'pg';
const pool = new pg.Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager'
});
async function run() {
try {
console.log('Buscando presenças com fotos na tabela frequencias...');
const { rows } = await pool.query(
`SELECT f.aluno_id, a.nome, count(*) as total, array_agg(f.data ORDER BY f.data ASC) as datas
FROM frequencias f
JOIN alunos a ON f.aluno_id = a.id
WHERE f.foto IS NOT NULL AND f.foto <> ''
GROUP BY f.aluno_id, a.nome`
);
console.log(`Encontrados ${rows.length} alunos com fotos de presenças.`);
rows.forEach(r => {
console.log(`Aluno: ${r.nome} (ID: ${r.aluno_id}) | Total Presenças com Foto: ${r.total} | Datas: ${r.datas.join(', ')}`);
});
} catch (err) {
console.error(err);
} finally {
await pool.end();
}
}
run();

View File

@ -0,0 +1,29 @@
import pkg from 'pg';
const { Pool } = pkg;
const DATABASE_URL = 'postgresql://edumanager:EduManager2026!Seguro@localhost:5432/edumanager';
const pool = new Pool({
connectionString: DATABASE_URL
});
async function test() {
try {
console.log('--- TESTANDO INTEGRIDADE ---');
const { rows: countAlunos } = await pool.query('SELECT COUNT(*) FROM alunos');
console.log('Total em alunos:', countAlunos[0].count);
const { rows: countProvas } = await pool.query('SELECT COUNT(*) FROM provas');
console.log('Total em provas:', countProvas[0].count);
const { rows: countSub } = await pool.query('SELECT COUNT(*) FROM provas_submissoes');
console.log('Total em provas_submissoes:', countSub[0].count);
} catch (err) {
console.error('ERRO NO TESTE:', err.message);
} finally {
await pool.end();
}
}
test();

View File

@ -0,0 +1,6 @@
import { pool } from '../services/database.js';
pool.query("SELECT data FROM app_state WHERE id = 'school_data'").then(r => {
const p = r.rows[0].data.payments.find(p => p.asaasPaymentId === 'pay_iipssljwa9df3fsq');
console.log('JSON AMOUNT:', p.amount, 'JSON DISCOUNT:', p.discount);
process.exit(0);
}).catch(e => console.error(e));

View File

@ -0,0 +1,33 @@
import pg from 'pg';
const pool = new pg.Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager'
});
async function run() {
try {
const { rows } = await pool.query('SELECT data FROM school_data WHERE id = 1');
const data = rows[0]?.data;
if (!data) {
console.log('No data found.');
return;
}
console.log('--- CHAVES NO JSONB ---');
for (const key of Object.keys(data)) {
if (Array.isArray(data[key])) {
console.log(`- ${key}: Array com ${data[key].length} itens`);
} else if (typeof data[key] === 'object' && data[key] !== null) {
console.log(`- ${key}: Objeto com chaves [${Object.keys(data[key]).join(', ')}]`);
} else {
console.log(`- ${key}: ${typeof data[key]}`);
}
}
} catch (err) {
console.error(err);
} finally {
await pool.end();
}
}
run();

View File

@ -0,0 +1,37 @@
import pg from 'pg';
const DATABASE_URL = 'postgresql://edumanager:EduManager2026!Seguro@localhost:5432/edumanager';
const pool = new pg.Pool({ connectionString: DATABASE_URL });
async function checkExams() {
try {
const { rows } = await pool.query('SELECT data FROM school_data WHERE id = 1');
const data = rows[0]?.data || {};
const exams = data.exams || [];
const pastaExam = exams.find(e => e.title && e.title.includes('Pastas'));
if (pastaExam) {
console.log('--- Atividade Encontrada ---');
console.log('Título:', pastaExam.title);
console.log('ID:', pastaExam.id);
console.log('subjectId:', pastaExam.subjectId);
console.log('periodId:', pastaExam.periodId);
console.log('Status:', pastaExam.status);
if (!pastaExam.subjectId || !pastaExam.periodId) {
console.log('❌ ALERTA: Esta atividade está DESCONECTADA (sem subjectId ou periodId).');
} else {
console.log('✅ OK: Esta atividade está vinculada corretamente.');
}
} else {
console.log('❌ Atividade "Pastas" não encontrada no school_data.');
console.log('Títulos disponíveis:', exams.map(e => e.title));
}
} catch (err) {
console.error('Erro na verificação:', err.message);
} finally {
await pool.end();
}
}
checkExams();

View File

@ -0,0 +1,27 @@
const db = {
amount_original: '150.00',
valor: '150.00',
discount: '20.00'
};
const jsonP = {
amount: 150, // Assuming webhook corrupted it
discount: 20
};
let amountOriginal = Number(db.amount_original) || Number(jsonP.amount) || Number(db.valor) || 0;
const discount = Number(db.discount) || (jsonP.amount ? Number(jsonP.discount || 0) : 0);
console.log('amountOriginal before:', amountOriginal);
console.log('discount:', discount);
console.log('db.valor:', Number(db.valor));
if (amountOriginal === Number(db.valor) && discount > 0) {
amountOriginal += discount;
console.log('BUGFIX APPLIED! New amountOriginal:', amountOriginal);
} else {
console.log('BUGFIX NOT APPLIED. Conditions:', {
isEqual: amountOriginal === Number(db.valor),
hasDiscount: discount > 0
});
}

View File

@ -0,0 +1,52 @@
const { createClient } = require('@supabase/supabase-js');
const supabaseUrl = 'https://ekbuvcjsfcczviqqlfit.supabase.co';
const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVrYnV2Y2pzZmNjenZpcXFsZml0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA5OTU0MzIsImV4cCI6MjA4NjU3MTQzMn0.oIzBeGF-PjaviZejYb1TeOOEzMm-Jjth1XzvJrjD6us';
const supabase = createClient(supabaseUrl, supabaseKey);
async function checkDatabase() {
const results = {
tables: {},
storage: {},
errors: []
};
// 1. Check school_data table
try {
const { data: schoolData, error: schoolError } = await supabase.from('school_data').select('*').limit(1);
results.tables.school_data = { exists: !schoolError, error: schoolError?.message };
if (schoolData?.[0]?.data) {
results.tables.school_data.hasData = true;
results.tables.school_data.hasExams = Array.isArray(schoolData[0].data.exams);
}
} catch (e) { results.errors.push('school_data check failed: ' + e.message); }
// 2. Check provas_submissoes table
try {
const { data: subData, error: subError } = await supabase.from('provas_submissoes').select('*').limit(1);
results.tables.provas_submissoes = { exists: !subError, error: subError?.message };
} catch (e) { results.errors.push('provas_submissoes check failed: ' + e.message); }
// 3. Check alunos_cobrancas table
try {
const { data: cobData, error: cobError } = await supabase.from('alunos_cobrancas').select('*').limit(1);
results.tables.alunos_cobrancas = { exists: !cobError, error: cobError?.message };
} catch (e) { results.errors.push('alunos_cobrancas check failed: ' + e.message); }
// 4. Check edumanager-assets storage
try {
const { data: buckets, error: bucketError } = await supabase.storage.listBuckets();
if (bucketError) {
results.storage.error = bucketError.message;
} else {
const bucket = buckets.find(b => b.id === 'edumanager-assets');
results.storage.edumanagerAssets = { exists: !!bucket, public: bucket?.public };
}
} catch (e) { results.errors.push('storage check failed: ' + e.message); }
console.log(JSON.stringify(results, null, 2));
}
checkDatabase();

View File

@ -0,0 +1,42 @@
import pg from 'pg';
import dotenv from 'dotenv';
dotenv.config();
const pool = new pg.Pool({
user: process.env.POSTGRES_USER || 'edumanager',
host: process.env.POSTGRES_HOST || '150.230.87.131',
database: process.env.POSTGRES_DB || 'edumanager',
password: process.env.POSTGRES_PASSWORD,
port: process.env.POSTGRES_PORT || 5432,
});
async function run() {
try {
const res = await pool.query(`
WITH RankedRecords AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY aluno_id, COALESCE(aula_id, data::date::text)
ORDER BY
CASE
WHEN tipo = 'absence' AND justificativa IS NOT NULL THEN 1
WHEN tipo = 'presence' THEN 2
WHEN tipo = 'absence' AND justificativa IS NULL THEN 3
ELSE 4
END ASC,
created_at DESC
) as rn
FROM frequencias
)
DELETE FROM frequencias WHERE id IN (SELECT id FROM RankedRecords WHERE rn > 1);
`);
console.log('Duplicatas deletadas:', res.rowCount);
} catch (err) {
console.error('Erro:', err);
} finally {
pool.end();
}
}
run();

View File

@ -0,0 +1,21 @@
import pg from 'pg';
const DATABASE_URL = 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager';
const pool = new pg.Pool({ connectionString: DATABASE_URL });
async function cleanup() {
try {
const { rowCount } = await pool.query(
`DELETE FROM notas_boletim
WHERE prova_id IS NOT NULL AND prova_id NOT IN (SELECT id FROM provas)`
);
console.log(`[Sucesso] Limpeza concluída: ${rowCount} registros órfãos de notas removidos!`);
} catch (error) {
console.error('[Erro] Falha ao limpar banco de dados:', error);
} finally {
await pool.end();
process.exit();
}
}
cleanup();

View File

@ -0,0 +1,31 @@
import pg from 'pg';
const pool = new pg.Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager'
});
async function run() {
try {
const { rows: schoolDataRows } = await pool.query("SELECT data->'students' as students FROM school_data WHERE id = 1");
const jsonStudents = schoolDataRows[0]?.students || [];
const { rows: sqlStudents } = await pool.query("SELECT id, nome, foto_url, face_descriptor, data_nascimento, cpf FROM alunos");
console.log('--- ALUNOS NO JSONB (school_data) ---');
jsonStudents.forEach(s => {
console.log(`ID: ${s.id} | Nome: ${s.name} | Photo: ${s.photo} | BirthDate: ${s.birthDate} | CPF: ${s.cpf} | HasFaceDescriptor: ${!!s.faceDescriptor}`);
});
console.log('\n--- ALUNOS NA TABELA SQL (alunos) ---');
sqlStudents.forEach(s => {
console.log(`ID: ${s.id} | Nome: ${s.nome} | FotoUrl: ${s.foto_url} | BirthDate: ${s.data_nascimento} | CPF: ${s.cpf} | HasFaceDescriptor: ${!!s.face_descriptor}`);
});
} catch (err) {
console.error(err);
} finally {
await pool.end();
}
}
run();

View File

@ -0,0 +1,97 @@
/**
* Script de Migração: Criação das tabelas de Pré-Matrícula
* Execução: node scratch/create_prematricula_tables.cjs
*/
const { Pool } = require('pg');
const pool = new Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager'
});
async function migrate() {
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. Tabela de configuração do formulário de pré-matrícula
await client.query(`
CREATE TABLE IF NOT EXISTS prematricula_config (
id SERIAL PRIMARY KEY,
titulo TEXT NOT NULL DEFAULT 'Pré-Matrícula Online',
descricao TEXT DEFAULT '',
slug TEXT NOT NULL DEFAULT 'pre-matricula',
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
cor_primaria TEXT DEFAULT '#4f46e5',
logo_url TEXT DEFAULT '',
mensagem_sucesso TEXT DEFAULT 'Sua pré-matrícula foi enviada com sucesso! Entraremos em contato em breve.',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`);
// Inserir registro default se não existir
await client.query(`
INSERT INTO prematricula_config (id, titulo, descricao, slug, status)
VALUES (1, 'Pré-Matrícula Online', 'Preencha o formulário abaixo para realizar sua pré-matrícula.', 'pre-matricula', 'draft')
ON CONFLICT (id) DO NOTHING;
`);
// 2. Tabela de campos personalizáveis do formulário
await client.query(`
CREATE TABLE IF NOT EXISTS prematricula_campos (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
tipo TEXT NOT NULL DEFAULT 'text' CHECK (tipo IN ('text', 'email', 'phone', 'cpf', 'date', 'select', 'textarea', 'number')),
placeholder TEXT DEFAULT '',
obrigatorio BOOLEAN DEFAULT false,
opcoes TEXT[] DEFAULT '{}',
ordem INTEGER NOT NULL DEFAULT 0,
ativo BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`);
// 3. Tabela de inscrições de pré-matrícula
await client.query(`
CREATE TABLE IF NOT EXISTS prematriculas (
id TEXT PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT DEFAULT '',
telefone TEXT DEFAULT '',
turma_id TEXT,
turma_nome TEXT DEFAULT '',
respostas JSONB DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pendente' CHECK (status IN ('pendente', 'aprovado', 'rejeitado', 'matriculado')),
observacoes TEXT DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`);
// Índices
await client.query(`
CREATE INDEX IF NOT EXISTS idx_prematriculas_turma ON prematriculas(turma_id);
CREATE INDEX IF NOT EXISTS idx_prematriculas_status ON prematriculas(status);
CREATE INDEX IF NOT EXISTS idx_prematriculas_created ON prematriculas(created_at DESC);
`);
await client.query('COMMIT');
console.log('✅ Tabelas de pré-matrícula criadas com sucesso!');
// Verificar
const { rows } = await client.query(`
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_name LIKE 'prematricula%'
ORDER BY table_name;
`);
console.log('📊 Tabelas criadas:', rows.map(r => r.table_name));
} catch (error) {
await client.query('ROLLBACK');
console.error('❌ Erro na migração:', error.message);
} finally {
client.release();
await pool.end();
}
}
migrate();

View File

@ -0,0 +1,35 @@
import pkg from 'pg';
const { Pool } = pkg;
const DATABASE_URL = 'postgresql://edumanager:EduManager2026!Seguro@postgres:5432/edumanager';
const pool = new Pool({
connectionString: DATABASE_URL
});
async function test() {
try {
console.log('--- TESTANDO CONEXÃO ---');
const { rows: tables } = await pool.query("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'");
console.log('Tabelas encontradas:', tables.map(t => t.table_name).join(', '));
console.log('\n--- CONTAGEM DE REGISTROS ---');
const { rows: countSub } = await pool.query('SELECT COUNT(*) FROM provas_submissoes');
console.log('Total em provas_submissoes:', countSub[0].count);
const { rows: countNot } = await pool.query('SELECT COUNT(*) FROM notas_boletim');
console.log('Total em notas_boletim:', countNot[0].count);
if (countSub[0].count > 0) {
const { rows: samples } = await pool.query('SELECT * FROM provas_submissoes LIMIT 1');
console.log('\nExemplo de submissão:', samples[0]);
}
} catch (err) {
console.error('ERRO NO TESTE:', err.message);
} finally {
await pool.end();
}
}
test();

View File

@ -0,0 +1,20 @@
import pg from 'pg';
const DATABASE_URL = 'postgresql://edumanager:EduManager2026!Seguro@localhost:5432/edumanager';
const pool = new pg.Pool({ connectionString: DATABASE_URL });
async function check() {
try {
const { rows: notas } = await pool.query('SELECT count(*) FROM notas_boletim');
console.log('Notas count in table:', notas[0].count);
const { rows: sd } = await pool.query('SELECT data FROM school_data WHERE id = 1');
const data = sd[0]?.data || {};
console.log('Grades count in JSON:', data.grades?.length || 0);
} catch (err) {
console.error('Error during check:', err.message);
} finally {
await pool.end();
}
}
check();

View File

@ -0,0 +1,29 @@
import pg from 'pg';
const DATABASE_URL = 'postgresql://edumanager:EduManager2026!Seguro@127.0.0.1:5432/edumanager';
const pool = new pg.Pool({ connectionString: DATABASE_URL });
async function debug() {
try {
const { rows: schoolRows } = await pool.query('SELECT data FROM school_data WHERE id = 1');
const data = schoolRows[0]?.data || {};
const sidney = (data.students || []).find(s => s.name && s.name.includes('Sidney'));
if (!sidney) {
console.log('Sidney não encontrado no JSON.');
} else {
console.log('ID do Sidney:', sidney.id);
const { rows: notas } = await pool.query('SELECT * FROM notas_boletim WHERE aluno_id = $1', [sidney.id]);
console.log('Notas na tabela SQL:', notas.length);
console.log(JSON.stringify(notas, null, 2));
}
const { rows: allNotas } = await pool.query('SELECT count(*) FROM notas_boletim');
console.log('Total de notas na tabela SQL:', allNotas[0].count);
} catch (err) {
console.error('ERRO:', err.message);
} finally {
await pool.end();
}
}
debug();

View File

@ -0,0 +1,29 @@
const { Pool } = require('pg');
const pool = new Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager'
});
async function run() {
const client = await pool.connect();
try {
const res = await client.query(`
DELETE FROM frequencias a
WHERE a.tipo = 'absence' AND a.id LIKE 'auto-abs-%'
AND EXISTS (
SELECT 1 FROM frequencias p
WHERE p.tipo = 'presence'
AND p.aluno_id = a.aluno_id
AND (p.aula_id = a.aula_id OR DATE(p.data AT TIME ZONE 'UTC') = DATE(a.data AT TIME ZONE 'UTC'))
) RETURNING id;
`);
console.log(`Deleted ${res.rowCount} orphaned absences.`);
} catch (err) {
console.error(err);
} finally {
client.release();
pool.end();
}
}
run();

View File

@ -0,0 +1,29 @@
const { Pool } = require('pg');
const pool = new Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@postgres:5432/edumanager'
});
async function run() {
const client = await pool.connect();
try {
const res = await client.query(`
DELETE FROM frequencias a
WHERE a.tipo = 'absence' AND a.id LIKE 'auto-abs-%'
AND EXISTS (
SELECT 1 FROM frequencias p
WHERE p.tipo = 'presence'
AND p.aluno_id = a.aluno_id
AND (p.aula_id = a.aula_id OR DATE(p.data AT TIME ZONE 'UTC') = DATE(a.data AT TIME ZONE 'UTC'))
) RETURNING id;
`);
console.log(`Deleted ${res.rowCount} orphaned absences.`);
} catch (err) {
console.error(err);
} finally {
client.release();
pool.end();
}
}
run();

View File

@ -0,0 +1,27 @@
const { Pool } = require('pg');
const pool = new Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager'
});
async function run() {
const client = await pool.connect();
try {
const res = await client.query(`
DELETE FROM frequencias a
WHERE a.tipo = 'absence' AND a.id LIKE 'auto-abs-%'
AND NOT EXISTS (
SELECT 1 FROM school_data sd
WHERE jsonb_path_exists(sd.data, ('$.attendance[*] ? (@.id == "' || a.id || '")')::jsonpath)
) RETURNING id;
`);
console.log(`Deleted ${res.rowCount} absences that are in SQL but NOT in JSON.`);
} catch (err) {
console.error(err);
} finally {
client.release();
pool.end();
}
}
run();

View File

@ -0,0 +1,53 @@
import pkg from 'pg';
const { Pool } = pkg;
const DATABASE_URL = 'postgresql://edumanager:EduManager2026!Seguro@localhost:5432/edumanager';
const pool = new Pool({ connectionString: DATABASE_URL });
async function runDiag() {
try {
const { rows } = await pool.query('SELECT data FROM school_data WHERE id = 1');
const data = rows[0]?.data || {};
console.log('--- DIAGNÓSTICO DE SINCRONIZAÇÃO ---');
// 1. Verificar Alunos
const jsonStudents = data.students || [];
const { rows: dbStudents } = await pool.query('SELECT id, nome FROM alunos');
console.log(`JSON: ${jsonStudents.length} alunos | DB: ${dbStudents.length} alunos`);
if (jsonStudents.length > 0) {
const sample = jsonStudents[0];
const inDb = dbStudents.find(s => s.id === sample.id);
console.log(`Exemplo Aluno [${sample.name}]: ${inDb ? '✅ No DB' : '❌ NÃO ESTÁ NO DB'}`);
// Verificar campos específicos do primeiro aluno
if (inDb) {
const { rows: fullStudent } = await pool.query('SELECT * FROM alunos WHERE id = $1', [sample.id]);
const dbS = fullStudent[0];
console.log('--- Comparação de Campos (Exemplo) ---');
console.log(`CPF: JSON[${sample.cpf || '?'}] vs DB[${dbS.cpf || '?'}]`);
console.log(`Telefone: JSON[${sample.phone || '?'}] vs DB[${dbS.telefone || '?'}]`);
console.log(`Status: JSON[${sample.status || '?'}] vs DB[${dbS.status || '?'}]`);
}
}
// 2. Verificar Turmas
const jsonClasses = data.classes || [];
const { rows: dbClasses } = await pool.query('SELECT COUNT(*) FROM turmas');
console.log(`JSON: ${jsonClasses.length} turmas | DB: ${dbClasses[0].count} turmas`);
// 3. Verificar Provas
const jsonExams = data.exams || [];
const { rows: dbExams } = await pool.query('SELECT COUNT(*) FROM provas');
console.log(`JSON: ${jsonExams.length} provas | DB: ${dbExams[0].count} provas`);
} catch (err) {
console.error('Erro no diagnóstico:', err.message);
} finally {
await pool.end();
}
}
runDiag();

View File

@ -0,0 +1,61 @@
import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const s3 = new S3Client({
endpoint: 'https://storageedu.microtecinformaticacurso.com.br:443',
region: 'us-east-1',
credentials: {
accessKeyId: 'minioadmin',
secretAccessKey: 'MiniO2026!Seguro'
},
forcePathStyle: true,
tls: false // Disable SSL verification for self-signed certs
});
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
async function run() {
const destDir = path.join(__dirname, 'photos');
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
try {
console.log('Listando objetos no bucket fotos-alunos...');
const listCommand = new ListObjectsV2Command({
Bucket: 'fotos-alunos'
});
const listResponse = await s3.send(listCommand);
const objects = listResponse.Contents || [];
console.log(`Encontrados ${objects.length} objetos.`);
for (const obj of objects) {
if (!obj.Key) continue;
console.log(`Baixando ${obj.Key}...`);
const getCommand = new GetObjectCommand({
Bucket: 'fotos-alunos',
Key: obj.Key
});
const getResponse = await s3.send(getCommand);
const stream = getResponse.Body;
if (stream) {
const destPath = path.join(destDir, obj.Key);
const fileStream = fs.createWriteStream(destPath);
// Convert web stream / readable stream to local file
const buffer = await stream.transformToByteArray();
fs.writeFileSync(destPath, buffer);
console.log(`Salvo em ${destPath}`);
}
}
console.log('Download concluído!');
} catch (err) {
console.error('Erro:', err);
}
}
run();

View File

@ -0,0 +1,8 @@
import fs from 'fs';
const content = fs.readFileSync('manager/components/Students.tsx', 'utf8');
const lines = content.split('\n');
lines.forEach((l, i) => {
if (l.includes('Alunos')) {
console.log(`[${i+1}] ${l.trim()}`);
}
});

View File

@ -0,0 +1,8 @@
import fs from 'fs';
const content = fs.readFileSync('manager/components/ReportCard.tsx', 'utf8');
const lines = content.split('\n');
lines.forEach((l, i) => {
if (l.toLowerCase().includes('exam') || l.toLowerCase().includes('prova')) {
console.log(`[${i+1}] ${l.trim()}`);
}
});

View File

@ -0,0 +1,8 @@
import fs from 'fs';
const content = fs.readFileSync('manager/services/database.js', 'utf8');
const lines = content.split('\n');
lines.forEach((l, i) => {
if (l.toLowerCase().includes('getalunos')) {
console.log(`[${i+1}] ${l.trim()}`);
}
});

View File

@ -0,0 +1,97 @@
const pg = require('pg');
const { S3Client, ListBucketsCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3');
const pool = new pg.Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager'
});
const s3Client = new S3Client({
endpoint: 'http://150.230.87.131:9000',
region: 'us-east-1',
credentials: {
accessKeyId: 'minioadmin',
secretAccessKey: 'MiniO2026!Seguro',
},
forcePathStyle: true,
});
async function main() {
try {
console.log('--- 1. BUSCANDO TODAS AS TABELAS E DADOS DO POSTGRESQL ---');
const { rows: tables } = await pool.query("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'");
console.log(`Encontradas ${tables.length} tabelas no banco de dados.`);
let dbContentDump = '';
for (const t of tables) {
const tableName = t.table_name;
try {
const { rows } = await pool.query(`SELECT * FROM "${tableName}"`);
dbContentDump += JSON.stringify(rows) + ' ';
} catch (err) {
console.warn(`Aviso: não foi possível ler a tabela ${tableName}:`, err.message);
}
}
console.log(`Dumping de referências concluído (${(dbContentDump.length / 1024 / 1024).toFixed(2)} MB).`);
console.log('\n--- 2. LISTANDO ARQUIVOS NO STORAGE MINIO ---');
const bucketsData = await s3Client.send(new ListBucketsCommand({}));
const buckets = bucketsData.Buckets || [];
console.log(`Encontrados ${buckets.length} buckets no MinIO.`);
const allFiles = [];
let totalStorageSize = 0;
for (const bucket of buckets) {
try {
const objects = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket.Name }));
const contents = objects.Contents || [];
console.log(`Bucket [${bucket.Name}]: ${contents.length} arquivos encontrados.`);
for (const obj of contents) {
totalStorageSize += obj.Size || 0;
allFiles.push({
bucket: bucket.Name,
key: obj.Key,
size: obj.Size || 0,
url: `/storage/${bucket.Name}/${obj.Key}`
});
}
} catch (err) {
console.warn(`Não foi possível listar objetos do bucket ${bucket.Name}:`, err.message);
}
}
console.log(`Total geral no MinIO: ${(totalStorageSize / 1024 / 1024).toFixed(2)} MB (${allFiles.length} arquivos).`);
console.log('\n--- 3. FILTRANDO ARQUIVOS NÃO UTILIZADOS ---');
const unusedFiles = [];
const usedFiles = [];
for (const file of allFiles) {
const hasReference = dbContentDump.includes(file.key) || dbContentDump.includes(file.url);
if (hasReference) {
usedFiles.push(file);
} else {
unusedFiles.push(file);
}
}
const totalUnusedSize = unusedFiles.reduce((acc, curr) => acc + curr.size, 0);
console.log(`\nRESULTADOS:`);
console.log(`- Arquivos em Uso: ${usedFiles.length}`);
console.log(`- Arquivos Órfãos (NÃO usados): ${unusedFiles.length}`);
console.log(`- Espaço sendo desperdiçado: ${(totalUnusedSize / 1024 / 1024).toFixed(2)} MB`);
if (unusedFiles.length > 0) {
console.log('\n--- LISTA DE ARQUIVOS NÃO UTILIZADOS ---');
unusedFiles.forEach((file, index) => {
console.log(`${index + 1}. Bucket: [${file.bucket}] | Arquivo: ${file.key} | Tamanho: ${(file.size / 1024).toFixed(2)} KB`);
});
} else {
console.log('\nExcelente! Todos os arquivos armazenados no MinIO estão sendo referenciados no banco de dados!');
}
} catch (error) {
console.error('Erro na execução:', error);
} finally {
await pool.end();
}
}
main();

View File

@ -0,0 +1,101 @@
const pg = require('pg');
const { S3Client, ListBucketsCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3');
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://edumanager:EduManager2026!Seguro@postgres:5432/edumanager';
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'minio';
const MINIO_PORT = process.env.MINIO_PORT || '9000';
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || 'minioadmin';
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || 'MiniO2026!Seguro';
const s3Client = new S3Client({
endpoint: `http://${MINIO_ENDPOINT}:${MINIO_PORT}`,
region: 'us-east-1',
credentials: {
accessKeyId: MINIO_ACCESS_KEY,
secretAccessKey: MINIO_SECRET_KEY,
},
forcePathStyle: true,
});
async function main() {
try {
console.log('--- 1. BUSCANDO TODAS AS TABELAS E DADOS DO POSTGRESQL ---');
const { rows: tables } = await pool.query("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'");
console.log(`Encontradas ${tables.length} tabelas no banco de dados.`);
let dbContentDump = '';
for (const t of tables) {
const tableName = t.table_name;
try {
const { rows } = await pool.query(`SELECT * FROM "${tableName}"`);
dbContentDump += JSON.stringify(rows) + ' ';
} catch (err) {
console.warn(`Aviso: não foi possível ler a tabela ${tableName}:`, err.message);
}
}
console.log(`Dumping de referências concluído (${(dbContentDump.length / 1024 / 1024).toFixed(2)} MB).`);
console.log('\n--- 2. LISTANDO ARQUIVOS NO STORAGE MINIO ---');
const bucketsData = await s3Client.send(new ListBucketsCommand({}));
const buckets = bucketsData.Buckets || [];
console.log(`Encontrados ${buckets.length} buckets no MinIO.`);
const allFiles = [];
let totalStorageSize = 0;
for (const bucket of buckets) {
try {
const objects = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket.Name }));
const contents = objects.Contents || [];
console.log(`Bucket [${bucket.Name}]: ${contents.length} arquivos encontrados.`);
for (const obj of contents) {
totalStorageSize += obj.Size || 0;
allFiles.push({
bucket: bucket.Name,
key: obj.Key,
size: obj.Size || 0,
url: `/storage/${bucket.Name}/${obj.Key}`
});
}
} catch (err) {
console.warn(`Não foi possível listar objetos do bucket ${bucket.Name}:`, err.message);
}
}
console.log(`Total geral no MinIO: ${(totalStorageSize / 1024 / 1024).toFixed(2)} MB (${allFiles.length} arquivos).`);
console.log('\n--- 3. FILTRANDO ARQUIVOS NÃO UTILIZADOS ---');
const unusedFiles = [];
const usedFiles = [];
for (const file of allFiles) {
const hasReference = dbContentDump.includes(file.key) || dbContentDump.includes(file.url);
if (hasReference) {
usedFiles.push(file);
} else {
unusedFiles.push(file);
}
}
const totalUnusedSize = unusedFiles.reduce((acc, curr) => acc + curr.size, 0);
console.log(`\nRESULTADOS:`);
console.log(`- Arquivos em Uso: ${usedFiles.length}`);
console.log(`- Arquivos Órfãos (NÃO usados): ${unusedFiles.length}`);
console.log(`- Espaço sendo desperdiçado: ${(totalUnusedSize / 1024 / 1024).toFixed(2)} MB`);
if (unusedFiles.length > 0) {
console.log('\n--- LISTA DE ARQUIVOS NÃO UTILIZADOS ---');
unusedFiles.forEach((file, index) => {
console.log(`${index + 1}. Bucket: [${file.bucket}] | Arquivo: ${file.key} | Tamanho: ${(file.size / 1024).toFixed(2)} KB`);
});
} else {
console.log('\nExcelente! Todos os arquivos armazenados no MinIO estão sendo referenciados no banco de dados!');
}
} catch (error) {
console.error('Erro na execução:', error);
} finally {
await pool.end();
}
}
main();

View File

@ -0,0 +1,27 @@
const pg = require('pg');
const pool = new pg.Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager'
});
async function run() {
try {
console.log('Removendo CHECK constraint antigo...');
await pool.query('ALTER TABLE prematricula_campos DROP CONSTRAINT prematricula_campos_tipo_check');
console.log('Adicionando CHECK constraint atualizado com file e banner...');
await pool.query(`
ALTER TABLE prematricula_campos
ADD CONSTRAINT prematricula_campos_tipo_check
CHECK (tipo = ANY (ARRAY['text', 'email', 'phone', 'cpf', 'date', 'select', 'textarea', 'number', 'file', 'banner']))
`);
console.log('✅ Constraint atualizado com sucesso! Tipos file e banner agora são aceitos.');
} catch (err) {
console.error('Erro:', err.message);
} finally {
await pool.end();
}
}
run();

View File

@ -0,0 +1,32 @@
import fs from 'fs';
const file = 'manager/components/LessonSchedule.tsx';
let content = fs.readFileSync(file, 'utf8');
const match = content.match(/const handleDeleteAllSchedule = \(\) => \{.*?\n \};/s);
if (match) {
const original = match[0];
const novo = `const handleDeleteAllSchedule = () => {
showConfirm('Excluir Cronograma Completo', '⚠️ Tem certeza? Isso removerá TODAS as aulas desta turma permanentemente (agendadas, canceladas e reposições). Esta ação NÃO pode ser desfeita.', async () => {
// Deletar do PostgreSQL
const idsToDelete = dbLessons.map(l => l.id);
if (idsToDelete.length > 0) {
await fetch('/api/aulas/lote', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: idsToDelete })
});
}
const updatedLessons = (data.lessons || []).filter(l => l.classId !== classObj.id);
updateData({ lessons: updatedLessons });
await dbService.saveData({ ...data, lessons: updatedLessons });
await loadLessons();
showAlert('Sucesso', 'Cronograma completo excluído.', 'success');
});
};`;
content = content.replace(original, novo);
fs.writeFileSync(file, content, 'utf8');
console.log('handleDeleteAllSchedule atualizado!');
} else {
console.log('handleDeleteAllSchedule não encontrado.');
}

View File

@ -0,0 +1,16 @@
const { Pool } = require('pg');
const pool = new Pool({ connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager' });
async function run() {
const { rows } = await pool.query('SELECT data FROM school_data LIMIT 1');
const students = rows[0].data.students || [];
let updated = 0;
for (const s of students) {
if (s.faceDescriptor && Array.isArray(s.faceDescriptor)) {
await pool.query('UPDATE alunos SET face_descriptor = $1 WHERE id = $2', [JSON.stringify(s.faceDescriptor), s.id]);
updated++;
}
}
console.log('Total alunos atualizados com face_descriptor:', updated);
await pool.end();
}
run().catch(console.error);

View File

@ -0,0 +1,63 @@
const pg = require('pg');
const pool = new pg.Pool({ connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager' });
// Import the sync function from database.js
// Since database.js uses ES modules (import/export), we can simulate or run it,
// or write a quick sync block here to ensure the SQL table matches our corrected JSON.
async function runFix() {
try {
console.log('--- Iniciando correção dos dados ---');
// 1. Buscar o JSON legado da tabela school_data
const { rows } = await pool.query('SELECT data FROM school_data WHERE id = 1');
if (rows.length === 0) {
console.error('Tabela school_data vazia!');
await pool.end();
return;
}
const schoolData = rows[0].data;
let modifiedPaymentsCount = 0;
// 2. Corrigir os pagamentos no JSON
if (schoolData.payments && Array.isArray(schoolData.payments)) {
for (const p of schoolData.payments) {
if (p.status === 'paid' && p.amount === 190 && p.discount === 20) {
p.amount = 170;
p.valor_pago = 150;
modifiedPaymentsCount++;
}
}
}
if (modifiedPaymentsCount > 0) {
// Salvar de volta na tabela school_data
await pool.query('UPDATE school_data SET data = $1 WHERE id = 1', [JSON.stringify(schoolData)]);
console.log(`JSON corrigido: ${modifiedPaymentsCount} pagamentos atualizados de 190/170 para 170/150.`);
} else {
console.log('Nenhum pagamento pago de R$ 190 com desconto de R$ 20 encontrado no JSON.');
}
// 3. Atualizar diretamente a tabela alunos_cobrancas no SQL para refletir a correção
const sqlResult = await pool.query(
"UPDATE alunos_cobrancas SET valor = 170, amount_original = 170, valor_pago = 150 WHERE status = 'PAGO' AND discount = 20"
);
console.log(`Tabela SQL alunos_cobrancas corrigida diretamente: ${sqlResult.rowCount} linhas.`);
// 4. Exibir o estado atualizado das duas fontes para auditoria
const statusQuery = await pool.query(
`SELECT status, valor, discount, valor_pago, amount_original, count(*)
FROM alunos_cobrancas
GROUP BY status, valor, discount, valor_pago, amount_original`
);
console.log('\n--- Estado Atual no PostgreSQL (alunos_cobrancas) ---');
console.table(statusQuery.rows);
} catch (error) {
console.error('Erro ao executar correção:', error);
} finally {
await pool.end();
}
}
runFix();

View File

@ -0,0 +1,126 @@
import pg from 'pg';
const pool = new pg.Pool({
user: 'edumanager',
host: '150.230.87.131',
database: 'edumanager',
password: 'EduManager2026!Seguro',
port: 5432,
});
async function main() {
console.log('🔄 Iniciando correção de justificativas e limpeza de duplicidades no VPS...');
const evillaId = '311709fb-68ab-4168-8684-887b5ec2d731';
const evillaLessonId = 'ca727723-e854-4503-b032-b811e06cfcdf';
const evillaJustId = 'att-just-1777745007528';
const lohannyId = '05de4757-d36f-4de6-9e57-5792d13ad3e7';
const lohannyLessonId = '080469ea-58fc-4231-a016-fd35b99c0dce';
const lohannyJustId = 'att-just-1777143386721';
// ==========================================
// 1. CORREÇÃO NO BANCO DE DADOS (SQL)
// ==========================================
const client = await pool.connect();
try {
await client.query('BEGIN');
// Evilla: Deletar duplicidades no SQL
const delEvilla = await client.query(
'DELETE FROM frequencias WHERE aluno_id = $1 AND DATE(data) = \'2026-05-02\' AND id <> $2',
[evillaId, evillaJustId]
);
console.log(`[SQL] Deletadas ${delEvilla.rowCount} duplicidades para Evilla em 02/05/2026.`);
// Evilla: Corrigir fuso da justificativa e setar aula_id no SQL
const updEvilla = await client.query(
'UPDATE frequencias SET data = \'2026-05-02 14:00:00\', aula_id = $1 WHERE id = $2',
[evillaLessonId, evillaJustId]
);
console.log(`[SQL] Justificativa da Evilla corrigida para 14:00 (aula: ${evillaLessonId}).`);
// Maria Lohanny: Deletar duplicidades no SQL
const delLohanny = await client.query(
'DELETE FROM frequencias WHERE aluno_id = $1 AND DATE(data) = \'2026-04-25\' AND id <> $2',
[lohannyId, lohannyJustId]
);
console.log(`[SQL] Deletadas ${delLohanny.rowCount} duplicidades para Maria Lohanny em 25/04/2026.`);
// Maria Lohanny: Corrigir fuso da justificativa e setar aula_id no SQL
const updLohanny = await client.query(
'UPDATE frequencias SET data = \'2026-04-25 14:00:00\', aula_id = $1 WHERE id = $2',
[lohannyLessonId, lohannyJustId]
);
console.log(`[SQL] Justificativa da Maria Lohanny corrigida para 14:00 (aula: ${lohannyLessonId}).`);
await client.query('COMMIT');
console.log('[SQL] Transação finalizada com sucesso!');
} catch (err) {
await client.query('ROLLBACK');
console.error('[SQL] ❌ Erro ao atualizar tabelas:', err);
}
// ==========================================
// 2. CORREÇÃO NO CACHE LEGADO (JSON NA TABELA school_data)
// ==========================================
try {
const { rows } = await client.query('SELECT data FROM school_data WHERE id = 1');
const schoolData = rows[0]?.data || {};
if (schoolData && Array.isArray(schoolData.attendance)) {
console.log(`[JSON] Analisando array de presença (${schoolData.attendance.length} registros)...`);
// Remover duplicidades
const initialLength = schoolData.attendance.length;
schoolData.attendance = schoolData.attendance.filter(a => {
if (a.id === evillaJustId || a.id === lohannyJustId) return true;
if (a.studentId === evillaId && a.date && a.date.startsWith('2026-05-02')) {
return false;
}
if (a.studentId === lohannyId && a.date && a.date.startsWith('2026-04-25')) {
return false;
}
return true;
});
console.log(`[JSON] Removidos ${initialLength - schoolData.attendance.length} registros duplicados.`);
// Corrigir fuso horário das justificativas no JSON
let fixedCount = 0;
for (const a of schoolData.attendance) {
if (a.id === evillaJustId) {
a.date = '2026-05-02T14:00:00';
a.lessonId = evillaLessonId;
fixedCount++;
}
if (a.id === lohannyJustId) {
a.date = '2026-04-25T14:00:00';
a.lessonId = lohannyLessonId;
fixedCount++;
}
}
console.log(`[JSON] Corrigidas ${fixedCount} justificativas no JSON.`);
// Salvar de volta na tabela school_data
await client.query('UPDATE school_data SET data = $1, updated_at = NOW() WHERE id = 1', [JSON.stringify(schoolData)]);
console.log('[JSON] Tabela school_data atualizada com sucesso!');
} else {
console.warn('[JSON] Dados de frequência não localizados no school_data.');
}
} catch (err) {
console.error('[JSON] ❌ Erro ao atualizar cache JSON:', err);
} finally {
client.release();
}
console.log('✅ Correção de justificativas concluída com sucesso!');
pool.end();
}
main().catch(err => {
console.error('❌ Erro inesperado:', err);
pool.end();
});

View File

@ -0,0 +1,26 @@
const pg = require('pg');
const pool = new pg.Pool({ connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager' });
async function fix() {
// 1. Corrigir parcelas PAGAS: valor=170, amount_original=170, valor_pago=150
const r1 = await pool.query(
"UPDATE alunos_cobrancas SET valor = 170, amount_original = 170, valor_pago = 150 WHERE status = 'PAGO' AND discount = 20"
);
console.log('Parcelas PAGAS corrigidas:', r1.rowCount);
// 2. Corrigir parcelas PENDENTES que tenham valor inflado
const r2 = await pool.query(
"UPDATE alunos_cobrancas SET amount_original = valor WHERE amount_original != valor AND discount = 20"
);
console.log('Amount_original alinhado:', r2.rowCount);
// 3. Verificar resultado
const { rows } = await pool.query(
"SELECT status, count(*), sum(valor) as total_valor, sum(valor_pago) as total_pago, sum(discount) as total_desconto FROM alunos_cobrancas GROUP BY status"
);
console.table(rows);
await pool.end();
}
fix().catch(e => { console.error(e); pool.end(); });

View File

@ -0,0 +1,14 @@
import { syncJsonToRelationalTables } from '../services/database.js';
async function run() {
console.log("Forcing sync of Employees...");
try {
await syncJsonToRelationalTables();
console.log("Sync complete.");
} catch (err) {
console.error(err);
}
process.exit(0);
}
run();

View File

@ -0,0 +1,23 @@
import pg from 'pg';
const DATABASE_URL = 'postgresql://edumanager:EduManager2026!Seguro@localhost:5432/edumanager';
const pool = new pg.Pool({ connectionString: DATABASE_URL });
async function checkKeys() {
const { rows } = await pool.query('SELECT data FROM school_data WHERE id = 1');
if (rows[0]) {
const data = rows[0].data;
console.log('Keys in school_data.json:', Object.keys(data));
if (data.students && Array.isArray(data.students)) {
const studentWithPayments = data.students.find(s => s.payments && s.payments.length > 0);
if (studentWithPayments) {
console.log('Student found with payments. Student ID:', studentWithPayments.id);
console.log('First payment keys:', Object.keys(studentWithPayments.payments[0]));
console.log('First payment sample:', studentWithPayments.payments[0]);
} else {
console.log('No students found with payments array.');
}
}
}
await pool.end();
}
checkKeys();

View File

@ -0,0 +1,52 @@
import pg from 'pg';
const pool = new pg.Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager'
});
async function run() {
try {
console.log('Buscando school_data...');
const { rows } = await pool.query('SELECT data FROM school_data WHERE id = 1');
const data = rows[0]?.data;
if (!data) {
console.log('school_data não encontrado.');
return;
}
// Recursively search for keys or values containing "webp" or "fotos-alunos"
const foundPaths = [];
function search(obj, path = '') {
if (!obj) return;
if (typeof obj === 'string') {
if (obj.includes('webp') || obj.includes('fotos-alunos')) {
foundPaths.push({ path, value: obj });
}
} else if (Array.isArray(obj)) {
obj.forEach((item, index) => search(item, `${path}[${index}]`));
} else if (typeof obj === 'object') {
for (const key of Object.keys(obj)) {
search(obj[key], `${path}.${key}`);
}
}
}
search(data);
console.log('\n--- LINKS ENCONTRADOS NO JSONB ---');
if (foundPaths.length === 0) {
console.log('Nenhum link encontrado.');
} else {
foundPaths.forEach(p => {
console.log(`Caminho: ${p.path} | Valor: ${p.value}`);
});
}
} catch (err) {
console.error(err);
} finally {
await pool.end();
}
}
run();

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