Compare commits

...

10 Commits

15 changed files with 550 additions and 160 deletions

View File

@ -3,6 +3,7 @@ name: Build and Deploy
on:
push:
branches: [ main ]
workflow_dispatch: {}
jobs:
build-and-push:

View File

@ -1,37 +1,37 @@
# 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.
## 🚀 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
## ðŸ ï¸<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.
- **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)
## âš ï¸<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.
> **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)
## 🚨 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.**
> **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).
## 📜 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.
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.
@ -42,12 +42,13 @@
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).
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.
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.
@ -59,18 +60,18 @@
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.
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.
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).
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).

View File

@ -6,10 +6,11 @@ Nesta sessão de trabalho, realizamos o "core" da transição do sistema EduMana
## Módulos Migrados
### Fase 4: Gestão de Alunos e Autenticação
- **Backend Manager**: CRUD no `database.js` para tabela `alunos`, rotas `/api/alunos` no `server.selfhosted.js`.
- **Frontend Manager (`Students.tsx`)**: Refatorado para buscar via `fetch('/api/alunos')`.
- **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`.

56
TABELA_DE_MIGRACAO_SQL.md Normal file
View File

@ -0,0 +1,56 @@
# 📊 Tabela de Migração SQL-First — EduManager
> **Última atualização:** 25/05/2026 às 19:17 (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. |
| **Configurações Globais** | `profile`, `evolutionConfig`, `messageTemplates` | `configuracoes` | Pendente (Lê contexto JSON) | Pendente (Escreve via `dbService.saveData` no JSON) | Pendente (Lê do JSON) | 🔴 **Pendente** — Último bloco a ser migrado. |
---
## Resumo Quantitativo
| Status | Qtd. Módulos | Módulos |
|:---|:---:|:---|
| 🟢 100% SQL-First | **8** | Alunos, Financeiro, Funcionários, Boletim, Avaliações, Frequências, Aulas, Contratos |
| 🟡 Híbrido | **0** | Nenhum |
| 🔴 Pendente | **1** | Configurações Globais |
---
## Próximos Passos (Ordem Sugerida de Prioridade)
1. **Configurações Globais** — Criar tabela `configuracoes` no PostgreSQL e migrar `profile`, `evolutionConfig`, `messageTemplates` com rotas CRUD dedicadas.
---
## 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.

View File

@ -58,6 +58,38 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
const [currentJustificationText, setCurrentJustificationText] = useState('');
const [currentRecordForJustification, setCurrentRecordForJustification] = useState<Attendance | null>(null);
const [dbAttendance, setDbAttendance] = useState<Attendance[]>(data.attendance || []);
const loadAttendance = async () => {
try {
const res = await fetch('/api/frequencias');
if (res.ok) {
const json = await res.json();
if (json.frequencias) {
setDbAttendance(json.frequencias.map((r: any) => ({
id: r.id,
studentId: r.studentId || r.aluno_id,
classId: r.classId || r.turma_id,
lessonId: r.lessonId || r.aula_id,
date: r.date || r.data,
photo: r.photo || r.foto || r.foto_url,
verified: r.verified ?? r.verificado ?? false,
type: r.type || r.tipo || 'presence',
justification: r.justification || r.justificativa,
justificationAccepted: r.justificationAccepted ?? r.justificativa_aceita ?? false,
createdAt: r.createdAt || r.created_at
})));
}
}
} catch (e) {
console.error('Erro ao carregar frequencias do SQL:', e);
}
};
useEffect(() => {
loadAttendance();
}, []);
// Helper para normalizar URLs de fotos (vacina contra cache antigo)
const normalizePhotoUrl = (url?: string) => {
if (!url || typeof url !== 'string') return '';
@ -72,8 +104,8 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
return url;
};
const toggleAttendanceStatus = (record: any) => {
let updatedAttendance = [...(data.attendance || [])];
const toggleAttendanceStatus = async (record: any) => {
let updatedAttendance = [...dbAttendance];
if (record.isVirtual) {
// Ação do botão do Admin: criar o registro real a partir do virtual
@ -106,10 +138,10 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
if (existingIdx >= 0) {
updatedAttendance[existingIdx] = { ...updatedAttendance[existingIdx], type: newType, justification: undefined, justificationAccepted: undefined };
fetch(`/api/frequencias/${updatedAttendance[existingIdx].id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatedAttendance[existingIdx]) });
await fetch(`/api/frequencias/${updatedAttendance[existingIdx].id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatedAttendance[existingIdx]) });
} else {
updatedAttendance.push(newRecord);
fetch('/api/frequencias', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newRecord) });
await fetch('/api/frequencias', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newRecord) });
}
} else {
// Toggle existing record
@ -118,14 +150,14 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
updatedAttendance = updatedAttendance.map(a =>
a.id === record.id ? modifiedRecord : a
);
fetch(`/api/frequencias/${record.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
await fetch(`/api/frequencias/${record.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
}
updateData({ attendance: updatedAttendance });
setDbAttendance(updatedAttendance);
showAlert('Sucesso', 'Status de frequência atualizado com sucesso.', 'success');
};
const handleDeleteAttachmentRecord = () => {
const handleDeleteAttachmentRecord = async () => {
if (!attendanceForAttachment || !attendanceForAttachment.justification) return;
try {
@ -134,14 +166,14 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
delete parsed.arquivo;
const updatedJustification = JSON.stringify(parsed);
const updatedAttendance = (data.attendance || []).map(a =>
const updatedAttendance = dbAttendance.map(a =>
a.id === attendanceForAttachment.id ? { ...a, justification: updatedJustification } : a
);
const modifiedRecord = updatedAttendance.find(a => a.id === attendanceForAttachment.id);
fetch(`/api/frequencias/${attendanceForAttachment.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
await fetch(`/api/frequencias/${attendanceForAttachment.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
updateData({ attendance: updatedAttendance });
setDbAttendance(updatedAttendance);
setViewingAttachment(null);
setAttendanceForAttachment(null);
showAlert('Sucesso', 'Arquivo removido com sucesso.', 'success');
@ -171,7 +203,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
}, 400);
};
const handleAddAbsence = () => {
const handleAddAbsence = async () => {
if (!absenceStudentId || !absenceJustification || !absenceLessonId) {
showAlert('Atenção', "⚠️ Por favor, preencha todos os campos da justificativa.", 'warning');
return;
@ -190,12 +222,12 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
}
// Check if there is already a record for this lesson specifically
const existingIndex = (data.attendance || []).findIndex(a =>
const existingIndex = dbAttendance.findIndex(a =>
a.studentId === absenceStudentId &&
((a as any).lessonId === lesson.id || a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`)
);
let updatedAttendance = [...(data.attendance || [])];
let updatedAttendance = [...dbAttendance];
if (existingIndex >= 0) {
updatedAttendance[existingIndex] = {
@ -206,7 +238,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
verified: true,
lessonId: lesson.id as any
};
fetch(`/api/frequencias/${updatedAttendance[existingIndex].id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatedAttendance[existingIndex]) });
await fetch(`/api/frequencias/${updatedAttendance[existingIndex].id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updatedAttendance[existingIndex]) });
} else {
const newAbsence: Attendance = {
id: crypto.randomUUID(),
@ -220,11 +252,10 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
...(lesson ? { lessonId: lesson.id } : {}) as any
};
updatedAttendance.push(newAbsence);
fetch('/api/frequencias', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newAbsence) });
await fetch('/api/frequencias', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newAbsence) });
}
updateData({ attendance: updatedAttendance });
dbService.saveData({ ...data, attendance: updatedAttendance });
setDbAttendance(updatedAttendance);
setAbsenceStudentId('');
setAbsenceJustification('');
@ -246,7 +277,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
doc.text(`Data: ${new Date(selectedDate).toLocaleDateString()}`, 14, startY + 18);
doc.text(`Turma: ${classObj.name}`, 14, startY + 24);
const classAttendance = (data.attendance || []).filter(record =>
const classAttendance = dbAttendance.filter(record =>
record.classId === classObj.id && record.date.startsWith(selectedDate)
);
@ -310,7 +341,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.classes.map(classObj => {
const classStudents = data.students.filter(s => s.classId === classObj.id && s.status === 'active');
const attendanceCount = (data.attendance || []).filter(a => a.classId === classObj.id && a.date.startsWith(selectedDate)).length;
const attendanceCount = dbAttendance.filter(a => a.classId === classObj.id && a.date.startsWith(selectedDate)).length;
const course = data.courses.find(c => c.id === classObj.courseId);
return (
@ -395,7 +426,7 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
}
return classStudents.map(student => {
const studentActualRecords = (data.attendance || []).filter(a => a.studentId === student.id && a.classId === selectedClass.id);
const studentActualRecords = dbAttendance.filter(a => a.studentId === student.id && a.classId === selectedClass.id);
const classLessonsRaw = (data.lessons || []).filter(l => l.classId === selectedClass.id && l.status !== 'cancelled');
const deduplicatedLessons = classLessonsRaw.filter((lesson, index, self) =>
@ -510,10 +541,10 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
const now = new Date();
const studentClassIds = new Set([
selectedClass.id,
...(data.attendance || []).filter(a => a.studentId === selectedStudent.id).map(a => a.classId)
...dbAttendance.filter(a => a.studentId === selectedStudent.id).map(a => a.classId)
].filter(Boolean));
const actualRecords = (data.attendance || [])
const actualRecords = dbAttendance
.filter(a => a.studentId === selectedStudent.id);
const classLessonsRaw = (data.lessons || [])
@ -745,12 +776,11 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
{hasPendingJustification && (
<button
onClick={() => {
const updated = (data.attendance || []).map(a => a.id === record.id ? { ...a, justificationAccepted: true } : a);
onClick={async () => {
const updated = dbAttendance.map(a => a.id === record.id ? { ...a, justificationAccepted: true } : a);
const modifiedRecord = updated.find(a => a.id === record.id);
fetch(`/api/frequencias/${record.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
updateData({ attendance: updated });
dbService.saveData({ ...data, attendance: updated });
await fetch(`/api/frequencias/${record.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modifiedRecord) });
setDbAttendance(updated);
showAlert('Sucesso', 'Justificativa aceita com sucesso.', 'success');
}}
className="text-[10px] px-2 py-1.5 bg-indigo-600 text-white font-bold rounded hover:bg-indigo-700 transition-colors"

View File

@ -45,8 +45,8 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
const loadData = async () => {
try {
const [resC, resT] = await Promise.all([
fetch('/api/contratos'),
fetch('/api/modelos-contrato')
fetch(`/api/contratos?t=${Date.now()}`),
fetch(`/api/modelos-contrato?t=${Date.now()}`)
]);
if (resC.ok) {
const json = await resC.json();
@ -172,9 +172,6 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
body: JSON.stringify(formData)
}).then(() => loadData());
updateData({
contracts: dbContracts.map(c => c.id === (formData as any).id ? { ...c, ...formData } : c)
});
closeModal();
return;
}
@ -191,7 +188,6 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
body: JSON.stringify(newContract)
}).then(() => loadData());
updateData({ contracts: [...dbContracts, newContract] });
closeModal();
};
@ -247,7 +243,6 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
body: JSON.stringify({ ...templateFormData, id: updatedTemplates.find(t=>t.name===templateFormData.name)?.id || crypto.randomUUID() })
}).then(() => loadData());
}
updateData({ contractTemplates: updatedTemplates });
closeTemplateModal();
};
@ -276,7 +271,7 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
'Excluir Contrato',
'Tem certeza que deseja excluir este contrato?',
() => {
updateData({ contracts: dbContracts.filter(c => c.id !== id) });
fetch(`/api/contratos/${id}`, { method: 'DELETE' }).then(() => loadData());
}
);
};
@ -286,7 +281,7 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
'Excluir Modelo',
'Tem certeza que deseja excluir este modelo de contrato?',
() => {
updateData({ contractTemplates: dbTemplates.filter(t => t.id !== id) });
fetch(`/api/modelos-contrato/${id}`, { method: 'DELETE' }).then(() => loadData());
}
);
};

View File

@ -31,8 +31,8 @@ const Employees: React.FC = () => {
try {
setIsLoadingData(true);
const [empRes, catRes] = await Promise.all([
fetch('/api/funcionarios'),
fetch('/api/categorias_funcionarios')
fetch(`/api/funcionarios?t=${Date.now()}`),
fetch(`/api/categorias_funcionarios?t=${Date.now()}`)
]);
const empData = await empRes.json();
const catData = await catRes.json();

View File

@ -3,7 +3,7 @@ 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';
import { dbService } from '../services/dbService';
interface ExamsProps {
data: SchoolData;
@ -27,7 +27,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
const loadExams = async () => {
try {
const res = await fetch('/api/provas');
const res = await fetch(`/api/provas?t=${Date.now()}`);
if (res.ok) {
const { provas } = await res.json();
setDbExams(provas.map((p: any) => ({
@ -122,15 +122,19 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
setCurrentView('builder');
};
const handleToggleRetake = (examId: string) => {
const updatedExams = exams.map(e => {
if (e.id === examId) {
return { ...e, allowRetake: !e.allowRetake };
}
return e;
});
updateData({ exams: updatedExams });
dbService.saveData({ ...data, exams: updatedExams });
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) => {
@ -149,9 +153,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
}
} catch (e) { console.error('Erro ao deletar prova:', e); }
const updatedExams = exams.map(e => e.id === examId ? { ...e, isDeleted: true } : e);
updateData({ exams: updatedExams });
dbService.saveData({ ...data, exams: updatedExams });
await loadExams();
showAlert('Sucesso', 'Avaliação movida para a lixeira.', 'success');
}
);
@ -169,30 +171,58 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
}
} catch (e) { console.error('Erro ao restaurar prova:', e); }
const updatedExams = exams.map(e => e.id === examId ? { ...e, isDeleted: false } : e);
updateData({ exams: updatedExams });
dbService.saveData({ ...data, exams: updatedExams });
await loadExams();
showAlert('Sucesso', 'Avaliação reativada.', 'success');
};
const handleDuplicateExam = () => {
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', // Sempre começa como rascunho para segurança
title: `${duplicatingExam.title} (Cópia)`
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)
}))
};
const updatedExams = [...exams, newExam];
updateData({ exams: updatedExams });
dbService.saveData({ ...data, exams: updatedExams });
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('');
showAlert('Sucesso', 'Avaliação duplicada com sucesso!', 'success');
};
const handleAddQuestion = () => {
@ -306,7 +336,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
);
};
const handleSave = (status: 'draft' | 'published') => {
const handleSave = async (status: 'draft' | 'published') => {
if (!editingExam) return;
if (!editingExam.title || !editingExam.classId) {
@ -324,18 +354,25 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
}
const finalExam = { ...editingExam, status };
const currentExams = data.exams || [];
const existingIndex = currentExams.findIndex(e => e.id === finalExam.id);
const isNew = !dbExams.some(e => e.id === finalExam.id);
const endpoint = isNew ? '/api/provas' : `/api/provas/${finalExam.id}`;
const method = isNew ? 'POST' : 'PUT';
let newExams;
if (existingIndex >= 0) {
newExams = [...currentExams];
newExams[existingIndex] = finalExam;
} else {
newExams = [...currentExams, finalExam];
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;
}
updateData({ exams: newExams });
setCurrentView('list');
setEditingExam(null);
};
@ -722,12 +759,21 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
</div>
<div className="border-t border-slate-100 pt-4 flex items-center justify-between">
{exam.isDeleted ? (
<button
onClick={() => handleRestoreExam(exam.id)}
className="w-full 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>
<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">

View File

@ -178,7 +178,7 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
const fetchPostgresPayments = async () => {
try {
const resp = await fetch('/api/admin/cobrancas');
const resp = await fetch(`/api/admin/cobrancas?t=${Date.now()}`);
if (resp.ok) {
const records = await resp.json();
const normalized = (records || []).map((r: any) => {
@ -260,7 +260,7 @@ const Finance: React.FC<FinanceProps> = ({ data, updateData }) => {
setIsFetchingSupabase(true);
setSelectedSupabaseRows([]);
try {
const resp = await fetch('/api/admin/cobrancas');
const resp = await fetch(`/api/admin/cobrancas?t=${Date.now()}`);
if (!resp.ok) throw new Error('API fetch failed');
const records = await resp.json();
setSupabaseRecords(records || []);

View File

@ -17,7 +17,7 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
const [dbLessons, setDbLessons] = useState<Lesson[]>([]);
const loadLessons = async () => {
try {
const res = await fetch(`/api/aulas?turma_id=${classObj.id}`);
const res = await fetch(`/api/aulas?turma_id=${classObj.id}&t=${Date.now()}`);
if (res.ok) {
const json = await res.json();
setDbLessons(json.aulas || []);
@ -167,8 +167,6 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
body: JSON.stringify({ aulas: newLessons })
}).then(() => loadLessons());
const updatedLessons = [...(data.lessons || []), ...newLessons];
// 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}).`;
@ -177,8 +175,8 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
const newNotifs = notifyLessonAction('Aulas Extras Agendadas', notifMsg, waMsg);
const updatedNotifications = [...(data.notifications || []), ...newNotifs];
updateData({ lessons: updatedLessons, notifications: updatedNotifications });
dbService.saveData({ ...data, lessons: updatedLessons, notifications: updatedNotifications });
updateData({ notifications: updatedNotifications });
dbService.saveData({ ...data, notifications: updatedNotifications });
setShowGenerateModal(false);
@ -317,8 +315,8 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aulas: updatedLessons })
}).then(() => loadLessons());
updateData({ lessons: [...(data.lessons || []).filter(dl => dl.classId !== classObj.id), ...updatedLessons], notifications: updatedNotifications });
await dbService.saveData({ ...data, lessons: [...(data.lessons || []).filter(dl => dl.classId !== classObj.id), ...updatedLessons], notifications: updatedNotifications });
updateData({ notifications: updatedNotifications });
await dbService.saveData({ ...data, notifications: updatedNotifications });
@ -336,7 +334,7 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
const handleUncancelLesson = async (lesson: Lesson) => {
setIsClosing(true);
const updatedLessons: Lesson[] = (data.lessons || []).map(l =>
const updatedLessons: Lesson[] = dbLessons.map(l =>
l.id === lesson.id ? { ...l, status: 'scheduled', cancelReason: undefined } : l
);
fetch('/api/aulas/lote', {
@ -344,8 +342,6 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aulas: updatedLessons })
}).then(() => loadLessons());
updateData({ lessons: [...(data.lessons || []).filter(dl => dl.classId !== classObj.id), ...updatedLessons] });
await dbService.saveData({ ...data, lessons: [...(data.lessons || []).filter(dl => dl.classId !== classObj.id), ...updatedLessons] });
setTimeout(() => {
setShowLessonDetail(null);
@ -373,7 +369,7 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
}
setIsClosing(true);
const updatedLessons: Lesson[] = (data.lessons || []).map(l =>
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
);
@ -391,8 +387,14 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
const newNotifs = notifyLessonAction('Aula Reagendada', notifMsg, waMsg);
const updatedNotifications = [...(data.notifications || []), ...newNotifs];
updateData({ lessons: updatedLessons, notifications: updatedNotifications, attendance: updatedAttendance });
await dbService.saveData({ ...data, lessons: updatedLessons, notifications: updatedNotifications, attendance: updatedAttendance });
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);
@ -407,14 +409,17 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
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 = (data.lessons || []).map(l => {
if (l.classId === classObj.id && l.status === 'scheduled' && l.date >= today) {
const updatedLessons = dbLessons.map(l => {
if (l.status === 'scheduled' && l.date >= today) {
return { ...l, status: 'cancelled', cancelReason: 'Cancelamento Geral de Cronograma' };
}
return l;
});
updateData({ lessons: updatedLessons as Lesson[] });
await dbService.saveData({ ...data, lessons: updatedLessons as Lesson[] });
fetch('/api/aulas/lote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aulas: updatedLessons })
}).then(() => loadLessons());
showAlert('Sucesso', 'Cronograma futuro cancelado.', 'success');
});
};
@ -422,14 +427,17 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
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 = (data.lessons || []).map(l => {
if (l.classId === classObj.id && l.status === 'cancelled' && l.date >= today) {
const updatedLessons = dbLessons.map(l => {
if (l.status === 'cancelled' && l.date >= today) {
return { ...l, status: 'scheduled', cancelReason: undefined };
}
return l;
});
updateData({ lessons: updatedLessons as Lesson[] });
await dbService.saveData({ ...data, lessons: updatedLessons as Lesson[] });
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');
});
};
@ -446,9 +454,6 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
});
}
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');
});

View File

@ -44,7 +44,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
const loadStudents = async () => {
try {
const res = await fetch('/api/alunos');
const res = await fetch(`/api/alunos?t=${Date.now()}`);
if (res.ok) {
const json = await res.json();
setDbStudents(json.alunos || []);
@ -849,7 +849,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
s.id === editingStudent.id ? studentToSave : s
);
} else {
updatedStudents = [...data.students, studentToSave];
updatedStudents = [...dbStudents, studentToSave];
}
// Process Generate Fee and Contract
@ -969,7 +969,23 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
};
updateData(newData);
dbService.saveData({ ...data, ...newData });
const isNew = !editingStudent;
const endpoint = isNew ? '/api/alunos' : `/api/alunos/${studentToSave.id}`;
const method = isNew ? 'POST' : 'PUT';
const saveResponse = await fetch(endpoint, {
method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(studentToSave)
});
if (!saveResponse.ok) {
throw new Error('Falha ao salvar o aluno no banco de dados');
}
await loadStudents();
showAlert('Sucesso', (formData as any).generateFee ? 'Aluno salvo e nova cobrança gerada com sucesso.' : 'Aluno salvo com sucesso.', 'success');
closeModal();
} catch (error) {
@ -997,7 +1013,27 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
);
updateData({ students: updatedStudents });
dbService.saveData({ ...data, students: updatedStudents });
try {
const studentToUpdate = {
...showDeleteModal,
status: 'cancelled' as const,
cancellationReason,
classId: ''
};
const response = await fetch(`/api/alunos/${showDeleteModal.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(studentToUpdate)
});
if (!response.ok) {
throw new Error('Falha ao cancelar matrícula no servidor');
}
await loadStudents();
} catch (err) {
console.error(err);
showAlert('Erro', 'Falha ao cancelar a matrícula no servidor.', 'error');
return;
}
if (generatePDF) {
await pdfService.generateCancellationTermPDF(showDeleteModal, data, cancellationReason);
@ -1030,7 +1066,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
);
updateData({ students: updatedStudents });
dbService.saveData({ ...data, students: updatedStudents });
await loadStudents();
showAlert('Sucesso', 'Aluno rematriculado com sucesso.', 'success');
} catch (error) {
@ -1061,20 +1097,52 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
};
updateData(newData);
dbService.saveData({ ...data, ...newData });
try {
const response = await fetch(`/api/alunos/${student.id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Falha ao deletar aluno no servidor');
}
await loadStudents();
} catch (err) {
console.error(err);
showAlert('Erro', 'Ocorreu um erro ao excluir o aluno do servidor.', 'error');
return;
}
showAlert('Sucesso', 'O aluno e todo o seu histórico foram removidos permanentemente.', 'success');
}
);
};
const handleTransferStudent = () => {
const handleTransferStudent = async () => {
if (!transferringStudent || !newClassId) return;
const updatedStudents = dbStudents.map(s =>
s.id === transferringStudent.id ? { ...s, classId: newClassId } : s
);
updateData({ students: updatedStudents });
dbService.saveData({ ...data, students: updatedStudents });
try {
const studentToUpdate = {
...transferringStudent,
classId: newClassId
};
const response = await fetch(`/api/alunos/${transferringStudent.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(studentToUpdate)
});
if (!response.ok) {
throw new Error('Falha ao transferir aluno no servidor');
}
await loadStudents();
} catch (err) {
console.error(err);
showAlert('Erro', 'Ocorreu um erro ao transferir o aluno no servidor.', 'error');
return;
}
showAlert('Sucesso', 'Aluno transferido com sucesso.', 'success');
closeTransferModal();
};
@ -1117,7 +1185,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
setShowModal(true);
};
const filteredStudents = data.students
const filteredStudents = dbStudents
.filter(s => {
const matchesSearch = (s.name || '').toLowerCase().includes((searchTerm || '').toLowerCase()) ||
(s.cpf || '').includes(searchTerm) ||
@ -1243,7 +1311,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
})}
{/* Card for students without class */}
{data.students.some(s => !s.classId && (activeTab === 'active' ? s.status !== 'cancelled' : s.status === 'cancelled')) && (
{dbStudents.some(s => !s.classId && (activeTab === 'active' ? s.status !== 'cancelled' : s.status === 'cancelled')) && (
<button
onClick={() => setSelectedClassId('none')}
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-md hover:border-amber-300 transition-all text-left group"

View File

@ -29,7 +29,12 @@ const App = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const [currentView, setCurrentView] = useState<View>(View.Dashboard);
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);
// Initial load from LocalStorage for speed (fallback), then IDB

View File

@ -532,7 +532,16 @@ app.get('/api/alunos', async (req, res) => {
app.post('/api/alunos', async (req, res) => {
try {
await insertAluno(req.body);
const student = req.body;
await insertAluno(student);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbAlunos = await getAlunos();
appData.students = dbAlunos;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao criar aluno:', error);
@ -542,7 +551,17 @@ app.post('/api/alunos', async (req, res) => {
app.put('/api/alunos/:id', async (req, res) => {
try {
await updateAluno(req.params.id, req.body);
const { id } = req.params;
const student = req.body;
await updateAluno(id, student);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbAlunos = await getAlunos();
appData.students = dbAlunos;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao atualizar aluno:', error);
@ -552,7 +571,27 @@ app.put('/api/alunos/:id', async (req, res) => {
app.delete('/api/alunos/:id', async (req, res) => {
try {
await deleteAluno(req.params.id);
const { id } = req.params;
// 1. Relational cleanups in Postgres
await pool.query('DELETE FROM alunos_cobrancas WHERE aluno_id = $1', [id]);
await pool.query('DELETE FROM contratos WHERE aluno_id = $1', [id]);
await pool.query('DELETE FROM frequencias WHERE aluno_id = $1', [id]);
await pool.query('DELETE FROM notificacoes WHERE aluno_id = $1', [id]);
await pool.query('DELETE FROM provas_submissoes WHERE aluno_id = $1', [id]);
await pool.query('DELETE FROM notas_boletim WHERE aluno_id = $1', [id]);
await deleteAluno(id);
// 2. Reverse sync to legacy JSON
const appData = await getSchoolData();
appData.students = appData.students.filter(s => s.id !== id);
appData.payments = appData.payments.filter(p => p.studentId !== id);
appData.contracts = appData.contracts.filter(c => c.studentId !== id);
if (appData.attendance) appData.attendance = appData.attendance.filter(a => a.studentId !== id);
if (appData.notifications) appData.notifications = appData.notifications.filter(n => n.studentId !== id);
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar aluno:', error);
@ -560,6 +599,7 @@ app.delete('/api/alunos/:id', async (req, res) => {
}
});
// ============================================================
// ROTAS DE CONTRATOS E MODELOS
// ============================================================
@ -567,26 +607,68 @@ app.get('/api/modelos-contrato', async (req, res) => {
try { res.json({ modelos: await getModelosContrato() }); } catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.post('/api/modelos-contrato', async (req, res) => {
try { await insertModeloContrato(req.body); res.json({ success: true }); } catch (e) { res.status(500).json({ error: 'Erro' }); }
try {
await insertModeloContrato(req.body);
const appData = await getSchoolData();
appData.contractTemplates = await getModelosContrato();
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.put('/api/modelos-contrato/:id', async (req, res) => {
try { await updateModeloContrato(req.params.id, req.body); res.json({ success: true }); } catch (e) { res.status(500).json({ error: 'Erro' }); }
try {
await updateModeloContrato(req.params.id, req.body);
const appData = await getSchoolData();
appData.contractTemplates = await getModelosContrato();
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.delete('/api/modelos-contrato/:id', async (req, res) => {
try { await deleteModeloContrato(req.params.id); res.json({ success: true }); } catch (e) { res.status(500).json({ error: 'Erro' }); }
try {
await deleteModeloContrato(req.params.id);
const appData = await getSchoolData();
appData.contractTemplates = await getModelosContrato();
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.get('/api/contratos', async (req, res) => {
try { res.json({ contratos: await getContratos() }); } catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.post('/api/contratos', async (req, res) => {
try { await insertContrato(req.body); res.json({ success: true }); } catch (e) { res.status(500).json({ error: 'Erro' }); }
try {
await insertContrato(req.body);
const appData = await getSchoolData();
appData.contracts = await getContratos();
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.put('/api/contratos/:id', async (req, res) => {
try { await updateContrato(req.params.id, req.body); res.json({ success: true }); } catch (e) { res.status(500).json({ error: 'Erro' }); }
try {
await updateContrato(req.params.id, req.body);
const appData = await getSchoolData();
appData.contracts = await getContratos();
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Erro' }); }
});
app.delete('/api/contratos/:id', async (req, res) => {
try { await deleteContrato(req.params.id); res.json({ success: true }); } catch (e) { res.status(500).json({ error: 'Erro' }); }
try {
await deleteContrato(req.params.id);
const appData = await getSchoolData();
appData.contracts = await getContratos();
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Erro' }); }
});
// ============================================================
@ -607,6 +689,14 @@ app.post('/api/aulas/lote', async (req, res) => {
try {
const { aulas } = req.body;
await insertAulas(aulas);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbAulas = await getAllAulas();
appData.lessons = dbAulas;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao inserir aulas em lote:', error);
@ -618,6 +708,14 @@ app.delete('/api/aulas/lote', async (req, res) => {
try {
const { ids } = req.body;
await deleteAulas(ids);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbAulas = await getAllAulas();
appData.lessons = dbAulas;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar aulas em lote:', error);
@ -641,6 +739,14 @@ app.get('/api/frequencias', async (req, res) => {
app.post('/api/frequencias', async (req, res) => {
try {
await insertFrequencia(req.body);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbFrequencias = await getFrequencias();
appData.attendance = dbFrequencias;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao inserir frequencia:', error);
@ -651,6 +757,14 @@ app.post('/api/frequencias', async (req, res) => {
app.put('/api/frequencias/:id', async (req, res) => {
try {
await updateFrequencia(req.params.id, req.body);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbFrequencias = await getFrequencias();
appData.attendance = dbFrequencias;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao atualizar frequencia:', error);
@ -661,6 +775,14 @@ app.put('/api/frequencias/:id', async (req, res) => {
app.delete('/api/frequencias/:id', async (req, res) => {
try {
await deleteFrequencia(req.params.id);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbFrequencias = await getFrequencias();
appData.attendance = dbFrequencias;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar frequencia:', error);
@ -693,7 +815,21 @@ app.get('/api/provas/:id/questoes', async (req, res) => {
app.post('/api/provas', async (req, res) => {
try {
await insertProva(req.body);
const prova = req.body;
await insertProva(prova);
// Sync questions if provided
if (prova.questions && prova.questions.length > 0) {
await syncQuestoesProva(prova.id, prova.questions);
}
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbProvas = await getProvas();
appData.exams = dbProvas;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao criar prova:', error);
@ -703,7 +839,22 @@ app.post('/api/provas', async (req, res) => {
app.put('/api/provas/:id', async (req, res) => {
try {
await updateProva(req.params.id, req.body);
const { id } = req.params;
const prova = req.body;
await updateProva(id, prova);
// Sync questions if provided
if (prova.questions) {
await syncQuestoesProva(id, prova.questions);
}
// Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbProvas = await getProvas();
appData.exams = dbProvas;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao atualizar prova:', error);
@ -713,7 +864,18 @@ app.put('/api/provas/:id', async (req, res) => {
app.delete('/api/provas/:id', async (req, res) => {
try {
await deleteProva(req.params.id);
const { id } = req.params;
// Cleanup relational dependencies
await pool.query('DELETE FROM questoes_provas WHERE prova_id = $1', [id]);
await pool.query('DELETE FROM provas_submissoes WHERE prova_id = $1', [id]);
await deleteProva(id);
// Reverse sync to legacy JSON
const appData = await getSchoolData();
appData.exams = (appData.exams || []).filter(e => e.id !== id);
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao deletar prova:', error);
@ -1787,7 +1949,25 @@ app.get('/api/cobrancas/:id/link', async (req, res) => {
} catch (error) { return res.status(500).json({ error: 'Erro interno.' }); }
});
app.patch('/api/alunos/:id/rematricular', async (req, res) => res.json({ success: true }));
app.patch('/api/alunos/:id/rematricular', async (req, res) => {
try {
const { id } = req.params;
// 1. Update in Postgres
await pool.query("UPDATE alunos SET status = 'active', motivo_cancelamento = NULL WHERE id = $1", [id]);
// 2. Reverse sync to legacy JSON
const appData = await getSchoolData();
const dbAlunos = await getAlunos();
appData.students = dbAlunos;
appData.lastUpdated = new Date().toISOString();
await saveSchoolData(appData);
res.json({ success: true });
} catch (error) {
console.error('Erro ao rematricular aluno:', error);
res.status(500).json({ error: 'Erro interno' });
}
});
app.put('/api/cobrancas/:id', async (req, res) => {
try {

View File

@ -917,7 +917,7 @@ export async function getProvas() {
examId: q.prova_id,
text: q.texto,
options: q.opcoes || [],
correctAnswer: q.indice_correto,
correctOptionIndex: q.indice_correto ?? 0,
order: q.ordem,
imageUrl: q.imagem_url
}))
@ -975,7 +975,7 @@ export async function syncQuestoesProva(provaId, questoes) {
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)`,
[q.id || require('crypto').randomUUID(), provaId, q.texto || q.text, q.imagem_url || q.imageUrl, JSON.stringify(q.opcoes || q.options || []), q.indice_correto ?? q.correctIndex ?? 0, i]
[q.id || require('crypto').randomUUID(), provaId, q.texto || q.text, q.imagem_url || q.imageUrl, JSON.stringify(q.opcoes || q.options || []), q.indice_correto ?? q.correctOptionIndex ?? q.correctIndex ?? 0, i]
);
}
await client.query('COMMIT');

View File

@ -593,6 +593,8 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arq
const submittedAt = new Date().toISOString();
let recordIndex = attendance.findIndex(a => a.studentId === req.user.studentId && a.date === fullDateStr);
if (recordIndex !== -1) {
const existing = attendance[recordIndex];
if (existing.type === 'presence') return res.status(400).json({ error: 'Não é possível justificar uma presença' });