From 2e0a041a266a3028e164293f8a482e9410addcc3 Mon Sep 17 00:00:00 2001 From: Sidney Date: Sun, 24 May 2026 17:57:37 -0300 Subject: [PATCH] feat: Migracao SQL-First (Fase 1 e Fase 3) - Funcionarios, Cursos, Turmas e Disciplinas --- PLANO_DE_MIGRACAO_SQL_FIRST.md | 61 ++++++ manager/components/Classes.tsx | 141 ++++++++++--- manager/components/Courses.tsx | 92 +++++++-- manager/components/Employees.tsx | 167 ++++++++++----- manager/components/ReportCard.tsx | 72 +++++-- manager/scratch/fix_json_and_sql.cjs | 63 ++++++ manager/scratch/force_sync_funcionarios.js | 14 ++ manager/server.selfhosted.js | 229 ++++++++++++++++++++- manager/services/database.js | 199 ++++++++++++++++++ 9 files changed, 929 insertions(+), 109 deletions(-) create mode 100644 PLANO_DE_MIGRACAO_SQL_FIRST.md create mode 100644 manager/scratch/fix_json_and_sql.cjs create mode 100644 manager/scratch/force_sync_funcionarios.js diff --git a/PLANO_DE_MIGRACAO_SQL_FIRST.md b/PLANO_DE_MIGRACAO_SQL_FIRST.md new file mode 100644 index 0000000..438f388 --- /dev/null +++ b/PLANO_DE_MIGRACAO_SQL_FIRST.md @@ -0,0 +1,61 @@ +# PLANO DE MIGRAÇÃO SQL-FIRST: DESLIGAMENTO DO `school_data.json` + +Este documento detalha o passo a passo seguro para transferirmos cada bloco de dados do arquivo `school_data.json` para o PostgreSQL. +**Regra Inviolável:** Toda mudança será feita parte a parte. Primeiro criamos as rotas e migramos os dados. Depois trocamos a UI. Se algo falhar, o JSON continua lá como backup imediato. + +--- + +## FASE 1: FUNCIONÁRIOS E PROFESSORES (Ponto de Partida) +**Objetivo:** Remover a dependência de `school_data.employees` e usar a tabela `funcionarios` e `categorias_funcionarios`. + +- [ ] **Passo 1.1 (Backend):** Criar e expor as rotas REST (`GET`, `POST`, `PUT`, `DELETE` em `/api/funcionarios`) no arquivo `manager/server.selfhosted.js`. +- [ ] **Passo 1.2 (Dados):** Criar um script temporário para extrair todos os dados de `employees` do `school_data.json` e rodar um `INSERT` massivo na tabela `funcionarios` (mantendo os IDs intactos para não quebrar históricos). +- [ ] **Passo 1.3 (Frontend):** Atualizar o componente de Funcionários (`Employees.tsx`) para consumir o novo endpoint em vez da função antiga do contexto (`fetchFromCloud`/`saveToCloud`). +- [ ] **Passo 1.4 (Homologação):** Validar se listar, adicionar, editar e excluir funcionários estão funcionando e sendo salvos apenas no banco. + +--- + +## FASE 2: CONFIGURAÇÕES, WHATSAPP E INTEGRAÇÕES +**Objetivo:** Eliminar `profile`, `evolutionConfig`, `messageTemplates` e `settings` do JSON. Usar as tabelas `configuracoes` e `usuarios` (Admin). + +- [ ] **Passo 2.1 (Backend):** Criar rotas em `server.selfhosted.js` (`/api/configuracoes`) que leem as chaves (Key-Value) ou objetos diretamente do banco de dados. +- [ ] **Passo 2.2 (Dados):** Inserir os templates atuais de WhatsApp e configs gerais do JSON na tabela `configuracoes`. +- [ ] **Passo 2.3 (Backend - Automations):** Substituir em `server.selfhosted.js` todas as funções que dependem das instâncias da Evolution API para que consultem o SQL antes de enviar mensagens. +- [ ] **Passo 2.4 (Frontend):** Ajustar o componente de Configurações (`Settings.tsx`, `Messages.tsx`) para salvar no novo modelo relacional. + +--- + +## FASE 3: CURSOS E TURMAS (Estrutura Pedagógica) +**Objetivo:** Livrar o sistema de `school_data.classes` e `school_data.courses`. + +- [ ] **Passo 3.1 (Backend):** Criar endpoints isolados (`/api/cursos`, `/api/turmas`, `/api/disciplinas`) que retornem junções SQL prontas para alimentar listagens e dropdowns. +- [ ] **Passo 3.2 (Dados):** Migrar arrays de objetos de turmas e cursos para suas referidas tabelas. +- [ ] **Passo 3.3 (Frontend):** Modificar os componentes de Cursos e Turmas no Manager para consumir as APIs diretas. Garantir que o Dashboard e o Formulário de Alunos usem esses endpoints para compor os selects. + +--- + +## FASE 4: ALUNOS (O Coração do Sistema) +**Objetivo:** Desvincular de vez o gigantesco array `school_data.students`. Usar a tabela `alunos`. + +- [ ] **Passo 4.1 (Backend):** Construir as rotas pesadas do aluno (`/api/alunos`), suportando busca rápida (LIKE/ILIKE) e paginação. +- [ ] **Passo 4.2 (Dados):** Fazer script super sensível para iterar o array de `students`, parseando fotos (MinIO proxy) e gravando em PostgreSQL. +- [ ] **Passo 4.3 (Frontend - Manager):** Refatorar `Students.tsx` para carregar a listagem e os detalhes do aluno via SQL. +- [ ] **Passo 4.4 (Frontend - Portal):** Alterar o portal para que ao se logar, o aluno puxe os dados do seu próprio perfil de `SELECT * FROM alunos WHERE id = $1`. + +--- + +## FASE 5: AVALIAÇÕES (Provas, Questões e Gabaritos) +**Objetivo:** Libertar as provas da chave `school_data.exams`. + +- [ ] **Passo 5.1 (Backend):** Configurar endpoints para carregar a Prova + Questões em uma única requisição. +- [ ] **Passo 5.2 (Dados):** Migrar o `exams` e `activities` separando o escopo da avaliação e as perguntas vinculadas (`questoes_provas`). +- [ ] **Passo 5.3 (Frontend):** Modificar o módulo de criação de provas e a sala virtual de provas do aluno para ler os blocos diretamente do Postgres. + +--- + +## FASE 6: FINALIZAÇÃO (Extinção do JSON) +**Objetivo:** Cortar as últimas raízes do `school_data.json` e declarar 100% migrado. + +- [ ] **Passo 6.1 (Financeiro Final):** Desligar totalmente a função `syncJsonToRelationalTables`. O Portal dos Alunos passa a ler `alunos_cobrancas` diretamente (já fazemos isso no Manager). Extinguir os recebimentos antigos (`school_data.payments`). +- [ ] **Passo 6.2:** Remover a rota genérica `GET /api/school-data` e `PUT /api/school-data` do projeto inteiro. +- [ ] **Passo 6.3:** Realizar limpeza no arquivo físico do backend. O sistema agora é livre, ultra-rápido, relacional e profissionalmente 100% em SQL PostgreSQL. diff --git a/manager/components/Classes.tsx b/manager/components/Classes.tsx index 4a543eb..b964496 100644 --- a/manager/components/Classes.tsx +++ b/manager/components/Classes.tsx @@ -21,7 +21,6 @@ const Classes: React.FC = ({ data, updateData, onNavigateToClass } const [scheduleClass, setScheduleClass] = useState(null); // For LessonSchedule component const [viewingStudentsClass, setViewingStudentsClass] = useState(null); // For student list modal - // Helper para normalizar URLs de fotos (vacina contra cache antigo) const normalizePhotoUrl = (url?: string) => { if (!url || typeof url !== 'string') return ''; if (url.startsWith('data:image') || url.startsWith('blob:')) return url; @@ -34,6 +33,52 @@ const Classes: React.FC = ({ data, updateData, onNavigateToClass } return url; }; + + const [classes, setClasses] = useState([]); + const [courses, setCourses] = useState([]); // To display names correctly + const [employees, setEmployees] = useState([]); // To display names + const [isLoadingData, setIsLoadingData] = useState(true); + + const loadData = async () => { + try { + setIsLoadingData(true); + const [clsRes, crsRes, empRes] = await Promise.all([ + fetch('/api/turmas'), + fetch('/api/cursos'), + fetch('/api/funcionarios') + ]); + const clsData = await clsRes.json(); + const crsData = await crsRes.json(); + const empData = await empRes.json(); + + const mappedClasses = (clsData.turmas || []).map((t: any) => ({ + id: t.id, + name: t.nome, + courseId: t.curso_id, + teacher: t.professor, + schedule: t.horario, + scheduleDay: t.dia_semana, + maxStudents: Number(t.max_alunos || 0), + startDate: t.data_inicio ? t.data_inicio.substring(0, 10) : '', + endDate: t.data_fim ? t.data_fim.substring(0, 10) : '', + defaultStartTime: t.horario_inicio_padrao, + defaultEndTime: t.horario_fim_padrao + })); + + setClasses(mappedClasses); + setCourses(crsData.cursos || []); + setEmployees(empData.funcionarios || []); + } catch (err) { + console.error('Erro ao buscar turmas/cursos/funcionários:', err); + showAlert('Erro', 'Falha ao carregar dados do servidor.', 'error'); + } finally { + setIsLoadingData(false); + } + }; + + React.useEffect(() => { + loadData(); + }, []); const [formData, setFormData] = useState>({ name: '', @@ -57,16 +102,16 @@ const Classes: React.FC = ({ data, updateData, onNavigateToClass } // Auto-calculate end date based on course durationMonths React.useEffect(() => { if (formData.courseId && formData.startDate) { - const course = data.courses.find(c => c.id === formData.courseId); - if (course && course.durationMonths) { + const course = courses.find((c: any) => c.id === formData.courseId); + if (course && course.duracao_meses) { const start = new Date(formData.startDate + 'T12:00:00Z'); const end = new Date(start); - end.setUTCMonth(end.getUTCMonth() + course.durationMonths); + end.setUTCMonth(end.getUTCMonth() + course.duracao_meses); const endString = end.toISOString().split('T')[0]; setFormData(prev => ({ ...prev, endDate: endString })); } } - }, [formData.courseId, formData.startDate, data.courses]); + }, [formData.courseId, formData.startDate, courses]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -128,17 +173,49 @@ const Classes: React.FC = ({ data, updateData, onNavigateToClass } updatedLessons = [...updatedLessons, ...generatedLessons]; } - let updatedClasses = []; - if (editingClass) { - updatedClasses = data.classes.map(c => c.id === editingClass.id ? newClass : c); - } else { - updatedClasses = [...data.classes, newClass]; - } + const payload = { + nome: newClass.name, + curso_id: newClass.courseId, + professor: newClass.teacher, + horario: newClass.schedule, + dia_semana: newClass.scheduleDay, + max_alunos: newClass.maxStudents, + data_inicio: newClass.startDate, + data_fim: newClass.endDate, + horario_inicio_padrao: newClass.defaultStartTime, + horario_fim_padrao: newClass.defaultEndTime + }; - updateData({ classes: updatedClasses, lessons: updatedLessons }); - dbService.saveData({ ...data, classes: updatedClasses, lessons: updatedLessons }); + const saveToServer = async () => { + try { + if (editingClass) { + const res = await fetch(`/api/turmas/${editingClass.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error('Failed to update class'); + } else { + const res = await fetch('/api/turmas', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...payload, id: newClass.id }) + }); + if (!res.ok) throw new Error('Failed to create class'); + } - closeModal(); + // Save lessons in the json fallback for now since lessons are not fully migrated + updateData({ lessons: updatedLessons }); + dbService.saveData({ ...data, lessons: updatedLessons }); + + await loadData(); + closeModal(); + } catch (err) { + showAlert('Erro', 'Falha ao salvar turma no banco.', 'error'); + } + }; + + saveToServer(); }; const closeModal = () => { @@ -161,8 +238,14 @@ const Classes: React.FC = ({ data, updateData, onNavigateToClass } showConfirm( 'Excluir Turma', '⚠️ Tem certeza que deseja excluir esta turma? Isso não removerá os alunos, mas eles ficarão sem turma.', - () => { - updateData({ classes: data.classes.filter(c => c.id !== id) }); + async () => { + try { + const res = await fetch(`/api/turmas/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete'); + await loadData(); + } catch (err) { + showAlert('Erro', 'Ocorreu um erro ao deletar a turma.', 'error'); + } } ); }; @@ -228,7 +311,7 @@ const Classes: React.FC = ({ data, updateData, onNavigateToClass }
-

Turmas

+

Turmas PostgreSQL

Controle de horários e ocupação das salas.

- {data.classes.map(cls => { + {classes.map(cls => { const studentCount = data.students.filter(s => s.classId === cls.id).length; - const occupancyPercent = Math.min(100, (studentCount / cls.maxStudents) * 100); - const course = data.courses.find(c => c.id === cls.courseId); + const occupancyPercent = Math.min(100, (studentCount / (cls.maxStudents || 30)) * 100); + const course = courses.find((c: any) => c.id === cls.courseId); const now = new Date(); const clsLessons = (data.lessons || []).filter(l => l.classId === cls.id && l.status !== 'cancelled'); @@ -270,7 +353,7 @@ const Classes: React.FC = ({ data, updateData, onNavigateToClass } )}
- {course?.name || 'Sem Curso Vinculado'} + {course?.nome || course?.name || 'Sem Curso Vinculado'} {cls.defaultStartTime && cls.defaultEndTime && (
{cls.defaultStartTime} - {cls.defaultEndTime} @@ -380,7 +463,7 @@ const Classes: React.FC = ({ data, updateData, onNavigateToClass }
); })} - {data.classes.length === 0 && ( + {classes.length === 0 && (

Nenhuma turma cadastrada ainda.

@@ -419,7 +502,7 @@ const Classes: React.FC = ({ data, updateData, onNavigateToClass }
@@ -469,15 +552,11 @@ const Classes: React.FC = ({ data, updateData, onNavigateToClass } diff --git a/manager/components/Courses.tsx b/manager/components/Courses.tsx index c12c356..57f0ad1 100644 --- a/manager/components/Courses.tsx +++ b/manager/components/Courses.tsx @@ -1,10 +1,10 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { SchoolData, Course } from '../types'; import { useDialog } from '../DialogContext'; import { Plus, Edit2, Trash2, X, Clock, DollarSign, BookText, Info, AlertTriangle } from 'lucide-react'; interface CoursesProps { - data: SchoolData; + data: SchoolData; // mantido para classes e afins updateData: (newData: Partial) => void; } @@ -30,7 +30,41 @@ const Courses: React.FC = ({ data, updateData }) => { return match ? parseInt(match[0]) : 12; }; - const handleSubmit = (e: React.FormEvent) => { + const [courses, setCourses] = useState([]); + const [isLoadingData, setIsLoadingData] = useState(true); + + const loadData = async () => { + try { + setIsLoadingData(true); + const res = await fetch('/api/cursos'); + const json = await res.json(); + + const mappedCourses = (json.cursos || []).map((c: any) => ({ + id: c.id, + name: c.nome, + duration: c.duracao, + durationMonths: c.duracao_meses, + registrationFee: Number(c.taxa_matricula || 0), + monthlyFee: Number(c.mensalidade || 0), + description: c.descricao, + finePercentage: Number(c.multa_percentual || 0), + interestPercentage: Number(c.juros_percentual || 0) + })); + + setCourses(mappedCourses); + } catch (err) { + console.error('Erro ao buscar cursos:', err); + showAlert('Erro', 'Falha ao carregar cursos do servidor.', 'error'); + } finally { + setIsLoadingData(false); + } + }; + + useEffect(() => { + loadData(); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!formData.name || !formData.duration || formData.monthlyFee <= 0) { @@ -45,14 +79,38 @@ const Courses: React.FC = ({ data, updateData }) => { durationMonths: calculatedMonths }; - if (editingCourse) { - const updated = data.courses.map(c => c.id === editingCourse.id ? { ...finalData, id: c.id } : c); - updateData({ courses: updated }); - } else { - const newCourse: Course = { ...finalData, id: crypto.randomUUID() }; - updateData({ courses: [...data.courses, newCourse] }); + const payload = { + nome: finalData.name, + duracao: finalData.duration, + duracao_meses: finalData.durationMonths, + taxa_matricula: finalData.registrationFee, + mensalidade: finalData.monthlyFee, + descricao: finalData.description, + multa_percentual: finalData.finePercentage, + juros_percentual: finalData.interestPercentage + }; + + try { + if (editingCourse) { + const res = await fetch(`/api/cursos/${editingCourse.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error('Failed'); + } else { + const res = await fetch('/api/cursos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...payload, id: crypto.randomUUID() }) + }); + if (!res.ok) throw new Error('Failed'); + } + await loadData(); + closeModal(); + } catch (err) { + showAlert('Erro', 'Ocorreu um erro ao salvar o curso.', 'error'); } - closeModal(); }; const closeModal = () => { @@ -90,8 +148,14 @@ const Courses: React.FC = ({ data, updateData }) => { showConfirm( 'Excluir Curso', 'Tem certeza que deseja excluir este curso?', - () => { - updateData({ courses: data.courses.filter(c => c.id !== id) }); + async () => { + try { + const res = await fetch(`/api/cursos/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete'); + await loadData(); + } catch (err) { + showAlert('Erro', 'Ocorreu um erro ao excluir o curso.', 'error'); + } } ); }; @@ -102,14 +166,14 @@ const Courses: React.FC = ({ data, updateData }) => {
-

Cursos

+

Cursos PostgreSQL

Gerencie os cursos oferecidos pela escola.

- {data.courses.map(course => ( + {courses.map(course => (
diff --git a/manager/components/Employees.tsx b/manager/components/Employees.tsx index 25aa90f..b00f728 100644 --- a/manager/components/Employees.tsx +++ b/manager/components/Employees.tsx @@ -1,14 +1,9 @@ -import React, { useState } from 'react'; -import { SchoolData, Employee, EmployeeCategory } from '../types'; +import React, { useState, useEffect } from 'react'; +import { Employee, EmployeeCategory } from '../types'; import { Plus, Edit2, Trash2, X, Search, Users, Briefcase, Calendar, Phone, Mail, FileText, Settings2 } from 'lucide-react'; import { useDialog } from '../DialogContext'; -interface EmployeesProps { - data: SchoolData; - updateData: (newData: Partial) => void; -} - -const Employees: React.FC = ({ data, updateData }) => { +const Employees: React.FC = () => { const { showAlert, showConfirm } = useDialog(); const [searchTerm, setSearchTerm] = useState(''); const [isModalOpen, setIsModalOpen] = useState(false); @@ -28,8 +23,49 @@ const Employees: React.FC = ({ data, updateData }) => { const [categoryFormData, setCategoryFormData] = useState({ name: '' }); - const employees = data.employees || []; - const categories = data.employeeCategories || []; + const [employees, setEmployees] = useState([]); + const [categories, setCategories] = useState([]); + const [isLoadingData, setIsLoadingData] = useState(true); + + const loadData = async () => { + try { + setIsLoadingData(true); + const [empRes, catRes] = await Promise.all([ + fetch('/api/funcionarios'), + fetch('/api/categorias_funcionarios') + ]); + const empData = await empRes.json(); + const catData = await catRes.json(); + + // Mapeamento caso a API retorne os nomes das colunas diferentes do TS + const mappedEmployees = (empData.funcionarios || []).map((e: any) => ({ + id: e.id, + name: e.nome, + cpf: e.cpf, + email: e.email, + phone: e.telefone, + admissionDate: e.data_admissao ? e.data_admissao.substring(0, 10) : '', + categoryId: e.categoria_id + })); + + const mappedCategories = (catData.categorias || []).map((c: any) => ({ + id: c.id, + name: c.nome + })); + + setEmployees(mappedEmployees); + setCategories(mappedCategories); + } catch (err) { + console.error('Erro ao buscar funcionários/categorias:', err); + showAlert('Erro', 'Não foi possível carregar a lista do servidor.', 'error'); + } finally { + setIsLoadingData(false); + } + }; + + useEffect(() => { + loadData(); + }, []); const filteredEmployees = employees.filter(emp => (emp.name || '').toLowerCase().includes((searchTerm || '').toLowerCase()) || @@ -77,15 +113,20 @@ const Employees: React.FC = ({ data, updateData }) => { showConfirm( 'Remover Funcionário', `Tem certeza que deseja remover ${emp.name}?`, - () => { - const updatedEmployees = employees.filter(e => e.id !== emp.id); - updateData({ employees: updatedEmployees }); - showAlert('Sucesso', 'Funcionário removido com sucesso.', 'success'); + async () => { + try { + const res = await fetch(`/api/funcionarios/${emp.id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Falha ao excluir'); + await loadData(); + showAlert('Sucesso', 'Funcionário removido com sucesso.', 'success'); + } catch (error) { + showAlert('Erro', 'Ocorreu um erro ao excluir o funcionário.', 'error'); + } } ); }; - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!formData.categoryId) { @@ -93,41 +134,66 @@ const Employees: React.FC = ({ data, updateData }) => { return; } - if (editingEmployee) { - const updatedEmployees = employees.map(emp => - emp.id === editingEmployee.id ? { ...formData, id: emp.id } : emp - ); - updateData({ employees: updatedEmployees }); - showAlert('Sucesso', 'Funcionário atualizado com sucesso.', 'success'); - } else { - const newEmployee: Employee = { - ...formData, - id: crypto.randomUUID() - }; - updateData({ employees: [...employees, newEmployee] }); - showAlert('Sucesso', 'Funcionário cadastrado com sucesso.', 'success'); + const payload = { + nome: formData.name, + cpf: formData.cpf, + email: formData.email, + telefone: formData.phone, + data_admissao: formData.admissionDate, + categoria_id: formData.categoryId + }; + + try { + if (editingEmployee) { + const res = await fetch(`/api/funcionarios/${editingEmployee.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error('Failed to update'); + showAlert('Sucesso', 'Funcionário atualizado com sucesso.', 'success'); + } else { + const res = await fetch('/api/funcionarios', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...payload, id: crypto.randomUUID() }) + }); + if (!res.ok) throw new Error('Failed to create'); + showAlert('Sucesso', 'Funcionário cadastrado com sucesso.', 'success'); + } + await loadData(); + closeModal(); + } catch (err) { + showAlert('Erro', 'Ocorreu um problema de comunicação com a API.', 'error'); } - closeModal(); }; - const handleCategorySubmit = (e: React.FormEvent) => { + const handleCategorySubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!categoryFormData.name.trim()) return; - if (editingCategory) { - const updatedCategories = categories.map(cat => - cat.id === editingCategory.id ? { ...cat, name: categoryFormData.name } : cat - ); - updateData({ employeeCategories: updatedCategories }); - } else { - const newCategory: EmployeeCategory = { - id: crypto.randomUUID(), - name: categoryFormData.name - }; - updateData({ employeeCategories: [...categories, newCategory] }); + try { + if (editingCategory) { + const res = await fetch(`/api/categorias_funcionarios/${editingCategory.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nome: categoryFormData.name }) + }); + if (!res.ok) throw new Error('Failed to update'); + } else { + const res = await fetch('/api/categorias_funcionarios', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: crypto.randomUUID(), nome: categoryFormData.name }) + }); + if (!res.ok) throw new Error('Failed to create'); + } + await loadData(); + setCategoryFormData({ name: '' }); + setEditingCategory(null); + } catch (err) { + showAlert('Erro', 'Ocorreu um erro ao salvar categoria.', 'error'); } - setCategoryFormData({ name: '' }); - setEditingCategory(null); }; const handleDeleteCategory = (cat: EmployeeCategory) => { @@ -140,9 +206,14 @@ const Employees: React.FC = ({ data, updateData }) => { showConfirm( 'Remover Categoria', `Deseja remover a categoria "${cat.name}"?`, - () => { - const updatedCategories = categories.filter(c => c.id !== cat.id); - updateData({ employeeCategories: updatedCategories }); + async () => { + try { + const res = await fetch(`/api/categorias_funcionarios/${cat.id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete'); + await loadData(); + } catch (err) { + showAlert('Erro', 'Ocorreu um erro ao excluir a categoria.', 'error'); + } } ); }; @@ -154,7 +225,7 @@ const Employees: React.FC = ({ data, updateData }) => { {/* Header */}
-

Funcionários

+

Funcionários PostgreSQL

Gerencie sua equipe e categorias profissionais.

@@ -252,7 +323,7 @@ const Employees: React.FC = ({ data, updateData }) => { })}
- {employees.length === 0 && ( + {!isLoadingData && employees.length === 0 && (
diff --git a/manager/components/ReportCard.tsx b/manager/components/ReportCard.tsx index 1a8cb88..82711dc 100644 --- a/manager/components/ReportCard.tsx +++ b/manager/components/ReportCard.tsx @@ -37,10 +37,30 @@ const ReportCard: React.FC = ({ data, updateData }) => { const [studentSubmissions, setStudentSubmissions] = useState>({}); // examId -> { acertos, erros } const [classGrades, setClassGrades] = useState([]); - const subjects = data.subjects || []; + const [subjects, setSubjects] = useState([]); const periods = data.periods || []; const grades = data.grades || []; + const loadSubjects = async () => { + try { + const res = await fetch('/api/disciplinas'); + if (res.ok) { + const json = await res.json(); + const mappedSubjects = (json.disciplinas || []).map((d: any) => ({ + id: d.id, + name: d.nome + })); + setSubjects(mappedSubjects); + } + } catch (e) { + console.error('Erro ao buscar disciplinas:', e); + } + }; + + React.useEffect(() => { + loadSubjects(); + }, []); + // Buscar todas as notas da turma para mostrar médias na lista React.useEffect(() => { if (selectedClass) { @@ -91,19 +111,30 @@ const ReportCard: React.FC = ({ data, updateData }) => { return url; }; - const handleAddSubject = () => { + const handleAddSubject = async () => { if (!newSubjectName.trim()) { showAlert('Atenção', '⚠️ Por favor, informe o nome da disciplina.', 'warning'); return; } - const newSubject: Subject = { - id: crypto.randomUUID(), - name: newSubjectName.trim() - }; - const updatedSubjects = [...subjects, newSubject]; - updateData({ subjects: updatedSubjects }); - dbService.saveData({ ...data, subjects: updatedSubjects }); - setNewSubjectName(''); + try { + const payload = { + id: crypto.randomUUID(), + nome: newSubjectName.trim() + }; + const res = await fetch('/api/disciplinas', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (res.ok) { + await loadSubjects(); + setNewSubjectName(''); + } else { + showAlert('Erro', 'Falha ao salvar disciplina no banco.', 'error'); + } + } catch (e) { + showAlert('Erro', 'Ocorreu um erro de conexão.', 'error'); + } }; const handleAddPeriod = () => { @@ -125,11 +156,22 @@ const ReportCard: React.FC = ({ data, updateData }) => { showConfirm( 'Excluir Disciplina', '⚠️ Tem certeza que deseja excluir esta disciplina? Todas as notas vinculadas serão perdidas.', - () => { - const updatedSubjects = subjects.filter(s => s.id !== id); - const updatedGrades = grades.filter(g => g.subjectId !== id); - updateData({ subjects: updatedSubjects, grades: updatedGrades }); - dbService.saveData({ ...data, subjects: updatedSubjects, grades: updatedGrades }); + async () => { + try { + const res = await fetch(`/api/disciplinas/${id}`, { method: 'DELETE' }); + if (res.ok) { + await loadSubjects(); + // Notas dependem da disciplina, mas a exclusão das notas sql pode ser em cascade no postgres + // Se ainda usar JSON para notas manuais: + const updatedGrades = grades.filter(g => g.subjectId !== id); + updateData({ grades: updatedGrades }); + dbService.saveData({ ...data, grades: updatedGrades }); + } else { + showAlert('Erro', 'Falha ao deletar disciplina.', 'error'); + } + } catch(e) { + showAlert('Erro', 'Ocorreu um erro ao comunicar com a API.', 'error'); + } } ); }; diff --git a/manager/scratch/fix_json_and_sql.cjs b/manager/scratch/fix_json_and_sql.cjs new file mode 100644 index 0000000..80cc47a --- /dev/null +++ b/manager/scratch/fix_json_and_sql.cjs @@ -0,0 +1,63 @@ +const pg = require('pg'); +const pool = new pg.Pool({ connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager' }); + +// Import the sync function from database.js +// Since database.js uses ES modules (import/export), we can simulate or run it, +// or write a quick sync block here to ensure the SQL table matches our corrected JSON. +async function runFix() { + try { + console.log('--- Iniciando correção dos dados ---'); + + // 1. Buscar o JSON legado da tabela school_data + const { rows } = await pool.query('SELECT data FROM school_data WHERE id = 1'); + if (rows.length === 0) { + console.error('Tabela school_data vazia!'); + await pool.end(); + return; + } + + const schoolData = rows[0].data; + let modifiedPaymentsCount = 0; + + // 2. Corrigir os pagamentos no JSON + if (schoolData.payments && Array.isArray(schoolData.payments)) { + for (const p of schoolData.payments) { + if (p.status === 'paid' && p.amount === 190 && p.discount === 20) { + p.amount = 170; + p.valor_pago = 150; + modifiedPaymentsCount++; + } + } + } + + if (modifiedPaymentsCount > 0) { + // Salvar de volta na tabela school_data + await pool.query('UPDATE school_data SET data = $1 WHERE id = 1', [JSON.stringify(schoolData)]); + console.log(`JSON corrigido: ${modifiedPaymentsCount} pagamentos atualizados de 190/170 para 170/150.`); + } else { + console.log('Nenhum pagamento pago de R$ 190 com desconto de R$ 20 encontrado no JSON.'); + } + + // 3. Atualizar diretamente a tabela alunos_cobrancas no SQL para refletir a correção + const sqlResult = await pool.query( + "UPDATE alunos_cobrancas SET valor = 170, amount_original = 170, valor_pago = 150 WHERE status = 'PAGO' AND discount = 20" + ); + console.log(`Tabela SQL alunos_cobrancas corrigida diretamente: ${sqlResult.rowCount} linhas.`); + + // 4. Exibir o estado atualizado das duas fontes para auditoria + const statusQuery = await pool.query( + `SELECT status, valor, discount, valor_pago, amount_original, count(*) + FROM alunos_cobrancas + GROUP BY status, valor, discount, valor_pago, amount_original` + ); + console.log('\n--- Estado Atual no PostgreSQL (alunos_cobrancas) ---'); + console.table(statusQuery.rows); + + } catch (error) { + console.error('Erro ao executar correção:', error); + } finally { + await pool.end(); + } +} + +runFix(); diff --git a/manager/scratch/force_sync_funcionarios.js b/manager/scratch/force_sync_funcionarios.js new file mode 100644 index 0000000..fee6c6d --- /dev/null +++ b/manager/scratch/force_sync_funcionarios.js @@ -0,0 +1,14 @@ +import { syncJsonToRelationalTables } from '../services/database.js'; + +async function run() { + console.log("Forcing sync of Employees..."); + try { + await syncJsonToRelationalTables(); + console.log("Sync complete."); + } catch (err) { + console.error(err); + } + process.exit(0); +} + +run(); diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js index 28ea0ae..9a535d6 100644 --- a/manager/server.selfhosted.js +++ b/manager/server.selfhosted.js @@ -32,7 +32,13 @@ import { getCobrancasByInstallmentId, updateCobrancaLinkCarne, updateCobrancaByField, initNotasTable, getNotasByAluno, upsertNota, - syncJsonToRelationalTables + syncJsonToRelationalTables, + getFuncionarios, getCategoriasFuncionarios, + insertFuncionario, updateFuncionario, deleteFuncionario, + insertCategoriaFuncionario, updateCategoriaFuncionario, deleteCategoriaFuncionario, + getCursos, insertCurso, updateCurso, deleteCurso, + getTurmas, insertTurma, updateTurma, deleteTurma, + getDisciplinas, insertDisciplina, updateDisciplina, deleteDisciplina } from './services/database.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'; @@ -384,6 +390,227 @@ app.post('/api/notas', async (req, res) => { } }); +// ============================================================ +// ROTAS DE CURSOS (MIGRAÇÃO FASE 3) +// ============================================================ +app.get('/api/cursos', async (req, res) => { + try { + const cursos = await getCursos(); + res.json({ cursos }); + } catch (error) { + console.error('Erro ao buscar cursos:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.post('/api/cursos', async (req, res) => { + try { + await insertCurso(req.body); + res.json({ success: true }); + } catch (error) { + console.error('Erro ao criar curso:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.put('/api/cursos/:id', async (req, res) => { + try { + await updateCurso(req.params.id, req.body); + res.json({ success: true }); + } catch (error) { + console.error('Erro ao atualizar curso:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.delete('/api/cursos/:id', async (req, res) => { + try { + await deleteCurso(req.params.id); + res.json({ success: true }); + } catch (error) { + console.error('Erro ao deletar curso:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +// ============================================================ +// ROTAS DE DISCIPLINAS (MIGRAÇÃO FASE 3) +// ============================================================ +app.get('/api/disciplinas', async (req, res) => { + try { + const disciplinas = await getDisciplinas(); + res.json({ disciplinas }); + } catch (error) { + console.error('Erro ao buscar disciplinas:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.post('/api/disciplinas', async (req, res) => { + try { + await insertDisciplina(req.body); + res.json({ success: true }); + } catch (error) { + console.error('Erro ao criar disciplina:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.put('/api/disciplinas/:id', async (req, res) => { + try { + await updateDisciplina(req.params.id, req.body); + res.json({ success: true }); + } catch (error) { + console.error('Erro ao atualizar disciplina:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.delete('/api/disciplinas/:id', async (req, res) => { + try { + await deleteDisciplina(req.params.id); + res.json({ success: true }); + } catch (error) { + console.error('Erro ao deletar disciplina:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +// ============================================================ +// ROTAS DE TURMAS (MIGRAÇÃO FASE 3) +// ============================================================ +app.get('/api/turmas', async (req, res) => { + try { + const turmas = await getTurmas(); + res.json({ turmas }); + } catch (error) { + console.error('Erro ao buscar turmas:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.post('/api/turmas', async (req, res) => { + try { + await insertTurma(req.body); + res.json({ success: true }); + } catch (error) { + console.error('Erro ao criar turma:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.put('/api/turmas/:id', async (req, res) => { + try { + await updateTurma(req.params.id, req.body); + res.json({ success: true }); + } catch (error) { + console.error('Erro ao atualizar turma:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.delete('/api/turmas/:id', async (req, res) => { + try { + await deleteTurma(req.params.id); + res.json({ success: true }); + } catch (error) { + console.error('Erro ao deletar turma:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +// ============================================================ +// ROTAS DE FUNCIONÁRIOS (MIGRAÇÃO FASE 1) +// ============================================================ + +app.get('/api/categorias_funcionarios', async (req, res) => { + try { + const categorias = await getCategoriasFuncionarios(); + res.json({ categorias }); + } catch (error) { + console.error('Erro ao buscar categorias:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.post('/api/categorias_funcionarios', async (req, res) => { + try { + const data = req.body; + await insertCategoriaFuncionario(data); + res.json({ success: true, categoria: data }); + } catch (error) { + console.error('Erro ao criar categoria:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.put('/api/categorias_funcionarios/:id', async (req, res) => { + try { + const { id } = req.params; + const { nome } = req.body; + await updateCategoriaFuncionario(id, nome); + res.json({ success: true }); + } catch (error) { + console.error('Erro ao atualizar categoria:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.delete('/api/categorias_funcionarios/:id', async (req, res) => { + try { + const { id } = req.params; + await deleteCategoriaFuncionario(id); + res.json({ success: true }); + } catch (error) { + console.error('Erro ao deletar categoria:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.get('/api/funcionarios', async (req, res) => { + try { + const funcionarios = await getFuncionarios(); + res.json({ funcionarios }); + } catch (error) { + console.error('Erro ao buscar funcionarios:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.post('/api/funcionarios', async (req, res) => { + try { + const data = req.body; + await insertFuncionario(data); + res.json({ success: true, funcionario: data }); + } catch (error) { + console.error('Erro ao criar funcionario:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.put('/api/funcionarios/:id', async (req, res) => { + try { + const { id } = req.params; + const updateData = req.body; + await updateFuncionario(id, updateData); + res.json({ success: true }); + } catch (error) { + console.error('Erro ao atualizar funcionario:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + +app.delete('/api/funcionarios/:id', async (req, res) => { + try { + const { id } = req.params; + await deleteFuncionario(id); + res.json({ success: true }); + } catch (error) { + console.error('Erro ao deletar funcionario:', error); + res.status(500).json({ error: 'Erro interno' }); + } +}); + // ============================================================ // Upload de Logo (MinIO em vez de Supabase Storage) // ============================================================ diff --git a/manager/services/database.js b/manager/services/database.js index e4a991d..d765664 100644 --- a/manager/services/database.js +++ b/manager/services/database.js @@ -367,6 +367,177 @@ export async function deleteNotasManuaisAusentes(alunoId, notasManuaisRetidas) { // Implementaremos a limpeza iterativamente na rota } +// ============================================================ +// HELPERS: cursos e turmas +// ============================================================ +export async function getCursos() { + const { rows } = await pool.query('SELECT * FROM cursos ORDER BY nome ASC'); + return rows; +} + +export async function insertCurso(c) { + await pool.query( + `INSERT INTO cursos (id, nome, duracao, duracao_meses, taxa_matricula, mensalidade, descricao, multa_percentual, juros_percentual) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [c.id, c.nome, c.duracao || '', c.duracao_meses || 12, c.taxa_matricula || 0, c.mensalidade || 0, c.descricao || '', c.multa_percentual || 0, c.juros_percentual || 0] + ); +} + +export async function updateCurso(id, updateData) { + const setClauses = []; + const values = []; + let i = 1; + + for (const [key, value] of Object.entries(updateData)) { + if (value !== undefined) { + setClauses.push(`${key} = $${i}`); + values.push(value); + i++; + } + } + + if (setClauses.length === 0) return; + + values.push(id); + await pool.query( + `UPDATE cursos SET ${setClauses.join(', ')} WHERE id = $${i}`, + values + ); +} + +export async function deleteCurso(id) { + await pool.query('DELETE FROM cursos WHERE id = $1', [id]); +} + +export async function getTurmas() { + const { rows } = await pool.query('SELECT * FROM turmas ORDER BY nome ASC'); + return rows; +} + +export async function insertTurma(t) { + await pool.query( + `INSERT INTO turmas (id, nome, curso_id, professor, horario, dia_semana, max_alunos, data_inicio, data_fim, horario_inicio_padrao, horario_fim_padrao) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [t.id, t.nome, t.curso_id || null, t.professor || '', t.horario || '', t.dia_semana || null, t.max_alunos || 30, t.data_inicio || null, t.data_fim || null, t.horario_inicio_padrao || null, t.horario_fim_padrao || null] + ); +} + +export async function updateTurma(id, updateData) { + const setClauses = []; + const values = []; + let i = 1; + + for (const [key, value] of Object.entries(updateData)) { + if (value !== undefined) { + setClauses.push(`${key} = $${i}`); + values.push(value); + i++; + } + } + + if (setClauses.length === 0) return; + + values.push(id); + await pool.query( + `UPDATE turmas SET ${setClauses.join(', ')} WHERE id = $${i}`, + values + ); +} + +export async function deleteTurma(id) { + await pool.query('DELETE FROM turmas WHERE id = $1', [id]); +} + +export async function getDisciplinas() { + const { rows } = await pool.query('SELECT * FROM disciplinas ORDER BY nome ASC'); + return rows; +} + +export async function insertDisciplina(d) { + await pool.query( + `INSERT INTO disciplinas (id, nome) VALUES ($1, $2)`, + [d.id, d.nome] + ); +} + +export async function updateDisciplina(id, updateData) { + if (updateData.nome !== undefined) { + await pool.query( + `UPDATE disciplinas SET nome = $1 WHERE id = $2`, + [updateData.nome, id] + ); + } +} + +export async function deleteDisciplina(id) { + await pool.query('DELETE FROM disciplinas WHERE id = $1', [id]); +} + +// ============================================================ +// HELPERS: funcionarios e categorias_funcionarios +// ============================================================ +export async function getFuncionarios() { + const { rows } = await pool.query('SELECT * FROM funcionarios ORDER BY nome ASC'); + return rows; +} + +export async function getCategoriasFuncionarios() { + const { rows } = await pool.query('SELECT * FROM categorias_funcionarios ORDER BY nome ASC'); + return rows; +} + +export async function insertFuncionario(f) { + await pool.query( + `INSERT INTO funcionarios (id, nome, cpf, email, telefone, categoria_id, data_admissao) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [f.id, f.nome, f.cpf, f.email, f.telefone, f.categoria_id, f.data_admissao || null] + ); +} + +export async function updateFuncionario(id, updateData) { + const setClauses = []; + const values = []; + let i = 1; + + for (const [key, value] of Object.entries(updateData)) { + if (value !== undefined) { + setClauses.push(`${key} = $${i}`); + values.push(value); + i++; + } + } + + if (setClauses.length === 0) return; + + values.push(id); + await pool.query( + `UPDATE funcionarios SET ${setClauses.join(', ')} WHERE id = $${i}`, + values + ); +} + +export async function deleteFuncionario(id) { + await pool.query('DELETE FROM funcionarios WHERE id = $1', [id]); +} + +export async function insertCategoriaFuncionario(c) { + await pool.query( + `INSERT INTO categorias_funcionarios (id, nome) VALUES ($1, $2)`, + [c.id, c.nome] + ); +} + +export async function updateCategoriaFuncionario(id, nome) { + await pool.query( + `UPDATE categorias_funcionarios SET nome = $1 WHERE id = $2`, + [nome, id] + ); +} + +export async function deleteCategoriaFuncionario(id) { + await pool.query('DELETE FROM categorias_funcionarios WHERE id = $1', [id]); +} + // ============================================================ // SINCRONIZAÇÃO: JSON -> TABELAS RELACIONAIS // Garante que IDs do JSON existam nas tabelas para evitar erro de Foreign Key @@ -408,6 +579,34 @@ export async function syncJsonToRelationalTables() { } } + // 1.5 Sincronizar Categorias de Funcionários + if (data.employeeCategories && Array.isArray(data.employeeCategories)) { + for (const cat of data.employeeCategories) { + if (!cat.id || !cat.name) continue; + await client.query( + `INSERT INTO categorias_funcionarios (id, nome) VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome`, + [cat.id, cat.name] + ); + } + } + + // 1.6 Sincronizar Funcionários + if (data.employees && Array.isArray(data.employees)) { + for (const emp of data.employees) { + if (!emp.id || !emp.name) continue; + await client.query( + `INSERT INTO funcionarios (id, nome, cpf, email, telefone, categoria_id, data_admissao) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET + nome = EXCLUDED.nome, cpf = EXCLUDED.cpf, email = EXCLUDED.email, + telefone = EXCLUDED.telefone, categoria_id = EXCLUDED.categoria_id, + data_admissao = EXCLUDED.data_admissao`, + [emp.id, emp.name, emp.cpf || '', emp.email || '', emp.phone || '', emp.categoryId || null, emp.admissionDate || null] + ); + } + } + // Garantir colunas de refação em provas await client.query('ALTER TABLE provas ADD COLUMN IF NOT EXISTS permitir_refacao BOOLEAN DEFAULT FALSE');