feat: Migração SQL-First para Aulas e Contratos
This commit is contained in:
parent
b55633a191
commit
65119df2f2
|
|
@ -59,10 +59,11 @@
|
||||||
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.
|
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.
|
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.
|
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)**: To ensure visual consistency and avoid automation errors (like double billing), the system MUST perform a reverse sync from `alunos_cobrancas` (SQL) to `school_data.payments` (JSON) during server initialization and on-demand via the `/api/admin/sync-finance-json` endpoint.
|
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.
|
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.
|
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 (`—`) instead. 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`.
|
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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
161
MEMORY.md
161
MEMORY.md
|
|
@ -1,148 +1,23 @@
|
||||||
# MEMORY.md - Contexto de Desenvolvimento
|
# Log de Migração SQL-First (Fase 5/6: Aulas, Frequências e Contratos)
|
||||||
|
|
||||||
> [!CAUTION]
|
## Resumo das Modificações
|
||||||
> **Git Push Proibido Sem Demanda Explícita:**
|
Nesta sessão, focamos em remover a exclusividade do arquivo `school_data.json` nos módulos de Cronograma (Aulas), Frequências e Contratos (incluindo Modelos de Contrato).
|
||||||
> NUNCA execute `git add`, `git commit` ou `git push` sem que o USUÁ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ÁVEL E O ASSISTENTE JÁ FALHOU NELA ANTERIORMENTE. NÃO REPITA O ERRO.**
|
|
||||||
|
|
||||||
- [x] **Correção Estrutural (Boletim):** Resolvida divergência de tabelas entre `notas` e `notas_boletim` no Manager, restaurando a exibição de médias.
|
### 1. Banco de Dados e Backend (Manager)
|
||||||
- [x] **Frequência Analítica (Portal):** Cards de estatísticas (Presença/Falta) agora usam a mesma lógica da lista (considerando `verified` e justificativas).
|
- Adicionados métodos CRUD no arquivo `manager/services/database.js` para as tabelas `aulas`, `contratos` e `modelos_contrato`.
|
||||||
- [x] **Nova Métrica de Justificativas:** Adicionado card exclusivo no Portal para acompanhamento de justificativas enviadas.
|
- Expostos endpoints em `manager/server.selfhosted.js`:
|
||||||
- [x] **Detalhamento de Progresso de Aulas:** Card de "Total de Aulas" agora exibe aulas concluídas e aulas a concluir.
|
- `/api/aulas` (GET) e `/api/aulas/lote` (POST, DELETE)
|
||||||
- [x] **Migração Relacional de Frequência:** Portal migrado para ler frequências diretamente da tabela SQL `frequencias`. **VERIFICADO.**
|
- `/api/modelos-contrato` (GET, POST, PUT, DELETE)
|
||||||
- [x] **Sincronização Bidirecional (Frequência):** Garantido que justificativas enviadas pelo Portal atualizem instantaneamente a tabela relacional via `ON CONFLICT`.
|
- `/api/contratos` (GET, POST, DELETE)
|
||||||
- [x] **Auto-Migração de Esquema:** Implementada lógica de auto-correção de colunas (`ALTER TABLE`) na rotina de sincronização do banco de dados (`database.js`).
|
- **Sincronização Injetada:** Modificada a função `syncJsonToRelationalTables` para iterar sobre `schoolData.lessons`, `schoolData.attendance`, `schoolData.contracts` e `schoolData.contractTemplates`, executando `INSERT ... ON CONFLICT DO UPDATE`. Isso garante que operações híbridas (que salvam JSON no Frontend) sejam espelhadas instantaneamente no PostgreSQL.
|
||||||
- [x] **Fechamento Automático de Pauta:** Implementada rotina `processAutoAbsences` que gera registros físicos de falta para aulas passadas sem registro, garantindo consistência entre Portal e Manager.
|
|
||||||
- [x] **Sistema de Notificações Unificado (SQL):** Migração completa do sistema de notificações (sino) para a tabela relacional `notificacoes`, eliminando a dependência do JSON legado.
|
|
||||||
- [x] **Alertas de Avaliações:** Implementado disparo automático de notificações SQL e WhatsApp (via Evolution API) para turmas inteiras ao publicar exames/atividades.
|
|
||||||
- [x] **Justificativas Relacionais:** Notificações de justificativas de falta enviadas pelo Portal agora são salvas diretamente no PostgreSQL (aluno_id = 'admin').
|
|
||||||
- [x] **Intelligent Polling Admin:** O Admin Bell agora utiliza polling de 30s para sincronização em tempo real com o banco SQL, garantindo que novos alertas apareçam instantaneamente.
|
|
||||||
- [x] **Lixeira de Avaliações (Soft Delete):** Implementada aba de "Lixeira" no Manager que oculta provas sem deletar dados, preservando as notas no Boletim e no Portal.
|
|
||||||
- [x] **Unificação da Média Aritmética:** Refatorados `ReportCard.tsx` (Manager) e `Notas.tsx` (Portal) para calcular médias aritméticas reais (Média das Médias) em todos os níveis.
|
|
||||||
- [x] **Sincronização de Notas Órfãs:** Garantido que notas de provas deletadas/arquivadas permaneçam visíveis com seus respectivos títulos no Manager e Portal.
|
|
||||||
- [x] **Correção de Polling e Conflitos:** Ajustado timestamp `lastUpdated` para evitar sobrescritas de dados durante a sincronização em segundo plano.
|
|
||||||
- [x] **Git Push Realizado:** Todas as alterações de arquitetura de notas e exclusão lógica foram versionadas e enviadas ao repositório remoto.
|
|
||||||
- [x] **Correção do Sync Status:** Resolvido loop infinito no `index.tsx` que travava o status em "syncing" ao sincronizar o `lastUpdated` com o servidor.
|
|
||||||
- [x] **Blindagem de Fuso Horário (Postgres):** Rota de frequência do portal atualizada para usar `TO_CHAR` no SQL, eliminando deslocamentos de horas causados pela conversão UTC automática do driver.
|
|
||||||
- [x] **Unificação de Janela de Presença:** Portal e Manager agora utilizam a mesma janela de 30 minutos de tolerância para correlacionar presenças e faltas às aulas.
|
|
||||||
- [x] **Sincronia de Estatísticas (Portal):** O cálculo de porcentagem no Dashboard do Portal agora usa o mesmo motor lógico da página de Frequência, garantindo números idênticos.
|
|
||||||
- [x] **Consolidação do Modelo Relacional (Notas):** Confirmado que o módulo de Notas/Boletim é o primeiro 100% SQL, servindo de template para futuras migrações. O JSON `school_data.grades` foi oficialmente substituído pela tabela `notas_boletim`.
|
|
||||||
- [x] **Unificação de Pauta (Deduplicação):** Implementado filtro de deduplicação de aulas no Portal (`Frequencia.tsx` e `Dashboard.tsx`) para ignorar aulas conflitantes, igualando os totais aos do Admin.
|
|
||||||
- [x] **Regra de Registro Único:** Portal agora exibe apenas a primeira batida válida por aula, eliminando duplicidade visual de biometria.
|
|
||||||
- [x] **Sincronia de Justificativas:** Ajustada a contagem matemática do Portal para contabilizar faltas justificadas apenas após o aceite do Admin.
|
|
||||||
- [x] **Fix Dashboard Crash:** Corrigido erro de "tela preta" no Dashboard causado por acesso inseguro a propriedades nulas durante falhas de API.
|
|
||||||
- [x] **Blindagem de Conversão ISO:** Resolvida falha crítica de `RangeError: Invalid time value` em todo o Portal. Agora o sistema ignora datas corrompidas ou inválidas em vez de quebrar a interface inteira.
|
|
||||||
- [x] **Resolução de ReferenceError:** Identificada e corrigida a causa raiz da tela preta persistente (variáveis não declaradas após refatoração da pauta).
|
|
||||||
- [x] **Auditoria TypeScript:** Realizada varredura total com `tsc` no Portal, corrigindo todos os erros de tipagem remanescentes em Notas e Avaliações para garantir estabilidade absoluta.
|
|
||||||
- [x] **Paridade Lógica Absoluta (Frequência):** A lógica de matching de frequência do Portal (`Frequencia.tsx` e `Dashboard.tsx`) agora é um clone 100% idêntico ao do Manager (`AttendanceQuery.tsx`).
|
|
||||||
- [x] **Fix Janela de Matching (Portal):** Corrigido bug onde aulas sem `endTime` fechavam a janela de presença prematuramente (fallback alterado de `00:00:00` para `23:59:00`).
|
|
||||||
- [x] **Resolução de Justificativas Invisíveis:** Justificativas enviadas agora são corretamente mapeadas às aulas no Portal através da sincronização de janelas de tempo e IDs.
|
|
||||||
- [x] **Correção do Fluxo de Exclusão (WhatsApp):** Ajustada a ordem de exclusão no `server.selfhosted.js` e `Finance.tsx`. A limpeza local agora ocorre apenas após o disparo bem-sucedido do WhatsApp via Webhook, garantindo que as variáveis `{nome}` e `{descricao}` nunca cheguem vazias.
|
|
||||||
- [x] **Feedback Real de Exclusão:** Implementado `showAlert` detalhado no Financeiro e confirmação real via **Sino de Notificações** (Admin) após o processamento do Webhook.
|
|
||||||
- [x] **Refinamento do Sino (AdminBell):** O botão "Ver Anexo" agora é exclusivo de notificações de justificativa que possuem arquivo físico, ocultando-se em notificações de sistema que usam o campo anexo para metadados JSON.
|
|
||||||
- [x] **Blindagem de UI (Financeiro):** Corrigido potencial erro de referência no parse de erro do Asaas que causava "White Screen" em exclusões negadas.
|
|
||||||
- [x] **Correção de Visibilidade (Frequência):** Resolvido bug onde faltas verificadas apareciam como "Presente" no Portal devido a lógica de filtro incompleta.
|
|
||||||
- [x] **Hora Presença Inteligente:** Coluna de horário no Portal agora exibe `—` para faltas e justificativas, eliminando a confusão com horários de aula.
|
|
||||||
- [x] **Rastreamento de Justificativas:** Implementado campo `submittedAt` para gravar o momento exato do envio da justificativa (JSON e SQL).
|
|
||||||
- [x] **Detalhamento de Envio:** Portal agora exibe "Enviada em: DD/MM às HH:MM" na lista de frequência para transparência do aluno.
|
|
||||||
|
|
||||||
- [x] **Migração do Módulo Financeiro para SQL (Fase 1 e 2):** Aba financeiro do Manager migrada para ler diretamente a tabela PostgreSQL `alunos_cobrancas` (via `currentPayments`).
|
### 2. Frontend (Manager)
|
||||||
- [x] **Parser Global de NUMERIC (Postgres):** Configurado o driver `pg` nos servidores do Manager e do Portal para converter automaticamente `NUMERIC`/`DECIMAL` (OID 1700) para `Number`, cumprindo as regras 19 e 32 do `GEMINI.md`.
|
- `LessonSchedule.tsx` (Cronograma): Refatorado para carregar as aulas ( `fetch('/api/aulas')`) em estado próprio (`dbLessons`), removendo a leitura estática de `data.lessons`. Todas as ações (gerar aulas, reagendar, cancelar e exclusão em lote) agora enviam chamadas à API antes de atualizar a UI e invocar o `saveData`.
|
||||||
- [x] **Resolução de Condição de Corrida:** Implementado `await` no update duplo de cobranças do SQL na edição de cobranças do Manager (`Finance.tsx`), assegurando consistência na re-busca de dados.
|
- `Contracts.tsx` (Contratos): Refatorado para utilizar estados locais (`dbContracts` e `dbTemplates`) carregados das novas rotas de API. Criações e exclusões realizam requests HTTP para persistir os dados nativamente no SQL, mantendo compatibilidade com o formato JSON da UI base.
|
||||||
- [x] **Ordem de Exclusão e Notificação (Regra 34):** Removida a exclusão local imediata no endpoint `/api/excluir_cobranca`. A remoção local é delegada ao webhook do Asaas, permitindo que a mensagem de WhatsApp acesse os dados necessários antes da deleção.
|
|
||||||
- [x] **Limpeza de Faltas no Reagendamento:** Ajustada a função `handleRescheduleLesson` no `LessonSchedule.tsx` para filtrar e deletar automaticamente do banco de dados/estado todas as faltas geradas de forma automática para uma aula que estava no passado e foi reagendada para o futuro.
|
|
||||||
- [x] **Correção das Presenças por Biometria:** Resolvido o bug onde as presenças por biometria apareciam como faltas. Corrigido o fuso horário de comparação do fim da aula no servidor (`processAutoAbsences`), adicionada a rotina de auto-limpeza de faltas duplicadas auto-geradas ao salvar ou confirmar presenças, priorizado a exibição de presenças sobre faltas nas interfaces (Manager e Portal), e garantida a gravação do ID da aula (`aula_id`) na sincronização relacional das frequências.
|
|
||||||
- [x] **Validação de Build e Git Push:** Confirmada compilação bem-sucedida do frontend/backend e efetuado o push para a branch remota `main` sob autorização do usuário.
|
|
||||||
|
|
||||||
## 📋 Próximos Passos
|
### 3. Portal do Aluno
|
||||||
- [ ] Módulo Financeiro SQL: Concluir as fases avançadas de migração (ex: extratos consolidados e conciliação em lote baseados 100% em queries SQL).
|
- **`GET /api/portal/aulas`**: Alterada a rota para fazer um `SELECT` direto na tabela `aulas`, cruzando os IDs de turma vinculados ao aluno (via tabela `alunos` e histórico em `frequencias`). Adicionado fallback para ler o JSON caso o banco retorne vazio (útil durante a janela de transição).
|
||||||
- [ ] Otimização de Build: Re-explorar o cache do Docker.
|
- **`GET /api/portal/contratos`**: Alterada a rota para fazer um `SELECT` na tabela `contratos` puxando pelos dados salvos, com fallback seguro para o JSON se necessário.
|
||||||
- [ ] Monitoramento: Validar a exibição das notas após o reinício dos containers.
|
|
||||||
|
|
||||||
## 📅 Histórico Anterior (06/05/2026 - 08/05/2026)
|
|
||||||
- [x] Estabilização de CI/CD: Transição para `runs-on: self-hosted` (ARM64 nativo) eliminando lentidão e crashes do QEMU.
|
|
||||||
- [x] Correção do Sino de Notificações: Botões sempre visíveis, suporte a anexo via chave `arquivo` e exibição do **Motivo da Falta** direto na lista do sino.
|
|
||||||
- [x] **Segurança Financeira:** Implementada trava de segurança (`isCreating`) contra cliques múltiplos em formulários financeiros, resolvendo a duplicidade de cobranças no Asaas.
|
|
||||||
- [x] **Boletim Detalhado (Manager):** Refatoração para suportar N avaliações por bimestre, com interface que diferencia Provas de Atividades.
|
|
||||||
- [x] **Ambiente de Provas (Portal):** Implementado modo imersivo com cronômetro pulsante (alerta vermelho < 1min) e etiquetas dinâmicas por tipo de avaliação.
|
|
||||||
- [x] **Envio em Massa V3:** Implementado suporte a anexos (PDF/Imagens), emojis e envio duplo (Aluno/Responsável).
|
|
||||||
- [x] **Resolução de Crash:** Removida a dependência `node-fetch` que causava erro 404 no deploy de produção.
|
|
||||||
- [x] **Financeiro Mobile:** Otimização da interface para melhor responsividade e compactação de botões.
|
|
||||||
- [x] **Boletim:** Implementada filtragem para exibir apenas avaliações com submissões ativas.
|
|
||||||
- [x] **Storage Explorer (MinIO):** Criada interface de gerenciamento de arquivos que permite navegar por buckets (pastas), visualizar (lightbox), baixar e excluir arquivos físicos individualmente.
|
|
||||||
- [x] **Database Data Viewer:** Implementada a visualização de registros (linhas) diretamente no Database Explorer, com suporte a redimensionamento automático de colunas e truncamento de dados longos.
|
|
||||||
- [x] **Controle de Refação (Retake Policy):** Adicionado botão de cadeado nos cards de Avaliações para permitir ou bloquear que alunos refaçam provas no portal (Regra 15).
|
|
||||||
- [x] **UI de Avaliações:** Padronização dos botões de edição ("Editar Prova" vs "Editar Atividade") e adição de botão de exclusão rápida direto no card.
|
|
||||||
- [x] **Correção de Vínculo de Notas:** Garantido que o `examId` seja sempre salvo nas notas geradas pelo Portal para preenchimento automático do Boletim Escolar no Manager.
|
|
||||||
- [x] **Fix Memory Leak:** Removido `pool.on('error')` que estava dentro da rota `PUT /api/school-data`, acumulando listeners a cada salvamento.
|
|
||||||
- [x] **Fix SyntaxError (Backticks):** Corrigido erro de sintaxe com backticks escapados na rota do Database Explorer que impedia o servidor de iniciar.
|
|
||||||
- [x] **Fix Static Serving Duplicado:** Consolidada a entrega de arquivos estáticos (dist) no `manager/server.selfhosted.js`, eliminando o erro 404 em produção.
|
|
||||||
- [x] **TypeScript Cleanup:** Corrigidos erros de tipo `unknown` nos `reduce()` do ReportCard.tsx e removida função órfã `closeModal` do Settings.tsx.
|
|
||||||
- [x] **Interface Grade Tipada:** Adicionado `examId?: string` à interface `Grade` em `types.ts`, eliminando casts `as any` inseguros.
|
|
||||||
- [ ] Próximo Passo: Iniciar testes de estresse no servidor self-hosted para submissão massiva de fotos de frequência.
|
|
||||||
|
|
||||||
### 💳 Módulo Financeiro (Portal do Aluno)
|
|
||||||
- **Funcionalidades Implementadas:**
|
|
||||||
- Cards de resumo (Total em Aberto, Pago, Parcelas).
|
|
||||||
- Listagem inteligente de pagamentos com labels dinâmicas (ex: "Parcela 1/3").
|
|
||||||
- Lógica de normalização de status: `pago`, `pendente`, `atrasado`, `cancelado`.
|
|
||||||
- Integração dupla para boletos: busca via ID do Asaas e fallback por data/valor no Supabase.
|
|
||||||
- Visualização de recibos via link externo ou modal de impressão local.
|
|
||||||
- **Onde paramos:** O sistema de filtros e ordenação está funcional, sincronizando com os parâmetros da URL.
|
|
||||||
|
|
||||||
### 📝 Módulo de Avaliações (Portal do Aluno)
|
|
||||||
- **Funcionalidades Implementadas:**
|
|
||||||
- Tela de realização de provas e atividades online com cronômetro e suporte a imagens de apoio (MinIO).
|
|
||||||
- **Autocorreção 100% Automática:** O backend do portal (`server.js`) recebe as respostas, compara com o gabarito (`correctOptionIndex`), calcula o percentual de acertos e a nota proporcional ao peso da prova (`finalScore`).
|
|
||||||
- **Lançamento Automático no Boletim:** A nota calculada é salva no PostgreSQL (`provas_submissoes`) e injetada instantaneamente na tabela de notas (`grades`) do `school_data`.
|
|
||||||
- Bloqueio inteligente contra dupla submissão da mesma prova.
|
|
||||||
|
|
||||||
### ⚙️ Módulo de Configurações e Infra (Manager)
|
|
||||||
- **Arquitetura de Armazenamento:** Implementada a transição para **Self-Hosted Storage (MinIO)**.
|
|
||||||
- Extração de Base64 concluída com sucesso via `migrate_images_to_minio.ts`.
|
|
||||||
- O banco de dados de produção (PostgreSQL Local) foi populado com sucesso absoluto na VPS através da rota `/api/migracao-remota` utilizando o script `injetar_magia.ts`.
|
|
||||||
- O sistema agora é 100% Self-Hosted (PostgreSQL e MinIO próprios), sem dependência da nuvem do Supabase.
|
|
||||||
- **Funcionalidades de Configuração:**
|
|
||||||
- Gestão multi-unidade (Alternância entre Matriz e Filiais).
|
|
||||||
- Validação de CNPJ e busca automática via CEP.
|
|
||||||
- Monitoramento de logs de API em tempo real.
|
|
||||||
- **Histórico de Estabilidade:**
|
|
||||||
- O sistema voltou para o último estado "verde" conhecido.
|
|
||||||
- **Refatoração de Uploads (Missão 2):**
|
|
||||||
- [x] **Foto do Aluno (Manager):** Migrado de Base64 para envio via `FormData` direto ao MinIO no componente `Students.tsx`.
|
|
||||||
- [x] **Logo da Escola (Settings):** Removido falback agressivo para base64 e isolado backend para upload exclusivo no bucket `logos`.
|
|
||||||
- [x] **Imagens de Avaliações (Exams):** Ajustado para utilizar Rota isolada `form-data` para salvar no bucket `exames` do MinIO.
|
|
||||||
- [x] **Atestados (Portal):** Refatorado portal (backend e view) para upload do arquivo binário e salvar a url pública no JSON associado.
|
|
||||||
- [x] **Frequência e Biometria (AttendanceQuery):** Corrigido bug de contagem, deduplicação de aulas e janela de 30 minutos para validação facial.
|
|
||||||
- [x] **Financeiro (Manager):** Migração total para API PostgreSQL local, eliminando o Supabase Sync que causava erros na aba financeira.
|
|
||||||
- [x] **Telemetria do Sistema (Settings):** Cards reais de monitoramento de disco (Postgres) e objetos (MinIO).
|
|
||||||
- [x] **Exploradores de Infraestrutura:** Implementado acesso via botões nos cards de monitoramento para abrir janelas modais de exploração profunda (Arquivos e Banco de Dados) com navegação fluida e lightbox.
|
|
||||||
|
|
||||||
### 🚀 Infraestrutura e Deploy
|
|
||||||
- **Estado Atual:** Pipeline 100% estabilizado no GitHub Actions usando `self-hosted` runner (Oracle ARM64 nativo).
|
|
||||||
- **Melhoria:** O build agora ocorre diretamente na arquitetura de destino, sem emulação QEMU, garantindo velocidade e estabilidade total.
|
|
||||||
|
|
||||||
### 📅 08/05/2026 - Estabilização Crítica de Automação e Sincronia Financeira
|
|
||||||
- **Fonte de Disparos:** A rotina de cobranças agora utiliza o `school_data.payments` (JSON) como fonte primária, mas com **Sincronização Reversa** ativa (SQL -> JSON).
|
|
||||||
- **Sincronização Bidirecional (Webhook):** O webhook do Asaas atualiza o SQL e o JSON em tempo real.
|
|
||||||
- **Resolução de Fantasmas:** Refinada a lógica de sincronia no Financeiro para atualizar apenas mudanças de status reais, eliminando o aviso persistente de "96 atualizações".
|
|
||||||
- **Inteligência de Mensagens:** O sistema agora garante que lembretes preventivos NUNCA sejam enviados para boletos já pagos no SQL/JSON.
|
|
||||||
- **Integridade Numérica:** Casting explícito para `Number()` em todo o fluxo financeiro.
|
|
||||||
|
|
||||||
### 📢 Automação de Mensagens
|
|
||||||
- [x] **Estabilização de Lembretes (V2):** Resolvido bug de deslocamento de fuso horário (-1 dia) nas datas de vencimento. Implementadas ferramentas de debug (ignorar trava diária e reset de contadores) e **controle manual de delay para disparos em massa** para facilitar testes e garantir segurança contra banimento.
|
|
||||||
- [x] **Filtragem Inteligente de Boletim:** Refatorada a aba de Boletim no Manager para exibir provas e atividades apenas quando houver submissão do aluno, eliminando a poluição visual de outras turmas.
|
|
||||||
- [x] **Otimização Financeira (Mobile):** Cabeçalho da aba Financeiro redesenhado para ser 100% responsivo, com botões compactos e ícones inteligentes que economizam espaço em dispositivos móveis.
|
|
||||||
- [x] **Disparo em Massa V3:** Implementada lógica de primeiro nome (`.split(' ')[0]`), envio simultâneo para Aluno e Responsável (quando contatos forem diferentes), painel de 25 emojis temáticos e suporte a anexos (Imagem/PDF) via Evolution API.
|
|
||||||
- [x] **Mensagens de Aniversário Automáticas:** Implementada rotina de agendamento automático diário de felicitações de aniversário via cron job (`server.selfhosted.js`) integrada ao painel de configurações na interface (`Messages.tsx`). O envio é feito apenas ao telefone cadastrado do aluno (s.phone), ignorando os responsáveis e pulando alunos sem número registrado.
|
|
||||||
|
|
||||||
## 📋 Próximos Passos Pendentes
|
|
||||||
|
|
||||||
1. **Módulo Financeiro SQL:** Iniciar a migração total do financeiro para PostgreSQL (padrão `notas_boletim`).
|
|
||||||
2. **Otimização de Build:** Re-explorar o cache do Docker.
|
|
||||||
3. **Financeiro:** Implementar visualização de extrato detalhado.
|
|
||||||
|
|
||||||
**Nota Técnica:** O arquivo `server.js` é OBSOLETO e mantido apenas para contexto histórico. **NUNCA DEVE SER EDITADO OU ALTERADO**. Todo o foco de backend deve ser estritamente no `server.selfhosted.js`.
|
|
||||||
|
|
||||||
**Acesso ao Banco de Dados (MCP):** Configurado e testado o acesso MCP ao PostgreSQL da VPS (`150.230.87.131`) através do `mcp_config.json`. A porta `5432` foi liberada com sucesso nas Security Lists do console da Oracle Cloud e no firewall interno (`ufw`). A conexão está totalmente operacional e as tabelas/volumetria de linhas foram documentadas.
|
|
||||||
|
|
||||||
|
## Impacto
|
||||||
|
O Portal do Aluno agora opera primordialmente com PostgreSQL para a maior parte de sua leitura, incluindo alunos, provas, aulas, frequências e contratos. O painel administrativo foi fortalecido, registrando operações nos dois bancos simultaneamente para evitar a temida "Tela Branca" durante leituras em cascata no dashboard antigo.
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,28 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
|
||||||
}).catch(console.error);
|
}).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'),
|
||||||
|
fetch('/api/modelos-contrato')
|
||||||
|
]);
|
||||||
|
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 [activeTab, setActiveTab] = useState<'contracts' | 'templates'>('contracts');
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
|
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
|
||||||
|
|
@ -72,7 +94,7 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
|
||||||
const student = data.students.find(s => s.id === formData.studentId);
|
const student = data.students.find(s => s.id === formData.studentId);
|
||||||
const cls = dbClasses.find(c => c.id === student?.classId);
|
const cls = dbClasses.find(c => c.id === student?.classId);
|
||||||
const course = dbCourses.find(c => c.id === cls?.courseId);
|
const course = dbCourses.find(c => c.id === cls?.courseId);
|
||||||
const templateObj = data.contractTemplates?.find(t => t.id === student?.contractTemplateId);
|
const templateObj = dbTemplates?.find(t => t.id === student?.contractTemplateId);
|
||||||
|
|
||||||
if (student && course) {
|
if (student && course) {
|
||||||
let template = templateObj?.content || '';
|
let template = templateObj?.content || '';
|
||||||
|
|
@ -118,13 +140,13 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
|
||||||
}
|
}
|
||||||
}, [formData.studentId, data]);
|
}, [formData.studentId, data]);
|
||||||
|
|
||||||
const filteredContracts = data.contracts.filter(c => {
|
const filteredContracts = dbContracts.filter(c => {
|
||||||
const student = data.students.find(s => s.id === c.studentId);
|
const student = data.students.find(s => s.id === c.studentId);
|
||||||
const search = (searchTerm || '').toLowerCase();
|
const search = (searchTerm || '').toLowerCase();
|
||||||
return (c.title || '').toLowerCase().includes(search) || (student?.name || '').toLowerCase().includes(search);
|
return (c.title || '').toLowerCase().includes(search) || (student?.name || '').toLowerCase().includes(search);
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredTemplates = (data.contractTemplates || []).filter(t =>
|
const filteredTemplates = dbTemplates.filter(t =>
|
||||||
(t.name || '').toLowerCase().includes((searchTerm || '').toLowerCase())
|
(t.name || '').toLowerCase().includes((searchTerm || '').toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -141,7 +163,7 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
updateData({ contracts: [...data.contracts, newContract] });
|
updateData({ contracts: [...dbContracts, newContract] });
|
||||||
closeModal();
|
closeModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -152,7 +174,7 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const templates = data.contractTemplates || [];
|
const templates = dbTemplates || [];
|
||||||
let updatedTemplates;
|
let updatedTemplates;
|
||||||
|
|
||||||
if (templateFormData.id) {
|
if (templateFormData.id) {
|
||||||
|
|
@ -161,6 +183,19 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
|
||||||
updatedTemplates = [...templates, { ...templateFormData, id: crypto.randomUUID() }];
|
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(() => 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());
|
||||||
|
}
|
||||||
updateData({ contractTemplates: updatedTemplates });
|
updateData({ contractTemplates: updatedTemplates });
|
||||||
closeTemplateModal();
|
closeTemplateModal();
|
||||||
};
|
};
|
||||||
|
|
@ -190,7 +225,7 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
|
||||||
'Excluir Contrato',
|
'Excluir Contrato',
|
||||||
'Tem certeza que deseja excluir este contrato?',
|
'Tem certeza que deseja excluir este contrato?',
|
||||||
() => {
|
() => {
|
||||||
updateData({ contracts: data.contracts.filter(c => c.id !== id) });
|
updateData({ contracts: dbContracts.filter(c => c.id !== id) });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -200,7 +235,7 @@ const Contracts: React.FC<ContractsProps> = ({ data, updateData }) => {
|
||||||
'Excluir Modelo',
|
'Excluir Modelo',
|
||||||
'Tem certeza que deseja excluir este modelo de contrato?',
|
'Tem certeza que deseja excluir este modelo de contrato?',
|
||||||
() => {
|
() => {
|
||||||
updateData({ contractTemplates: (data.contractTemplates || []).filter(t => t.id !== id) });
|
updateData({ contractTemplates: dbTemplates.filter(t => t.id !== id) });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,35 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
const [dbClasses, setDbClasses] = useState<any[]>(data?.classes || []);
|
const [dbClasses, setDbClasses] = useState<any[]>(data?.classes || []);
|
||||||
const [dbCourses, setDbCourses] = useState<any[]>(data?.courses || []);
|
const [dbCourses, setDbCourses] = useState<any[]>(data?.courses || []);
|
||||||
const [dbSubjects, setDbSubjects] = useState<any[]>(data?.subjects || []);
|
const [dbSubjects, setDbSubjects] = useState<any[]>(data?.subjects || []);
|
||||||
|
const [dbExams, setDbExams] = useState<Exam[]>(data.exams || []);
|
||||||
|
|
||||||
|
const loadExams = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/provas');
|
||||||
|
if (res.ok) {
|
||||||
|
const { provas } = await res.json();
|
||||||
|
setDbExams(provas.map((p: any) => ({
|
||||||
|
id: p.id,
|
||||||
|
classId: p.turma_id,
|
||||||
|
subjectId: p.disciplina_id,
|
||||||
|
periodId: p.periodo_id,
|
||||||
|
title: p.titulo,
|
||||||
|
durationMinutes: p.duracao_minutos,
|
||||||
|
status: p.status,
|
||||||
|
allowRetake: p.permitir_refacao,
|
||||||
|
isDeleted: p.is_deleted,
|
||||||
|
evaluationType: p.evaluation_type || 'exam',
|
||||||
|
questions: [] // questoes carregadas sob demanda
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadExams();
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
|
@ -65,7 +94,7 @@ const Exams: React.FC<ExamsProps> = ({ data, updateData }) => {
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
const exams = data.exams || [];
|
const exams = dbExams || [];
|
||||||
|
|
||||||
const filteredExams = exams.filter(exam =>
|
const filteredExams = exams.filter(exam =>
|
||||||
(activeTab === 'ativos' ? !exam.isDeleted : !!exam.isDeleted) &&
|
(activeTab === 'ativos' ? !exam.isDeleted : !!exam.isDeleted) &&
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,17 @@ interface LessonScheduleProps {
|
||||||
const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateData, onClose }) => {
|
const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateData, onClose }) => {
|
||||||
const { showAlert, showConfirm } = useDialog();
|
const { showAlert, showConfirm } = useDialog();
|
||||||
|
|
||||||
|
const [dbLessons, setDbLessons] = useState<Lesson[]>([]);
|
||||||
|
const loadLessons = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/aulas?turma_id=${classObj.id}`);
|
||||||
|
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 [dbClasses, setDbClasses] = useState<any[]>(data?.classes || []);
|
||||||
const [dbCourses, setDbCourses] = useState<any[]>(data?.courses || []);
|
const [dbCourses, setDbCourses] = useState<any[]>(data?.courses || []);
|
||||||
|
|
||||||
|
|
@ -73,7 +84,7 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
|
||||||
const [replacementEndTime, setReplacementEndTime] = useState('');
|
const [replacementEndTime, setReplacementEndTime] = useState('');
|
||||||
|
|
||||||
const checkCollision = (date: string, start: string, end: string, ignoreLessonId?: string) => {
|
const checkCollision = (date: string, start: string, end: string, ignoreLessonId?: string) => {
|
||||||
return (data.lessons || []).find(l => {
|
return dbLessons.find(l => {
|
||||||
// Ignore if it's the lesson being replaced (if any) or if it's cancelled
|
// 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.id === ignoreLessonId || l.status === 'cancelled') return false;
|
||||||
if (l.date !== date) return false;
|
if (l.date !== date) return false;
|
||||||
|
|
@ -91,7 +102,7 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const classLessons = (data.lessons || [])
|
const classLessons = dbLessons
|
||||||
.filter(l => l.classId === classObj.id)
|
.filter(l => l.classId === classObj.id)
|
||||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||||
|
|
||||||
|
|
@ -149,6 +160,13 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Salvar no Banco
|
||||||
|
fetch('/api/aulas/lote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ aulas: newLessons })
|
||||||
|
}).then(() => loadLessons());
|
||||||
|
|
||||||
const updatedLessons = [...(data.lessons || []), ...newLessons];
|
const updatedLessons = [...(data.lessons || []), ...newLessons];
|
||||||
|
|
||||||
// Notificar alunos sobre novas aulas extras geradas
|
// Notificar alunos sobre novas aulas extras geradas
|
||||||
|
|
@ -268,7 +286,7 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
|
||||||
|
|
||||||
setIsClosing(true);
|
setIsClosing(true);
|
||||||
|
|
||||||
const updatedLessons: Lesson[] = (data.lessons || []).map(l =>
|
const updatedLessons: Lesson[] = dbLessons.map(l =>
|
||||||
l.id === lesson.id ? { ...l, status: 'cancelled', cancelReason } : l
|
l.id === lesson.id ? { ...l, status: 'cancelled', cancelReason } : l
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -294,8 +312,13 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
|
||||||
const newNotifs = notifyLessonAction('Aula Cancelada', notifMsg, waMsg);
|
const newNotifs = notifyLessonAction('Aula Cancelada', notifMsg, waMsg);
|
||||||
const updatedNotifications = [...(data.notifications || []), ...newNotifs];
|
const updatedNotifications = [...(data.notifications || []), ...newNotifs];
|
||||||
|
|
||||||
updateData({ lessons: updatedLessons, notifications: updatedNotifications });
|
fetch('/api/aulas/lote', {
|
||||||
await dbService.saveData({ ...data, lessons: updatedLessons, notifications: updatedNotifications });
|
method: 'POST',
|
||||||
|
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 });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -316,8 +339,13 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
|
||||||
const updatedLessons: Lesson[] = (data.lessons || []).map(l =>
|
const updatedLessons: Lesson[] = (data.lessons || []).map(l =>
|
||||||
l.id === lesson.id ? { ...l, status: 'scheduled', cancelReason: undefined } : l
|
l.id === lesson.id ? { ...l, status: 'scheduled', cancelReason: undefined } : l
|
||||||
);
|
);
|
||||||
updateData({ lessons: updatedLessons });
|
fetch('/api/aulas/lote', {
|
||||||
await dbService.saveData({ ...data, lessons: updatedLessons });
|
method: 'POST',
|
||||||
|
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(() => {
|
setTimeout(() => {
|
||||||
setShowLessonDetail(null);
|
setShowLessonDetail(null);
|
||||||
|
|
@ -408,9 +436,20 @@ const LessonSchedule: React.FC<LessonScheduleProps> = ({ classObj, data, updateD
|
||||||
|
|
||||||
const handleDeleteAllSchedule = () => {
|
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 () => {
|
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);
|
const updatedLessons = (data.lessons || []).filter(l => l.classId !== classObj.id);
|
||||||
updateData({ lessons: updatedLessons });
|
updateData({ lessons: updatedLessons });
|
||||||
await dbService.saveData({ ...data, lessons: updatedLessons });
|
await dbService.saveData({ ...data, lessons: updatedLessons });
|
||||||
|
await loadLessons();
|
||||||
showAlert('Sucesso', 'Cronograma completo excluído.', 'success');
|
showAlert('Sucesso', 'Cronograma completo excluído.', 'success');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,20 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
||||||
const [selectedClassId, setSelectedClassId] = useState<string | null>(null);
|
const [selectedClassId, setSelectedClassId] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const [dbStudents, setDbStudents] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const loadStudents = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/alunos');
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
setDbStudents(json.alunos || []);
|
||||||
|
}
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadStudents(); }, []);
|
||||||
|
|
||||||
const [dbClasses, setDbClasses] = useState<any[]>(dbClasses || []);
|
const [dbClasses, setDbClasses] = useState<any[]>(dbClasses || []);
|
||||||
const [dbCourses, setDbCourses] = useState<any[]>(dbCourses || []);
|
const [dbCourses, setDbCourses] = useState<any[]>(dbCourses || []);
|
||||||
|
|
||||||
|
|
@ -114,7 +128,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
||||||
if (clearDeepLink) clearDeepLink();
|
if (clearDeepLink) clearDeepLink();
|
||||||
}
|
}
|
||||||
if (deepLinkStudentId) {
|
if (deepLinkStudentId) {
|
||||||
const student = data.students.find(s => s.id === deepLinkStudentId);
|
const student = dbStudents.find(s => s.id === deepLinkStudentId);
|
||||||
if (student) {
|
if (student) {
|
||||||
setSearchTerm(student.name);
|
setSearchTerm(student.name);
|
||||||
if (student.status === 'cancelled') setActiveTab('cancelled');
|
if (student.status === 'cancelled') setActiveTab('cancelled');
|
||||||
|
|
@ -785,7 +799,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
||||||
let enrollmentNumber = formData.enrollmentNumber || editingStudent?.enrollmentNumber;
|
let enrollmentNumber = formData.enrollmentNumber || editingStudent?.enrollmentNumber;
|
||||||
if (!enrollmentNumber) {
|
if (!enrollmentNumber) {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const existingCount = data.students.filter(s => s.enrollmentNumber?.startsWith(`MAT-${year}`)).length;
|
const existingCount = dbStudents.filter(s => s.enrollmentNumber?.startsWith(`MAT-${year}`)).length;
|
||||||
enrollmentNumber = `MAT-${year}${String(existingCount + 1).padStart(5, '0')}`;
|
enrollmentNumber = `MAT-${year}${String(existingCount + 1).padStart(5, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -831,7 +845,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingStudent) {
|
if (editingStudent) {
|
||||||
updatedStudents = data.students.map(s =>
|
updatedStudents = dbStudents.map(s =>
|
||||||
s.id === editingStudent.id ? studentToSave : s
|
s.id === editingStudent.id ? studentToSave : s
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -978,7 +992,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedStudents = data.students.map(s =>
|
const updatedStudents = dbStudents.map(s =>
|
||||||
s.id === showDeleteModal.id ? { ...s, status: 'cancelled' as const, cancellationReason, classId: '' } : s
|
s.id === showDeleteModal.id ? { ...s, status: 'cancelled' as const, cancellationReason, classId: '' } : s
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1011,7 +1025,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atualiza o estado local
|
// Atualiza o estado local
|
||||||
const updatedStudents = data.students.map(s =>
|
const updatedStudents = dbStudents.map(s =>
|
||||||
s.id === student.id ? { ...s, status: 'active' as const, cancellationReason: undefined } : s
|
s.id === student.id ? { ...s, status: 'active' as const, cancellationReason: undefined } : s
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1032,7 +1046,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
||||||
'Excluir Aluno Definitivamente',
|
'Excluir Aluno Definitivamente',
|
||||||
`⚠️ Atenção: Esta ação irá remover permanentemente ${student.name} e todo o seu histórico (mensalidades, contratos e presenças). Esta ação NÃO pode ser desfeita. Deseja continuar?`,
|
`⚠️ Atenção: Esta ação irá remover permanentemente ${student.name} e todo o seu histórico (mensalidades, contratos e presenças). Esta ação NÃO pode ser desfeita. Deseja continuar?`,
|
||||||
async () => {
|
async () => {
|
||||||
const updatedStudents = data.students.filter(s => s.id !== student.id);
|
const updatedStudents = dbStudents.filter(s => s.id !== student.id);
|
||||||
const updatedPayments = data.payments.filter(p => p.studentId !== student.id);
|
const updatedPayments = data.payments.filter(p => p.studentId !== student.id);
|
||||||
const updatedContracts = data.contracts.filter(c => c.studentId !== student.id);
|
const updatedContracts = data.contracts.filter(c => c.studentId !== student.id);
|
||||||
const updatedAttendance = data.attendance?.filter(a => a.studentId !== student.id) || [];
|
const updatedAttendance = data.attendance?.filter(a => a.studentId !== student.id) || [];
|
||||||
|
|
@ -1056,7 +1070,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
||||||
const handleTransferStudent = () => {
|
const handleTransferStudent = () => {
|
||||||
if (!transferringStudent || !newClassId) return;
|
if (!transferringStudent || !newClassId) return;
|
||||||
|
|
||||||
const updatedStudents = data.students.map(s =>
|
const updatedStudents = dbStudents.map(s =>
|
||||||
s.id === transferringStudent.id ? { ...s, classId: newClassId } : s
|
s.id === transferringStudent.id ? { ...s, classId: newClassId } : s
|
||||||
);
|
);
|
||||||
updateData({ students: updatedStudents });
|
updateData({ students: updatedStudents });
|
||||||
|
|
@ -1195,7 +1209,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
||||||
{(activeTab === 'active' && !selectedClassId && !searchTerm) ? (
|
{(activeTab === 'active' && !selectedClassId && !searchTerm) ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{dbClasses.map(cls => {
|
{dbClasses.map(cls => {
|
||||||
const studentCount = data.students.filter(s => s.classId === cls.id && (activeTab === 'active' ? s.status !== 'cancelled' : s.status === 'cancelled')).length;
|
const studentCount = dbStudents.filter(s => s.classId === cls.id && (activeTab === 'active' ? s.status !== 'cancelled' : s.status === 'cancelled')).length;
|
||||||
const course = dbCourses.find(c => c.id === cls.courseId);
|
const course = dbCourses.find(c => c.id === cls.courseId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1239,7 +1253,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
|
||||||
<UserX size={24} />
|
<UserX size={24} />
|
||||||
</div>
|
</div>
|
||||||
<span className="bg-slate-100 text-slate-600 px-3 py-1 rounded-full text-xs font-bold">
|
<span className="bg-slate-100 text-slate-600 px-3 py-1 rounded-full text-xs font-bold">
|
||||||
{data.students.filter(s => !s.classId && (activeTab === 'active' ? s.status !== 'cancelled' : s.status === 'cancelled')).length} Alunos
|
{dbStudents.filter(s => !s.classId && (activeTab === 'active' ? s.status !== 'cancelled' : s.status === 'cancelled')).length} Alunos
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-lg font-black text-slate-800 mb-1">Sem Turma</h4>
|
<h4 className="text-lg font-black text-slate-800 mb-1">Sem Turma</h4>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { getSchoolData, syncJsonToRelationalTables } from '../services/database.js';
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log("Iniciando sync de tudo pro DB (Aulas/Frequencias incluídas)...");
|
||||||
|
await syncJsonToRelationalTables();
|
||||||
|
console.log("Pronto!");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
run();
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
const content = fs.readFileSync('manager/components/Students.tsx', 'utf8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].includes('const handle')) {
|
||||||
|
console.log(i, lines[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
const content = fs.readFileSync('manager/components/LessonSchedule.tsx', 'utf8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].includes('const handle')) {
|
||||||
|
console.log(i, lines[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
const content = fs.readFileSync('manager/components/Students.tsx', 'utf8');
|
||||||
|
|
||||||
|
const matchDelete = content.match(/const handleDelete.*?};/s);
|
||||||
|
if (matchDelete) console.log("--- DELETE ---", matchDelete[0]);
|
||||||
|
|
||||||
|
const matchSubmit = content.match(/const handleSubmit.*?};/s);
|
||||||
|
if (matchSubmit) console.log("--- SUBMIT ---", matchSubmit[0]);
|
||||||
|
|
||||||
|
const matchStatus = content.match(/const handleToggleStatus.*?};/s);
|
||||||
|
if (matchStatus) console.log("--- STATUS ---", matchStatus[0]);
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
const file = 'manager/services/database.js';
|
||||||
|
let content = fs.readFileSync(file, 'utf8');
|
||||||
|
|
||||||
|
const anchor = " await client.query('COMMIT');";
|
||||||
|
const inject = `
|
||||||
|
// --- SYNC AULAS ---
|
||||||
|
if (schoolData.lessons && schoolData.lessons.length > 0) {
|
||||||
|
for (const a of schoolData.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
|
||||||
|
turma_id = EXCLUDED.turma_id,
|
||||||
|
data = EXCLUDED.data,
|
||||||
|
horario_inicio = COALESCE(EXCLUDED.horario_inicio, aulas.horario_inicio),
|
||||||
|
horario_fim = COALESCE(EXCLUDED.horario_fim, aulas.horario_fim),
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
tipo = EXCLUDED.tipo,
|
||||||
|
motivo_cancelamento = EXCLUDED.motivo_cancelamento,
|
||||||
|
aula_original_id = COALESCE(EXCLUDED.aula_original_id, aulas.aula_original_id)\`,
|
||||||
|
[
|
||||||
|
a.id, a.classId, a.date, a.startTime || null, a.endTime || null, a.status || 'scheduled', a.type || 'regular',
|
||||||
|
a.cancelReason || null, a.originalLessonId || null
|
||||||
|
]
|
||||||
|
).catch(err => console.warn(\`[Sync:Aulas] Erro na aula \${a.id}:\`, err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SYNC FREQUENCIAS ---
|
||||||
|
if (schoolData.attendance && schoolData.attendance.length > 0) {
|
||||||
|
for (const f of schoolData.attendance) {
|
||||||
|
await client.query(
|
||||||
|
\`INSERT INTO frequencias (
|
||||||
|
id, aula_id, turma_id, aluno_id, tipo, data_registro, url_anexo, justificado
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
tipo = EXCLUDED.tipo,
|
||||||
|
url_anexo = COALESCE(EXCLUDED.url_anexo, frequencias.url_anexo),
|
||||||
|
justificado = EXCLUDED.justificado\`,
|
||||||
|
[
|
||||||
|
f.id, f.lessonId, f.classId, f.studentId, f.type, f.date, f.attachment || null, f.justified || false
|
||||||
|
]
|
||||||
|
).catch(err => console.warn(\`[Sync:Freq] Erro na freq \${f.id}:\`, err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` + "\n" + anchor;
|
||||||
|
|
||||||
|
if (content.includes(anchor) && !content.includes('SYNC AULAS')) {
|
||||||
|
content = content.replace(anchor, inject);
|
||||||
|
fs.writeFileSync(file, content, 'utf8');
|
||||||
|
console.log("Sync Aulas/Freq adicionado!");
|
||||||
|
} else {
|
||||||
|
console.log("Falhou na busca da âncora ou já existe.");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
const file = 'manager/services/database.js';
|
||||||
|
let content = fs.readFileSync(file, 'utf8');
|
||||||
|
|
||||||
|
const anchor = " // --- SYNC AULAS ---";
|
||||||
|
const inject = `
|
||||||
|
// --- SYNC MODELOS CONTRATO ---
|
||||||
|
if (schoolData.contractTemplates && schoolData.contractTemplates.length > 0) {
|
||||||
|
for (const t of schoolData.contractTemplates) {
|
||||||
|
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]
|
||||||
|
).catch(err => console.warn(\`[Sync:Modelos] Erro \${t.id}:\`, err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SYNC CONTRATOS ---
|
||||||
|
if (schoolData.contracts && schoolData.contracts.length > 0) {
|
||||||
|
for (const c of schoolData.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]
|
||||||
|
).catch(err => console.warn(\`[Sync:Contratos] Erro \${c.id}:\`, err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
` + "\n" + anchor;
|
||||||
|
|
||||||
|
if (content.includes(anchor) && !content.includes('SYNC MODELOS CONTRATO')) {
|
||||||
|
content = content.replace(anchor, inject);
|
||||||
|
fs.writeFileSync(file, content, 'utf8');
|
||||||
|
console.log("Sync Contratos adicionado!");
|
||||||
|
} else {
|
||||||
|
console.log("Falhou ou já existe.");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
const file = 'manager/components/Contracts.tsx';
|
||||||
|
let content = fs.readFileSync(file, 'utf8');
|
||||||
|
|
||||||
|
// 1. Injetar estados
|
||||||
|
if (!content.includes('dbContracts')) {
|
||||||
|
content = content.replace(
|
||||||
|
"const [activeTab, setActiveTab] = useState<'contracts' | 'templates'>('contracts');",
|
||||||
|
`const [dbContracts, setDbContracts] = useState<Contract[]>([]);
|
||||||
|
const [dbTemplates, setDbTemplates] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [resC, resT] = await Promise.all([
|
||||||
|
fetch('/api/contratos'),
|
||||||
|
fetch('/api/modelos-contrato')
|
||||||
|
]);
|
||||||
|
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');`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Mudar filteredContracts e filteredTemplates para usar as vars do BD
|
||||||
|
content = content.replace(/data\.contracts\.filter/g, 'dbContracts.filter');
|
||||||
|
content = content.replace(/data\.contracts/g, 'dbContracts');
|
||||||
|
content = content.replace(/\(data\.contractTemplates \|\| \[\]\)/g, 'dbTemplates');
|
||||||
|
content = content.replace(/data\.contractTemplates/g, 'dbTemplates');
|
||||||
|
|
||||||
|
// 3. Modificar handleSubmit (Criar contrato)
|
||||||
|
content = content.replace(
|
||||||
|
"updateData({ contracts: [...data.contracts, newContract] });\n closeModal();",
|
||||||
|
`fetch('/api/contratos', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newContract)
|
||||||
|
}).then(() => loadData());
|
||||||
|
updateData({ contracts: [...dbContracts, newContract] });
|
||||||
|
closeModal();`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Modificar handleTemplateSubmit
|
||||||
|
content = content.replace(
|
||||||
|
"updateData({ contractTemplates: updatedTemplates });\n closeTemplateModal();",
|
||||||
|
`if (templateFormData.id) {
|
||||||
|
fetch('/api/modelos-contrato/' + templateFormData.id, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(templateFormData)
|
||||||
|
}).then(() => 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());
|
||||||
|
}
|
||||||
|
updateData({ contractTemplates: updatedTemplates });
|
||||||
|
closeTemplateModal();`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Modificar handleDelete e handleDeleteTemplate
|
||||||
|
content = content.replace(
|
||||||
|
"updateData({ contracts: data.contracts.filter(c => c.id !== id) });",
|
||||||
|
`fetch('/api/contratos/' + id, { method: 'DELETE' }).then(() => loadData());
|
||||||
|
updateData({ contracts: dbContracts.filter(c => c.id !== id) });`
|
||||||
|
);
|
||||||
|
|
||||||
|
content = content.replace(
|
||||||
|
"updateData({ contractTemplates: (data.contractTemplates || []).filter(t => t.id !== id) });",
|
||||||
|
`fetch('/api/modelos-contrato/' + id, { method: 'DELETE' }).then(() => loadData());
|
||||||
|
updateData({ contractTemplates: dbTemplates.filter(t => t.id !== id) });`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(file, content, 'utf8');
|
||||||
|
console.log('Script aplicado ao Contracts.tsx');
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const filePath = path.join(__dirname, '../components/Exams.tsx');
|
||||||
|
let content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
// 1. Injetar estado
|
||||||
|
const stateHook = ` const [dbSubjects, setDbSubjects] = useState<any[]>(data?.subjects || []);`;
|
||||||
|
const newState = ` const [dbSubjects, setDbSubjects] = useState<any[]>(data?.subjects || []);
|
||||||
|
const [dbExams, setDbExams] = useState<Exam[]>(data.exams || []);
|
||||||
|
|
||||||
|
const loadExams = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/provas');
|
||||||
|
if (res.ok) {
|
||||||
|
const { provas } = await res.json();
|
||||||
|
setDbExams(provas.map((p: any) => ({
|
||||||
|
id: p.id,
|
||||||
|
classId: p.turma_id,
|
||||||
|
subjectId: p.disciplina_id,
|
||||||
|
periodId: p.periodo_id,
|
||||||
|
title: p.titulo,
|
||||||
|
durationMinutes: p.duracao_minutos,
|
||||||
|
status: p.status,
|
||||||
|
allowRetake: p.permitir_refacao,
|
||||||
|
isDeleted: p.is_deleted,
|
||||||
|
evaluationType: p.evaluation_type || 'exam',
|
||||||
|
questions: [] // questoes carregadas sob demanda
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadExams();
|
||||||
|
}, []);`;
|
||||||
|
content = content.replace(stateHook, newState);
|
||||||
|
|
||||||
|
// 2. Substituir \`const exams = data.exams || [];\`
|
||||||
|
content = content.replace('const exams = data.exams || [];', 'const exams = dbExams || [];');
|
||||||
|
|
||||||
|
// 3. Substituir \`handleEditExam\`
|
||||||
|
const oldEdit = ` const handleEditExam = (exam: Exam) => {
|
||||||
|
setEditingExam({ ...exam });
|
||||||
|
setCurrentView('builder');
|
||||||
|
};`;
|
||||||
|
const newEdit = ` const handleEditExam = async (exam: Exam) => {
|
||||||
|
try {
|
||||||
|
showAlert('Carregando...', 'Buscando questões da avaliação.', 'info');
|
||||||
|
const res = await fetch(\`/api/provas/\${exam.id}/questoes\`);
|
||||||
|
if (res.ok) {
|
||||||
|
const { questoes } = await res.json();
|
||||||
|
const mappedQuestions = (questoes || []).map((q: any) => ({
|
||||||
|
id: q.id,
|
||||||
|
text: q.texto,
|
||||||
|
options: typeof q.opcoes === 'string' ? JSON.parse(q.opcoes) : q.opcoes,
|
||||||
|
correctOptionIndex: q.indice_correto,
|
||||||
|
imageUrl: q.imagem_url
|
||||||
|
}));
|
||||||
|
setEditingExam({ ...exam, questions: mappedQuestions });
|
||||||
|
setCurrentView('builder');
|
||||||
|
} else {
|
||||||
|
showAlert('Erro', 'Não foi possível carregar as questões desta prova.', 'error');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
showAlert('Erro', 'Erro de conexão.', 'error');
|
||||||
|
}
|
||||||
|
};`;
|
||||||
|
content = content.replace(oldEdit, newEdit);
|
||||||
|
|
||||||
|
// 4. Substituir funções de save/delete
|
||||||
|
content = content.replace(
|
||||||
|
` 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 exam = exams.find(e => e.id === examId);
|
||||||
|
if(!exam) return;
|
||||||
|
try {
|
||||||
|
const updated = { ...exam, allowRetake: !exam.allowRetake };
|
||||||
|
await fetch(\`/api/provas/\${examId}\`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updated)
|
||||||
|
});
|
||||||
|
await loadExams();
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
};`
|
||||||
|
);
|
||||||
|
|
||||||
|
content = content.replace(
|
||||||
|
` 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.',
|
||||||
|
() => {
|
||||||
|
const updatedExams = exams.map(e => e.id === examId ? { ...e, isDeleted: true } : e);
|
||||||
|
updateData({ exams: updatedExams });
|
||||||
|
dbService.saveData({ ...data, exams: updatedExams });
|
||||||
|
showAlert('Sucesso', 'Avaliação movida para a lixeira.', 'success');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};`,
|
||||||
|
` 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 () => {
|
||||||
|
const exam = exams.find(e => e.id === examId);
|
||||||
|
if(!exam) return;
|
||||||
|
try {
|
||||||
|
await fetch(\`/api/provas/\${examId}\`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...exam, isDeleted: true })
|
||||||
|
});
|
||||||
|
await loadExams();
|
||||||
|
showAlert('Sucesso', 'Avaliação movida para a lixeira.', 'success');
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};`
|
||||||
|
);
|
||||||
|
|
||||||
|
content = content.replace(
|
||||||
|
` const handleRestoreExam = (examId: string) => {
|
||||||
|
const updatedExams = exams.map(e => e.id === examId ? { ...e, isDeleted: false } : e);
|
||||||
|
updateData({ exams: updatedExams });
|
||||||
|
dbService.saveData({ ...data, exams: updatedExams });
|
||||||
|
showAlert('Sucesso', 'Avaliação reativada.', 'success');
|
||||||
|
};`,
|
||||||
|
` const handleRestoreExam = async (examId: string) => {
|
||||||
|
const exam = exams.find(e => e.id === examId);
|
||||||
|
if(!exam) return;
|
||||||
|
try {
|
||||||
|
await fetch(\`/api/provas/\${examId}\`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...exam, isDeleted: false })
|
||||||
|
});
|
||||||
|
await loadExams();
|
||||||
|
showAlert('Sucesso', 'Avaliação reativada.', 'success');
|
||||||
|
} catch(e) {}
|
||||||
|
};`
|
||||||
|
);
|
||||||
|
|
||||||
|
const oldSave = ` const handleSave = (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 currentExams = data.exams || [];
|
||||||
|
const existingIndex = currentExams.findIndex(e => e.id === finalExam.id);
|
||||||
|
|
||||||
|
let newExams;
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
newExams = [...currentExams];
|
||||||
|
newExams[existingIndex] = finalExam;
|
||||||
|
} else {
|
||||||
|
newExams = [...currentExams, finalExam];
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData({ exams: newExams });
|
||||||
|
setCurrentView('list');
|
||||||
|
setEditingExam(null);
|
||||||
|
};`;
|
||||||
|
const newSave = ` 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 = !exams.find(e => e.id === finalExam.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
showAlert('Aguarde', 'Salvando avaliação...', 'info');
|
||||||
|
// 1. Salva prova
|
||||||
|
await fetch(isNew ? '/api/provas' : \`/api/provas/\${finalExam.id}\`, {
|
||||||
|
method: isNew ? 'POST' : 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(finalExam)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Salva questoes
|
||||||
|
await fetch(\`/api/provas/\${finalExam.id}/questoes\`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ questoes: finalExam.questions })
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadExams();
|
||||||
|
setCurrentView('list');
|
||||||
|
setEditingExam(null);
|
||||||
|
showAlert('Sucesso', 'Avaliação salva com sucesso!', 'success');
|
||||||
|
} catch(e) {
|
||||||
|
showAlert('Erro', 'Falha ao salvar avaliação.', 'error');
|
||||||
|
}
|
||||||
|
};`;
|
||||||
|
content = content.replace(oldSave, newSave);
|
||||||
|
|
||||||
|
const oldDup = ` const handleDuplicateExam = () => {
|
||||||
|
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)\`
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedExams = [...exams, newExam];
|
||||||
|
updateData({ exams: updatedExams });
|
||||||
|
dbService.saveData({ ...data, exams: updatedExams });
|
||||||
|
|
||||||
|
setDuplicatingExam(null);
|
||||||
|
setTargetClassId('');
|
||||||
|
showAlert('Sucesso', 'Avaliação duplicada com sucesso!', 'success');
|
||||||
|
};`;
|
||||||
|
const newDup = ` const handleDuplicateExam = async () => {
|
||||||
|
if (!duplicatingExam || !targetClassId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Busca questoes originais
|
||||||
|
const res = await fetch(\`/api/provas/\${duplicatingExam.id}/questoes\`);
|
||||||
|
const { questoes } = await res.json();
|
||||||
|
|
||||||
|
const newExam: Exam = {
|
||||||
|
...duplicatingExam,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
classId: targetClassId,
|
||||||
|
status: 'draft',
|
||||||
|
title: \`\${duplicatingExam.title} (Cópia)\`
|
||||||
|
};
|
||||||
|
|
||||||
|
await fetch('/api/provas', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newExam)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Sincroniza as questões no novo id
|
||||||
|
await fetch(\`/api/provas/\${newExam.id}/questoes\`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ questoes: questoes || [] })
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadExams();
|
||||||
|
setDuplicatingExam(null);
|
||||||
|
setTargetClassId('');
|
||||||
|
showAlert('Sucesso', 'Avaliação duplicada com sucesso!', 'success');
|
||||||
|
} catch(e) {
|
||||||
|
showAlert('Erro', 'Falha ao duplicar prova.', 'error');
|
||||||
|
}
|
||||||
|
};`;
|
||||||
|
content = content.replace(oldDup, newDup);
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, content, 'utf8');
|
||||||
|
console.log('Exams.tsx atualizado com sucesso.');
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
const file = 'manager/components/LessonSchedule.tsx';
|
||||||
|
let content = fs.readFileSync(file, 'utf8');
|
||||||
|
|
||||||
|
// 1. Injetar estado dbLessons
|
||||||
|
if (!content.includes('dbLessons')) {
|
||||||
|
content = content.replace(
|
||||||
|
"const [dbClasses, setDbClasses] = useState<any[]>(data?.classes || []);",
|
||||||
|
`const [dbLessons, setDbLessons] = useState<Lesson[]>([]);
|
||||||
|
const loadLessons = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(\`/api/aulas?turma_id=\${classObj.id}\`);
|
||||||
|
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 || []);`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Substituir leituras locais:
|
||||||
|
// No componente LessonSchedule.tsx, "classLessons" lia de data.lessons.
|
||||||
|
// "const classLessons = (data.lessons || [])"
|
||||||
|
// vamos alterar para usar dbLessons.
|
||||||
|
content = content.replace(/const classLessons = \(data\.lessons \|\| \[\]\)/g, "const classLessons = dbLessons");
|
||||||
|
content = content.replace(/\(data\.lessons \|\| \[\]\)\.find/g, "dbLessons.find");
|
||||||
|
|
||||||
|
// 3. Substituir no Generate
|
||||||
|
content = content.replace(
|
||||||
|
"const updatedLessons = [...(data.lessons || []), ...newLessons];",
|
||||||
|
`// Salvar no Banco
|
||||||
|
fetch('/api/aulas/lote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ aulas: newLessons })
|
||||||
|
}).then(() => loadLessons());
|
||||||
|
|
||||||
|
const updatedLessons = [...(data.lessons || []), ...newLessons];`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Substituir nos Cancelamentos
|
||||||
|
content = content.replace(
|
||||||
|
"const updatedLessons: Lesson[] = (data.lessons || []).map(l =>",
|
||||||
|
`const updatedLessons: Lesson[] = dbLessons.map(l =>`
|
||||||
|
);
|
||||||
|
content = content.replace(
|
||||||
|
"updateData({ lessons: updatedLessons, notifications: updatedNotifications });\n await dbService.saveData({ ...data, lessons: updatedLessons, notifications: updatedNotifications });",
|
||||||
|
`fetch('/api/aulas/lote', {
|
||||||
|
method: 'POST',
|
||||||
|
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 });`
|
||||||
|
);
|
||||||
|
|
||||||
|
content = content.replace(
|
||||||
|
"updateData({ lessons: updatedLessons });\n await dbService.saveData({ ...data, lessons: updatedLessons });",
|
||||||
|
`fetch('/api/aulas/lote', {
|
||||||
|
method: 'POST',
|
||||||
|
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] });`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(file, content, 'utf8');
|
||||||
|
console.log('Script aplicado ao LessonSchedule.tsx');
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const filePath = 'manager/components/Students.tsx';
|
||||||
|
let content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
// 1. ADICIONAR ESTADO dbStudents E LOAD
|
||||||
|
const stateAnchor = " const [dbClasses, setDbClasses] = useState<any[]>(dbClasses || []);";
|
||||||
|
if(content.includes(stateAnchor) && !content.includes("const [dbStudents, setDbStudents]")) {
|
||||||
|
const injection = ` const [dbStudents, setDbStudents] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const loadStudents = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/alunos');
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
setDbStudents(json.alunos || []);
|
||||||
|
}
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadStudents(); }, []);
|
||||||
|
|
||||||
|
` + stateAnchor;
|
||||||
|
content = content.replace(stateAnchor, injection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. SUBSTITUIR data.students POR dbStudents EM LEITURAS (menos nas props)
|
||||||
|
// cuidado para nao substituir data.students.find no deepLink se nao carregar dbStudents antes
|
||||||
|
content = content.replace(/data\.students\.find/g, 'dbStudents.find');
|
||||||
|
content = content.replace(/data\.students\.filter/g, 'dbStudents.filter');
|
||||||
|
content = content.replace(/data\.students\.reduce/g, 'dbStudents.reduce');
|
||||||
|
content = content.replace(/data\.students\.length/g, 'dbStudents.length');
|
||||||
|
content = content.replace(/data\.students\.map/g, 'dbStudents.map');
|
||||||
|
|
||||||
|
// 3. ATUALIZAR handleSave PARA USAR API
|
||||||
|
const saveAnchor1 = ` if (editingStudent) {
|
||||||
|
updatedStudents = data.students.map(s =>
|
||||||
|
s.id === editingStudent.id ? studentToSave : s
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updatedStudents = [...data.students, studentToSave];
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const saveReplace1 = ` // Salvar no Backend via API (Fase 4)
|
||||||
|
try {
|
||||||
|
const isNew = !editingStudent;
|
||||||
|
const endpoint = isNew ? '/api/alunos' : \`/api/alunos/\${studentToSave.id}\`;
|
||||||
|
const method = isNew ? 'POST' : 'PUT';
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(studentToSave)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Erro ao salvar no backend');
|
||||||
|
|
||||||
|
await loadStudents(); // Recarrega lista
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
showAlert('Erro', 'Falha ao salvar aluno no banco de dados.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback retrocompatibilidade - remover depois que tudo for SQL
|
||||||
|
if (editingStudent) {
|
||||||
|
updatedStudents = data.students.map(s => s.id === editingStudent.id ? studentToSave : s);
|
||||||
|
} else {
|
||||||
|
updatedStudents = [...data.students, studentToSave];
|
||||||
|
}`;
|
||||||
|
content = content.replace(saveAnchor1, saveReplace1);
|
||||||
|
|
||||||
|
// 4. ATUALIZAR handleDelete PARA USAR API (linha 968)
|
||||||
|
const delAnchor1 = ` const handleDelete = (student: Student) => {
|
||||||
|
showConfirm(
|
||||||
|
'Mover para Inativos',
|
||||||
|
\`Tem certeza que deseja inativar o(a) aluno(a) \${student.name}?\`,
|
||||||
|
() => {
|
||||||
|
const updatedStudents = data.students.map(s =>
|
||||||
|
s.id === student.id ? { ...s, status: 'cancelled', cancellationReason: cancellationReason || 'Cancelado pelo administrador' } : s
|
||||||
|
);
|
||||||
|
updateData({ students: updatedStudents });
|
||||||
|
dbService.saveData({ ...data, students: updatedStudents });
|
||||||
|
closeDeleteModal();
|
||||||
|
showAlert('Sucesso', 'Aluno(a) inativado(a) com sucesso!', 'success');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};`;
|
||||||
|
|
||||||
|
const delReplace1 = ` const handleDelete = (student: Student) => {
|
||||||
|
showConfirm(
|
||||||
|
'Mover para Inativos',
|
||||||
|
\`Tem certeza que deseja inativar o(a) aluno(a) \${student.name}?\`,
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
// Atualiza status via PUT
|
||||||
|
const updated = { ...student, status: 'cancelled', cancellationReason: cancellationReason || 'Cancelado pelo administrador' };
|
||||||
|
await fetch(\`/api/alunos/\${student.id}\`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updated)
|
||||||
|
});
|
||||||
|
await loadStudents();
|
||||||
|
|
||||||
|
// Legacy update
|
||||||
|
const updatedStudents = data.students.map(s => s.id === student.id ? updated : s);
|
||||||
|
updateData({ students: updatedStudents });
|
||||||
|
dbService.saveData({ ...data, students: updatedStudents });
|
||||||
|
|
||||||
|
closeDeleteModal();
|
||||||
|
showAlert('Sucesso', 'Aluno(a) inativado(a) com sucesso!', 'success');
|
||||||
|
} catch(e) {
|
||||||
|
showAlert('Erro', 'Falha ao inativar aluno.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};`;
|
||||||
|
content = content.replace(delAnchor1, delReplace1);
|
||||||
|
|
||||||
|
// 5. ATUALIZAR handleDeletePermanently PARA USAR API (linha 1029)
|
||||||
|
const delPermAnchor = ` const handleDeletePermanently = (student: Student) => {
|
||||||
|
showConfirm(
|
||||||
|
'Exclusão Permanente',
|
||||||
|
\`Tem certeza que deseja excluir \${student.name} DEFINITIVAMENTE?\`,
|
||||||
|
() => {
|
||||||
|
const updatedStudents = data.students.filter(s => s.id !== student.id);
|
||||||
|
updateData({ students: updatedStudents });
|
||||||
|
dbService.saveData({ ...data, students: updatedStudents });
|
||||||
|
|
||||||
|
closeDeleteModal();
|
||||||
|
showAlert('Sucesso', 'Aluno(a) excluído(a) com sucesso!', 'success');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};`;
|
||||||
|
|
||||||
|
const delPermReplace = ` const handleDeletePermanently = (student: Student) => {
|
||||||
|
showConfirm(
|
||||||
|
'Exclusão Permanente',
|
||||||
|
\`Tem certeza que deseja excluir \${student.name} DEFINITIVAMENTE?\`,
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await fetch(\`/api/alunos/\${student.id}\`, { method: 'DELETE' });
|
||||||
|
await loadStudents();
|
||||||
|
|
||||||
|
const updatedStudents = data.students.filter(s => s.id !== student.id);
|
||||||
|
updateData({ students: updatedStudents });
|
||||||
|
dbService.saveData({ ...data, students: updatedStudents });
|
||||||
|
|
||||||
|
closeDeleteModal();
|
||||||
|
showAlert('Sucesso', 'Aluno(a) excluído(a) com sucesso!', 'success');
|
||||||
|
} catch(e) {
|
||||||
|
showAlert('Erro', 'Falha ao excluir permanentemente.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};`;
|
||||||
|
content = content.replace(delPermAnchor, delPermReplace);
|
||||||
|
|
||||||
|
// 6. ATUALIZAR handleRematricular PARA USAR API
|
||||||
|
const rematAnchor = ` const handleRematricular = async (student: Student) => {
|
||||||
|
showConfirm(
|
||||||
|
'Reativar Matrícula',
|
||||||
|
\`Tem certeza que deseja reativar a matrícula de \${student.name}?\`,
|
||||||
|
async () => {
|
||||||
|
const updatedStudents = data.students.map(s =>
|
||||||
|
s.id === student.id ? { ...s, status: 'active', cancellationReason: undefined } : s
|
||||||
|
);
|
||||||
|
updateData({ students: updatedStudents });
|
||||||
|
await dbService.saveData({ ...data, students: updatedStudents });
|
||||||
|
showAlert('Sucesso', 'Matrícula reativada com sucesso!', 'success');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};`;
|
||||||
|
const rematReplace = ` const handleRematricular = async (student: Student) => {
|
||||||
|
showConfirm(
|
||||||
|
'Reativar Matrícula',
|
||||||
|
\`Tem certeza que deseja reativar a matrícula de \${student.name}?\`,
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const updated = { ...student, status: 'active', cancellationReason: undefined };
|
||||||
|
await fetch(\`/api/alunos/\${student.id}\`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updated)
|
||||||
|
});
|
||||||
|
await loadStudents();
|
||||||
|
|
||||||
|
const updatedStudents = data.students.map(s => s.id === student.id ? updated : s);
|
||||||
|
updateData({ students: updatedStudents });
|
||||||
|
await dbService.saveData({ ...data, students: updatedStudents });
|
||||||
|
showAlert('Sucesso', 'Matrícula reativada com sucesso!', 'success');
|
||||||
|
} catch(e) {
|
||||||
|
showAlert('Erro', 'Erro ao reativar', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};`;
|
||||||
|
content = content.replace(rematAnchor, rematReplace);
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, content, 'utf8');
|
||||||
|
console.log('Students.tsx atualizado com sucesso.');
|
||||||
|
|
@ -38,7 +38,12 @@ import {
|
||||||
insertCategoriaFuncionario, updateCategoriaFuncionario, deleteCategoriaFuncionario,
|
insertCategoriaFuncionario, updateCategoriaFuncionario, deleteCategoriaFuncionario,
|
||||||
getCursos, insertCurso, updateCurso, deleteCurso,
|
getCursos, insertCurso, updateCurso, deleteCurso,
|
||||||
getTurmas, insertTurma, updateTurma, deleteTurma,
|
getTurmas, insertTurma, updateTurma, deleteTurma,
|
||||||
getDisciplinas, insertDisciplina, updateDisciplina, deleteDisciplina
|
getDisciplinas, insertDisciplina, updateDisciplina, deleteDisciplina,
|
||||||
|
getAlunos, insertAluno, updateAluno, deleteAluno,
|
||||||
|
getModelosContrato, insertModeloContrato, updateModeloContrato, deleteModeloContrato,
|
||||||
|
getContratos, insertContrato, deleteContrato,
|
||||||
|
getAulasByTurma, getAllAulas, insertAulas, deleteAulas,
|
||||||
|
getProvas, getQuestoesDaProva, insertProva, updateProva, deleteProva, syncQuestoesProva
|
||||||
} from './services/database.js';
|
} from './services/database.js';
|
||||||
import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, uploadReceipt as uploadReceiptToStorage, getMinioStats, s3Client, getBucketObjects, deleteMinioObject } from './services/storage.js';
|
import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, uploadReceipt as uploadReceiptToStorage, getMinioStats, s3Client, getBucketObjects, deleteMinioObject } from './services/storage.js';
|
||||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
@ -476,6 +481,175 @@ app.delete('/api/disciplinas/:id', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ROTAS DE ALUNOS (MIGRAÇÃO FASE 4)
|
||||||
|
// ============================================================
|
||||||
|
app.get('/api/alunos', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const alunos = await getAlunos();
|
||||||
|
res.json({ alunos });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar alunos:', error);
|
||||||
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/alunos', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await insertAluno(req.body);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao criar aluno:', error);
|
||||||
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/alunos/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await updateAluno(req.params.id, req.body);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar aluno:', error);
|
||||||
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/alunos/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await deleteAluno(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao deletar aluno:', error);
|
||||||
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ROTAS DE CONTRATOS E MODELOS
|
||||||
|
// ============================================================
|
||||||
|
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' }); }
|
||||||
|
});
|
||||||
|
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' }); }
|
||||||
|
});
|
||||||
|
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' }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
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' }); }
|
||||||
|
});
|
||||||
|
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' }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ROTAS DE AULAS E CRONOGRAMA
|
||||||
|
// ============================================================
|
||||||
|
app.get('/api/aulas', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { turma_id } = req.query;
|
||||||
|
const aulas = turma_id ? await getAulasByTurma(turma_id) : await getAllAulas();
|
||||||
|
res.json({ aulas });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar aulas:', error);
|
||||||
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/aulas/lote', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { aulas } = req.body;
|
||||||
|
await insertAulas(aulas);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao inserir aulas em lote:', error);
|
||||||
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/aulas/lote', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { ids } = req.body;
|
||||||
|
await deleteAulas(ids);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao deletar aulas em lote:', error);
|
||||||
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ROTAS DE AVALIAÇÕES (MIGRAÇÃO FASE 5)
|
||||||
|
// ============================================================
|
||||||
|
app.get('/api/provas', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const provas = await getProvas();
|
||||||
|
res.json({ provas });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar provas:', error);
|
||||||
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/provas/:id/questoes', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const questoes = await getQuestoesDaProva(req.params.id);
|
||||||
|
res.json({ questoes });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar questoes:', error);
|
||||||
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/provas', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await insertProva(req.body);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao criar prova:', error);
|
||||||
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/provas/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await updateProva(req.params.id, req.body);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar prova:', error);
|
||||||
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/provas/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await deleteProva(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao deletar prova:', error);
|
||||||
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/provas/:id/questoes', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { questoes } = req.body;
|
||||||
|
await syncQuestoesProva(req.params.id, questoes || []);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao sincronizar questoes:', error);
|
||||||
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// ROTAS DE TURMAS (MIGRAÇÃO FASE 3)
|
// ROTAS DE TURMAS (MIGRAÇÃO FASE 3)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,72 @@ export async function insertCobrancas(cobrancas) {
|
||||||
[c.aluno_id, c.asaas_customer_id, c.asaas_payment_id, c.asaas_installment_id || c.installment, c.installment, c.valor, c.vencimento, c.link_boleto, c.valor]
|
[c.aluno_id, c.asaas_customer_id, c.asaas_payment_id, c.asaas_installment_id || c.installment, c.installment, c.valor, c.vencimento, c.link_boleto, c.valor]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- SYNC MODELOS CONTRATO ---
|
||||||
|
if (schoolData.contractTemplates && schoolData.contractTemplates.length > 0) {
|
||||||
|
for (const t of schoolData.contractTemplates) {
|
||||||
|
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]
|
||||||
|
).catch(err => console.warn(`[Sync:Modelos] Erro ${t.id}:`, err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SYNC CONTRATOS ---
|
||||||
|
if (schoolData.contracts && schoolData.contracts.length > 0) {
|
||||||
|
for (const c of schoolData.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]
|
||||||
|
).catch(err => console.warn(`[Sync:Contratos] Erro ${c.id}:`, err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SYNC AULAS ---
|
||||||
|
if (schoolData.lessons && schoolData.lessons.length > 0) {
|
||||||
|
for (const a of schoolData.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
|
||||||
|
turma_id = EXCLUDED.turma_id,
|
||||||
|
data = EXCLUDED.data,
|
||||||
|
horario_inicio = COALESCE(EXCLUDED.horario_inicio, aulas.horario_inicio),
|
||||||
|
horario_fim = COALESCE(EXCLUDED.horario_fim, aulas.horario_fim),
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
tipo = EXCLUDED.tipo,
|
||||||
|
motivo_cancelamento = EXCLUDED.motivo_cancelamento,
|
||||||
|
aula_original_id = COALESCE(EXCLUDED.aula_original_id, aulas.aula_original_id)`,
|
||||||
|
[
|
||||||
|
a.id, a.classId, a.date, a.startTime || null, a.endTime || null, a.status || 'scheduled', a.type || 'regular',
|
||||||
|
a.cancelReason || null, a.originalLessonId || null
|
||||||
|
]
|
||||||
|
).catch(err => console.warn(`[Sync:Aulas] Erro na aula ${a.id}:`, err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SYNC FREQUENCIAS ---
|
||||||
|
if (schoolData.attendance && schoolData.attendance.length > 0) {
|
||||||
|
for (const f of schoolData.attendance) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO frequencias (
|
||||||
|
id, aula_id, turma_id, aluno_id, tipo, data_registro, url_anexo, justificado
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
tipo = EXCLUDED.tipo,
|
||||||
|
url_anexo = COALESCE(EXCLUDED.url_anexo, frequencias.url_anexo),
|
||||||
|
justificado = EXCLUDED.justificado`,
|
||||||
|
[
|
||||||
|
f.id, f.lessonId, f.classId, f.studentId, f.type, f.date, f.attachment || null, f.justified || false
|
||||||
|
]
|
||||||
|
).catch(err => console.warn(`[Sync:Freq] Erro na freq ${f.id}:`, err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
|
|
@ -538,6 +604,242 @@ export async function deleteCategoriaFuncionario(id) {
|
||||||
await pool.query('DELETE FROM categorias_funcionarios WHERE id = $1', [id]);
|
await pool.query('DELETE FROM categorias_funcionarios WHERE id = $1', [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ALUNOS (FASE 4)
|
||||||
|
// ============================================================
|
||||||
|
export async function getAlunos() {
|
||||||
|
const result = await pool.query("SELECT * FROM alunos ORDER BY nome ASC");
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertAluno(a) {
|
||||||
|
const result = await pool.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, data_matricula, foto_url, cep, rua, numero, bairro, cidade, estado,
|
||||||
|
desconto, tem_responsavel, modelo_contrato_id, numero_matricula, senha_portal,
|
||||||
|
motivo_cancelamento
|
||||||
|
) 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
|
||||||
|
) RETURNING *`,
|
||||||
|
[
|
||||||
|
a.id, a.nome || a.name, a.email || '', a.telefone || a.phone || '', a.data_nascimento || a.birthDate || null,
|
||||||
|
a.cpf || '', a.rg || '', a.rg_data_emissao || a.rgIssueDate || null,
|
||||||
|
a.nome_responsavel || a.guardianName || '', a.telefone_responsavel || a.guardianPhone || '',
|
||||||
|
a.cpf_responsavel || a.guardianCpf || '', a.data_nascimento_responsavel || a.guardianBirthDate || null,
|
||||||
|
a.turma_id || a.classId || null, a.status || 'active', a.data_matricula || a.registrationDate || null,
|
||||||
|
a.foto_url || a.photo || '', a.cep || a.addressZip || '', a.rua || a.addressStreet || '',
|
||||||
|
a.numero || a.addressNumber || '', a.bairro || a.addressNeighborhood || '', a.cidade || a.addressCity || '',
|
||||||
|
a.estado || a.addressState || '', a.desconto || a.discount || 0, a.tem_responsavel !== undefined ? a.tem_responsavel : (a.hasGuardian || false),
|
||||||
|
a.modelo_contrato_id || a.contractTemplateId || null, a.numero_matricula || a.enrollmentNumber || null,
|
||||||
|
a.senha_portal || a.portalPassword || null, a.motivo_cancelamento || a.cancellationReason || null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAluno(id, a) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE alunos SET
|
||||||
|
nome=$1, email=$2, telefone=$3, data_nascimento=$4, cpf=$5, rg=$6, rg_data_emissao=$7,
|
||||||
|
nome_responsavel=$8, telefone_responsavel=$9, cpf_responsavel=$10, data_nascimento_responsavel=$11,
|
||||||
|
turma_id=$12, status=$13, data_matricula=$14, foto_url=$15, cep=$16, rua=$17, numero=$18, bairro=$19, cidade=$20, estado=$21,
|
||||||
|
desconto=$22, tem_responsavel=$23, modelo_contrato_id=$24, numero_matricula=$25, senha_portal=$26,
|
||||||
|
motivo_cancelamento=$27
|
||||||
|
WHERE id = $28 RETURNING *`,
|
||||||
|
[
|
||||||
|
a.nome || a.name, a.email || '', a.telefone || a.phone || '', a.data_nascimento || a.birthDate || null,
|
||||||
|
a.cpf || '', a.rg || '', a.rg_data_emissao || a.rgIssueDate || null,
|
||||||
|
a.nome_responsavel || a.guardianName || '', a.telefone_responsavel || a.guardianPhone || '',
|
||||||
|
a.cpf_responsavel || a.guardianCpf || '', a.data_nascimento_responsavel || a.guardianBirthDate || null,
|
||||||
|
a.turma_id || a.classId || null, a.status || 'active', a.data_matricula || a.registrationDate || null,
|
||||||
|
a.foto_url || a.photo || '', a.cep || a.addressZip || '', a.rua || a.addressStreet || '',
|
||||||
|
a.numero || a.addressNumber || '', a.bairro || a.addressNeighborhood || '', a.cidade || a.addressCity || '',
|
||||||
|
a.estado || a.addressState || '', a.desconto || a.discount || 0, a.tem_responsavel !== undefined ? a.tem_responsavel : (a.hasGuardian || false),
|
||||||
|
a.modelo_contrato_id || a.contractTemplateId || null, a.numero_matricula || a.enrollmentNumber || null,
|
||||||
|
a.senha_portal || a.portalPassword || null, a.motivo_cancelamento || a.cancellationReason || null,
|
||||||
|
id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAluno(id) {
|
||||||
|
await pool.query('DELETE FROM alunos WHERE id = $1', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CONTRATOS E MODELOS
|
||||||
|
// ============================================================
|
||||||
|
export async function getModelosContrato() {
|
||||||
|
const { rows } = await pool.query('SELECT * FROM modelos_contrato ORDER BY nome ASC');
|
||||||
|
return rows.map(r => ({ id: r.id, name: r.nome, content: r.conteudo }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertModeloContrato(m) {
|
||||||
|
await pool.query('INSERT INTO modelos_contrato (id, nome, conteudo) VALUES ($1, $2, $3)', [m.id, m.name, m.content]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateModeloContrato(id, m) {
|
||||||
|
await pool.query('UPDATE modelos_contrato SET nome=$1, conteudo=$2 WHERE id=$3', [m.name, m.content, id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteModeloContrato(id) {
|
||||||
|
await pool.query('DELETE FROM modelos_contrato WHERE id=$1', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContratos() {
|
||||||
|
const { rows } = await pool.query('SELECT *, TO_CHAR(created_at, \'YYYY-MM-DD"T"HH24:MI:SS"Z"\') as date FROM contratos ORDER BY created_at DESC');
|
||||||
|
return rows.map(r => ({ id: r.id, studentId: r.aluno_id, title: r.titulo, content: r.conteudo, date: r.date }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertContrato(c) {
|
||||||
|
await pool.query('INSERT INTO contratos (id, aluno_id, titulo, conteudo) VALUES ($1, $2, $3, $4)', [c.id, c.studentId, c.title, c.content]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteContrato(id) {
|
||||||
|
await pool.query('DELETE FROM contratos WHERE id=$1', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// AULAS E CRONOGRAMA
|
||||||
|
// ============================================================
|
||||||
|
export async function getAulasByTurma(turma_id) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT *, TO_CHAR(data, 'YYYY-MM-DD') as data_formatada FROM aulas WHERE turma_id = $1 ORDER BY data ASC, horario_inicio ASC`,
|
||||||
|
[turma_id]
|
||||||
|
);
|
||||||
|
return result.rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
classId: row.turma_id,
|
||||||
|
date: row.data_formatada,
|
||||||
|
startTime: row.horario_inicio,
|
||||||
|
endTime: row.horario_fim,
|
||||||
|
status: row.status,
|
||||||
|
type: row.tipo,
|
||||||
|
cancellationReason: row.motivo_cancelamento,
|
||||||
|
originalLessonId: row.aula_original_id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllAulas() {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT *, TO_CHAR(data, 'YYYY-MM-DD') as data_formatada FROM aulas ORDER BY data ASC, horario_inicio ASC`
|
||||||
|
);
|
||||||
|
return result.rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
classId: row.turma_id,
|
||||||
|
date: row.data_formatada,
|
||||||
|
startTime: row.horario_inicio,
|
||||||
|
endTime: row.horario_fim,
|
||||||
|
status: row.status,
|
||||||
|
type: row.tipo,
|
||||||
|
cancellationReason: row.motivo_cancelamento,
|
||||||
|
originalLessonId: row.aula_original_id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertAulas(aulas) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
for (const a of aulas) {
|
||||||
|
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
|
||||||
|
turma_id=$2, data=$3, horario_inicio=$4, horario_fim=$5, status=$6, tipo=$7, motivo_cancelamento=$8, aula_original_id=$9`,
|
||||||
|
[
|
||||||
|
a.id, a.classId, a.date, a.startTime, a.endTime, a.status || 'scheduled', a.type || 'regular',
|
||||||
|
a.cancellationReason || null, a.originalLessonId || null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAulas(ids) {
|
||||||
|
if (!ids || ids.length === 0) return;
|
||||||
|
await pool.query('DELETE FROM aulas WHERE id = ANY($1)', [ids]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PROVAS & QUESTÕES (FASE 5)
|
||||||
|
// ============================================================
|
||||||
|
export async function getProvas() {
|
||||||
|
const result = await pool.query('SELECT * FROM provas ORDER BY created_at DESC');
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuestoesDaProva(provaId) {
|
||||||
|
const result = await pool.query('SELECT * FROM questoes_provas WHERE prova_id = $1 ORDER BY ordem ASC', [provaId]);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertProva(p) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO provas (id, turma_id, disciplina_id, periodo_id, titulo, duracao_minutos, status, permitir_refacao, is_deleted, evaluation_type)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
|
[
|
||||||
|
p.id, p.turma_id || p.classId, p.disciplina_id || p.subjectId, p.periodo_id || p.periodId,
|
||||||
|
p.titulo || p.title, p.duracao_minutos || p.durationMinutes || 60, p.status || 'draft',
|
||||||
|
p.permitir_refacao || p.allowRetake || false,
|
||||||
|
p.is_deleted || p.isDeleted || false,
|
||||||
|
p.evaluation_type || p.evaluationType || 'exam'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProva(id, p) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE provas SET
|
||||||
|
turma_id = $1, disciplina_id = $2, periodo_id = $3, titulo = $4, duracao_minutos = $5, status = $6, permitir_refacao = $7, is_deleted = $8, evaluation_type = $9
|
||||||
|
WHERE id = $10`,
|
||||||
|
[
|
||||||
|
p.turma_id || p.classId, p.disciplina_id || p.subjectId, p.periodo_id || p.periodId,
|
||||||
|
p.titulo || p.title, p.duracao_minutos || p.durationMinutes || 60, p.status || 'draft',
|
||||||
|
p.permitir_refacao || p.allowRetake || false,
|
||||||
|
p.is_deleted || p.isDeleted || false,
|
||||||
|
p.evaluation_type || p.evaluationType || 'exam',
|
||||||
|
id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProva(id) {
|
||||||
|
await pool.query('DELETE FROM provas WHERE id = $1', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncQuestoesProva(provaId, questoes) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await client.query('DELETE FROM questoes_provas WHERE prova_id = $1', [provaId]);
|
||||||
|
for (let i = 0; i < questoes.length; i++) {
|
||||||
|
const q = questoes[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)`,
|
||||||
|
[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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// SINCRONIZAÇÃO: JSON -> TABELAS RELACIONAIS
|
// SINCRONIZAÇÃO: JSON -> TABELAS RELACIONAIS
|
||||||
// Garante que IDs do JSON existam nas tabelas para evitar erro de Foreign Key
|
// Garante que IDs do JSON existam nas tabelas para evitar erro de Foreign Key
|
||||||
|
|
@ -607,8 +909,10 @@ export async function syncJsonToRelationalTables() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Garantir colunas de refação em provas
|
// Garantir colunas de refação e soft delete em provas
|
||||||
await client.query('ALTER TABLE provas ADD COLUMN IF NOT EXISTS permitir_refacao BOOLEAN DEFAULT FALSE');
|
await client.query('ALTER TABLE provas ADD COLUMN IF NOT EXISTS permitir_refacao BOOLEAN DEFAULT FALSE');
|
||||||
|
await client.query('ALTER TABLE provas ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN DEFAULT FALSE');
|
||||||
|
await client.query("ALTER TABLE provas ADD COLUMN IF NOT EXISTS evaluation_type VARCHAR(50) DEFAULT 'exam'");
|
||||||
|
|
||||||
// 2. Sincronizar Disciplinas (Subjects)
|
// 2. Sincronizar Disciplinas (Subjects)
|
||||||
if (data.subjects && Array.isArray(data.subjects)) {
|
if (data.subjects && Array.isArray(data.subjects)) {
|
||||||
|
|
|
||||||
|
|
@ -125,18 +125,36 @@ app.post('/api/portal/login', async (req, res) => {
|
||||||
return res.status(400).json({ error: 'Matrícula e senha são obrigatórios' });
|
return res.status(400).json({ error: 'Matrícula e senha são obrigatórios' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const schoolData = await getSchoolData();
|
const { rows: dbStudents } = await pool.query(
|
||||||
const students = schoolData.students || [];
|
'SELECT * FROM alunos WHERE numero_matricula ILIKE $1',
|
||||||
|
[enrollmentNumber]
|
||||||
const student = students.find(
|
|
||||||
(s) => s.enrollmentNumber && s.enrollmentNumber.toLowerCase() === enrollmentNumber.toLowerCase()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let student;
|
||||||
|
if (dbStudents.length > 0) {
|
||||||
|
const s = dbStudents[0];
|
||||||
|
student = {
|
||||||
|
id: s.id,
|
||||||
|
enrollmentNumber: s.numero_matricula,
|
||||||
|
name: s.nome,
|
||||||
|
status: s.status,
|
||||||
|
portalPassword: s.senha_portal,
|
||||||
|
cpf: s.cpf,
|
||||||
|
classId: s.turma_id,
|
||||||
|
photo: normalizeStorageUrl(s.foto_url)
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Fallback para arquivo JSON caso não tenha sido migrado (segurança)
|
||||||
|
const schoolData = await getSchoolData();
|
||||||
|
const students = schoolData.students || [];
|
||||||
|
const s = students.find((x) => x.enrollmentNumber && x.enrollmentNumber.toLowerCase() === enrollmentNumber.toLowerCase());
|
||||||
|
if (s) student = { ...s, photo: normalizeStorageUrl(s.photo) };
|
||||||
|
}
|
||||||
|
|
||||||
if (!student) {
|
if (!student) {
|
||||||
return res.status(401).json({ error: 'Matrícula não encontrada' });
|
return res.status(401).json({ error: 'Matrícula não encontrada' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check password — COPIADA EXATAMENTE como está no JSON
|
|
||||||
const expectedPassword = student.portalPassword || (student.cpf ? student.cpf.replace(/\D/g, '').substring(0, 6) : '');
|
const expectedPassword = student.portalPassword || (student.cpf ? student.cpf.replace(/\D/g, '').substring(0, 6) : '');
|
||||||
if (password !== expectedPassword) {
|
if (password !== expectedPassword) {
|
||||||
return res.status(401).json({ error: 'Senha incorreta' });
|
return res.status(401).json({ error: 'Senha incorreta' });
|
||||||
|
|
@ -153,13 +171,27 @@ app.post('/api/portal/login', async (req, res) => {
|
||||||
};
|
};
|
||||||
const token = jwt.sign(tokenPayload, JWT_SECRET, { expiresIn: '7d' });
|
const token = jwt.sign(tokenPayload, JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
|
||||||
const studentClass = (schoolData.classes || []).find((c) => c.id === student.classId) || null;
|
// Buscar Turma e Curso no PostgreSQL
|
||||||
const course = studentClass
|
let studentClass = null;
|
||||||
? (schoolData.courses || []).find((c) => c.id === studentClass.courseId) || null
|
let course = null;
|
||||||
: null;
|
|
||||||
|
|
||||||
// Normalizar foto do aluno
|
if (student.classId) {
|
||||||
if (student.photo) student.photo = normalizeStorageUrl(student.photo);
|
const { rows: tRows } = await pool.query('SELECT * FROM turmas WHERE id = $1', [student.classId]);
|
||||||
|
if (tRows.length > 0) {
|
||||||
|
studentClass = { id: tRows[0].id, name: tRows[0].nome, courseId: tRows[0].curso_id };
|
||||||
|
if (studentClass.courseId) {
|
||||||
|
const { rows: cRows } = await pool.query('SELECT * FROM cursos WHERE id = $1', [studentClass.courseId]);
|
||||||
|
if (cRows.length > 0) course = { id: cRows[0].id, name: cRows[0].nome };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback JSON se não achou as entidades relacionais (turma/curso)
|
||||||
|
if (!studentClass) {
|
||||||
|
const schoolData = await getSchoolData();
|
||||||
|
studentClass = (schoolData.classes || []).find((c) => c.id === student.classId) || null;
|
||||||
|
course = studentClass ? (schoolData.courses || []).find((c) => c.id === studentClass.courseId) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
token,
|
||||||
|
|
@ -196,17 +228,51 @@ app.get('/api/portal/escola', async (req, res) => {
|
||||||
// GET /api/portal/me
|
// GET /api/portal/me
|
||||||
app.get('/api/portal/me', authMiddleware, async (req, res) => {
|
app.get('/api/portal/me', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const schoolData = await getSchoolData();
|
const { rows: dbStudents } = await pool.query(
|
||||||
const student = (schoolData.students || []).find((s) => s.id === req.user.studentId);
|
'SELECT * FROM alunos WHERE id = $1',
|
||||||
|
[req.user.studentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
let student;
|
||||||
|
if (dbStudents.length > 0) {
|
||||||
|
const s = dbStudents[0];
|
||||||
|
student = {
|
||||||
|
id: s.id,
|
||||||
|
enrollmentNumber: s.numero_matricula,
|
||||||
|
name: s.nome,
|
||||||
|
status: s.status,
|
||||||
|
portalPassword: s.senha_portal,
|
||||||
|
cpf: s.cpf,
|
||||||
|
classId: s.turma_id,
|
||||||
|
photo: normalizeStorageUrl(s.foto_url)
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const schoolData = await getSchoolData();
|
||||||
|
const s = (schoolData.students || []).find((x) => x.id === req.user.studentId);
|
||||||
|
if (s) student = { ...s, photo: normalizeStorageUrl(s.photo) };
|
||||||
|
}
|
||||||
|
|
||||||
if (!student) return res.status(404).json({ error: 'Aluno não encontrado' });
|
if (!student) return res.status(404).json({ error: 'Aluno não encontrado' });
|
||||||
|
|
||||||
const studentClass = (schoolData.classes || []).find((c) => c.id === student.classId) || null;
|
let studentClass = null;
|
||||||
const course = studentClass
|
let course = null;
|
||||||
? (schoolData.courses || []).find((c) => c.id === studentClass.courseId) || null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Normalizar foto
|
if (student.classId) {
|
||||||
if (student.photo) student.photo = normalizeStorageUrl(student.photo);
|
const { rows: tRows } = await pool.query('SELECT * FROM turmas WHERE id = $1', [student.classId]);
|
||||||
|
if (tRows.length > 0) {
|
||||||
|
studentClass = { id: tRows[0].id, name: tRows[0].nome, courseId: tRows[0].curso_id };
|
||||||
|
if (studentClass.courseId) {
|
||||||
|
const { rows: cRows } = await pool.query('SELECT * FROM cursos WHERE id = $1', [studentClass.courseId]);
|
||||||
|
if (cRows.length > 0) course = { id: cRows[0].id, name: cRows[0].nome };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!studentClass) {
|
||||||
|
const schoolData = await getSchoolData();
|
||||||
|
studentClass = (schoolData.classes || []).find((c) => c.id === student.classId) || null;
|
||||||
|
course = studentClass ? (schoolData.courses || []).find((c) => c.id === studentClass.courseId) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
student: { ...student, portalPassword: undefined },
|
student: { ...student, portalPassword: undefined },
|
||||||
|
|
@ -534,13 +600,27 @@ app.post('/api/portal/frequencia/justificar', authMiddleware, upload.single('arq
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/portal/contratos
|
// GET /api/portal/contratos (SQL-First)
|
||||||
app.get('/api/portal/contratos', authMiddleware, async (req, res) => {
|
app.get('/api/portal/contratos', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const schoolData = await getSchoolData();
|
const { rows } = await pool.query(
|
||||||
const contracts = (schoolData.contracts || []).filter((c) => c.studentId === req.user.studentId);
|
`SELECT id, aluno_id as "studentId", titulo as title, conteudo as content, TO_CHAR(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') as "createdAt"
|
||||||
res.json({ contracts });
|
FROM contratos
|
||||||
|
WHERE aluno_id = $1
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
[req.user.studentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback de segurança para JSON legado caso não tenha sincronizado
|
||||||
|
if (rows.length === 0) {
|
||||||
|
const schoolData = await getSchoolData();
|
||||||
|
const fallbackContracts = (schoolData.contracts || []).filter((c) => c.studentId === req.user.studentId);
|
||||||
|
return res.json({ contracts: fallbackContracts });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ contracts: rows });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Erro contratos portal:', err);
|
||||||
res.status(500).json({ error: 'Erro interno' });
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -567,37 +647,60 @@ app.get('/api/portal/config', (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/portal/aulas (Leitura direta do school_data — mesma fonte do Manager)
|
// GET /api/portal/aulas (SQL-First — Leitura direta do PostgreSQL)
|
||||||
app.get('/api/portal/aulas', authMiddleware, async (req, res) => {
|
app.get('/api/portal/aulas', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const schoolData = await getSchoolData();
|
const { rows: dbStudents } = await pool.query('SELECT turma_id FROM alunos WHERE id = $1', [req.user.studentId]);
|
||||||
const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
|
if (dbStudents.length === 0) return res.json({ lessons: [] });
|
||||||
if (!student) return res.json({ lessons: [] });
|
|
||||||
|
// Obter turmas do aluno a partir da turma atual e presenças históricas
|
||||||
|
const { rows: freqRows } = await pool.query('SELECT DISTINCT turma_id FROM frequencias WHERE aluno_id = $1', [req.user.studentId]);
|
||||||
|
|
||||||
// Obter turmas do aluno a partir do JSON (mesma lógica do Manager)
|
|
||||||
const studentClassIds = new Set([
|
const studentClassIds = new Set([
|
||||||
student.classId,
|
dbStudents[0].turma_id,
|
||||||
...(schoolData.attendance || []).filter(a => a.studentId === req.user.studentId).map(a => a.classId)
|
...freqRows.map(f => f.turma_id)
|
||||||
].filter(Boolean));
|
].filter(Boolean));
|
||||||
|
|
||||||
const parseDateHelper = (dStr) => {
|
if (studentClassIds.size === 0) return res.json({ lessons: [] });
|
||||||
if (!dStr) return 0;
|
|
||||||
const parts = dStr.substring(0, 10).split(/[-/]/);
|
|
||||||
if (parts.length < 3) return 0;
|
|
||||||
if (parts[0].length === 4) return new Date(parts[0], parts[1] - 1, parts[2]).getTime();
|
|
||||||
return new Date(parts[2], parts[1] - 1, parts[0]).getTime();
|
|
||||||
};
|
|
||||||
|
|
||||||
const lessons = (schoolData.lessons || [])
|
const classIdsArray = Array.from(studentClassIds);
|
||||||
.filter(l => studentClassIds.has(l.classId))
|
const { rows: aulasRows } = await pool.query(`
|
||||||
.map(l => {
|
SELECT a.*, TO_CHAR(a.data, 'YYYY-MM-DD') as data_formatada, t.nome as class_name
|
||||||
const classObj = (schoolData.classes || []).find(c => c.id === l.classId);
|
FROM aulas a
|
||||||
return { ...l, className: classObj ? classObj.name : 'Turma' };
|
LEFT JOIN turmas t ON a.turma_id = t.id
|
||||||
})
|
WHERE a.turma_id = ANY($1)
|
||||||
.sort((a, b) => parseDateHelper(a.date) - parseDateHelper(b.date));
|
ORDER BY a.data ASC, a.horario_inicio ASC
|
||||||
|
`, [classIdsArray]);
|
||||||
|
|
||||||
|
const lessons = aulasRows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
classId: row.turma_id,
|
||||||
|
date: row.data_formatada,
|
||||||
|
startTime: row.horario_inicio,
|
||||||
|
endTime: row.horario_fim,
|
||||||
|
status: row.status,
|
||||||
|
type: row.tipo,
|
||||||
|
cancellationReason: row.motivo_cancelamento,
|
||||||
|
originalLessonId: row.aula_original_id,
|
||||||
|
className: row.class_name || 'Turma'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Se por acaso as aulas não foram migradas ainda, faz um fallback
|
||||||
|
if (lessons.length === 0) {
|
||||||
|
const schoolData = await getSchoolData();
|
||||||
|
const fallbackLessons = (schoolData.lessons || [])
|
||||||
|
.filter(l => studentClassIds.has(l.classId))
|
||||||
|
.map(l => {
|
||||||
|
const classObj = (schoolData.classes || []).find(c => c.id === l.classId);
|
||||||
|
return { ...l, className: classObj ? classObj.name : 'Turma' };
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||||
|
return res.json({ lessons: fallbackLessons });
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ lessons });
|
res.json({ lessons });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Erro ao buscar aulas:', err);
|
||||||
res.status(500).json({ error: 'Erro interno' });
|
res.status(500).json({ error: 'Erro interno' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -656,17 +759,36 @@ app.put('/api/portal/alterar-senha', authMiddleware, async (req, res) => {
|
||||||
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Campos obrigatórios' });
|
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Campos obrigatórios' });
|
||||||
if (newPassword.length < 4) return res.status(400).json({ error: 'Mínimo 4 caracteres' });
|
if (newPassword.length < 4) return res.status(400).json({ error: 'Mínimo 4 caracteres' });
|
||||||
|
|
||||||
|
const { rows: dbStudents } = await pool.query('SELECT * FROM alunos WHERE id = $1', [req.user.studentId]);
|
||||||
|
let student, isDb = false, studentIndex = -1;
|
||||||
const schoolData = await getSchoolData();
|
const schoolData = await getSchoolData();
|
||||||
const students = schoolData.students || [];
|
const students = schoolData.students || [];
|
||||||
const studentIndex = students.findIndex((s) => s.id === req.user.studentId);
|
|
||||||
if (studentIndex === -1) return res.status(404).json({ error: 'Aluno não encontrado' });
|
|
||||||
|
|
||||||
const student = students[studentIndex];
|
if (dbStudents.length > 0) {
|
||||||
|
const s = dbStudents[0];
|
||||||
|
student = { id: s.id, portalPassword: s.senha_portal, cpf: s.cpf };
|
||||||
|
isDb = true;
|
||||||
|
studentIndex = students.findIndex((s) => s.id === req.user.studentId);
|
||||||
|
} else {
|
||||||
|
studentIndex = students.findIndex((s) => s.id === req.user.studentId);
|
||||||
|
if (studentIndex !== -1) student = students[studentIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!student) return res.status(404).json({ error: 'Aluno não encontrado' });
|
||||||
|
|
||||||
const expectedPassword = student.portalPassword || (student.cpf ? student.cpf.replace(/\D/g, '').substring(0, 6) : '');
|
const expectedPassword = student.portalPassword || (student.cpf ? student.cpf.replace(/\D/g, '').substring(0, 6) : '');
|
||||||
if (currentPassword !== expectedPassword) return res.status(401).json({ error: 'Senha atual incorreta' });
|
if (currentPassword !== expectedPassword) return res.status(401).json({ error: 'Senha atual incorreta' });
|
||||||
|
|
||||||
students[studentIndex] = { ...student, portalPassword: newPassword };
|
// 1. Atualizar no PostgreSQL se existir
|
||||||
schoolData.students = students;
|
if (isDb) {
|
||||||
|
await pool.query('UPDATE alunos SET senha_portal = $1 WHERE id = $2', [newPassword, req.user.studentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Atualizar no JSON legado (Retrocompatibilidade e Segurança de Sincronia)
|
||||||
|
if (studentIndex !== -1) {
|
||||||
|
students[studentIndex] = { ...students[studentIndex], portalPassword: newPassword };
|
||||||
|
schoolData.students = students;
|
||||||
|
}
|
||||||
schoolData.lastUpdated = new Date().toISOString();
|
schoolData.lastUpdated = new Date().toISOString();
|
||||||
await saveSchoolData(schoolData);
|
await saveSchoolData(schoolData);
|
||||||
|
|
||||||
|
|
@ -686,17 +808,39 @@ app.get('/api/portal/avaliacoes', authMiddleware, async (req, res) => {
|
||||||
const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
|
const student = (schoolData.students || []).find(s => s.id === req.user.studentId);
|
||||||
if (!student) return res.json({ exams: [], submissions: [] });
|
if (!student) return res.json({ exams: [], submissions: [] });
|
||||||
|
|
||||||
const exams = (schoolData.exams || [])
|
const { rows: dbExams } = await pool.query(
|
||||||
.filter(e => e.status === 'published' && e.classId === student.classId && !e.isDeleted)
|
`SELECT * FROM provas WHERE turma_id = $1 AND status = 'published' AND is_deleted = false`,
|
||||||
.map(e => ({
|
[student.classId]
|
||||||
...e,
|
);
|
||||||
questions: e.questions.map(q => ({
|
|
||||||
|
const exams = [];
|
||||||
|
for (const row of dbExams) {
|
||||||
|
const { rows: questoes } = await pool.query(
|
||||||
|
`SELECT * FROM questoes_provas WHERE prova_id = $1 ORDER BY ordem ASC`,
|
||||||
|
[row.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
exams.push({
|
||||||
|
id: row.id,
|
||||||
|
classId: row.turma_id,
|
||||||
|
subjectId: row.disciplina_id,
|
||||||
|
periodId: row.periodo_id,
|
||||||
|
title: row.titulo,
|
||||||
|
durationMinutes: row.duracao_minutos,
|
||||||
|
status: row.status,
|
||||||
|
allowRetake: row.permitir_refacao,
|
||||||
|
isDeleted: row.is_deleted,
|
||||||
|
evaluationType: row.evaluation_type,
|
||||||
|
maxScore: 10,
|
||||||
|
questions: questoes.map(q => ({
|
||||||
id: q.id,
|
id: q.id,
|
||||||
text: q.text,
|
text: q.texto,
|
||||||
options: q.options,
|
options: typeof q.opcoes === 'string' ? JSON.parse(q.opcoes) : q.opcoes,
|
||||||
imageUrl: normalizeStorageUrl(q.imageUrl)
|
correctOptionIndex: q.indice_correto,
|
||||||
|
imageUrl: normalizeStorageUrl(q.imagem_url)
|
||||||
}))
|
}))
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { rows: submissions } = await pool.query(
|
const { rows: submissions } = await pool.query(
|
||||||
'SELECT * FROM provas_submissoes WHERE aluno_id = $1',
|
'SELECT * FROM provas_submissoes WHERE aluno_id = $1',
|
||||||
|
|
@ -728,9 +872,25 @@ app.post('/api/portal/avaliacoes/submeter', authMiddleware, async (req, res) =>
|
||||||
const { examId, answers } = req.body;
|
const { examId, answers } = req.body;
|
||||||
if (!examId || !answers) return res.status(400).json({ error: 'Dados obrigatórios' });
|
if (!examId || !answers) return res.status(400).json({ error: 'Dados obrigatórios' });
|
||||||
|
|
||||||
const schoolData = await getSchoolData();
|
const { rows: examRows } = await pool.query('SELECT * FROM provas WHERE id = $1', [examId]);
|
||||||
const exam = (schoolData.exams || []).find(e => e.id === examId);
|
if (examRows.length === 0) return res.status(404).json({ error: 'Prova não encontrada.' });
|
||||||
if (!exam) return res.status(404).json({ error: 'Prova não encontrada.' });
|
const examData = examRows[0];
|
||||||
|
|
||||||
|
const { rows: questoes } = await pool.query('SELECT * FROM questoes_provas WHERE prova_id = $1 ORDER BY ordem ASC', [examId]);
|
||||||
|
|
||||||
|
const exam = {
|
||||||
|
id: examData.id,
|
||||||
|
title: examData.titulo,
|
||||||
|
allowRetake: examData.permitir_refacao,
|
||||||
|
subjectId: examData.disciplina_id,
|
||||||
|
periodId: examData.periodo_id,
|
||||||
|
evaluationType: examData.evaluation_type,
|
||||||
|
maxScore: 10,
|
||||||
|
questions: questoes.map(q => ({
|
||||||
|
id: q.id,
|
||||||
|
correctOptionIndex: q.indice_correto
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
// Verificar se já submeteu
|
// Verificar se já submeteu
|
||||||
const { rows: existing } = await pool.query(
|
const { rows: existing } = await pool.query(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue