feat: Migracao SQL-First (Fase 1 e Fase 3) - Funcionarios, Cursos, Turmas e Disciplinas

This commit is contained in:
Sidney 2026-05-24 17:57:37 -03:00
parent 9fe6882174
commit 2e0a041a26
9 changed files with 929 additions and 109 deletions

View File

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

View File

@ -21,7 +21,6 @@ const Classes: React.FC<ClassesProps> = ({ data, updateData, onNavigateToClass }
const [scheduleClass, setScheduleClass] = useState<Class | null>(null); // For LessonSchedule component
const [viewingStudentsClass, setViewingStudentsClass] = useState<Class | null>(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;
@ -35,6 +34,52 @@ const Classes: React.FC<ClassesProps> = ({ data, updateData, onNavigateToClass }
return url;
};
const [classes, setClasses] = useState<Class[]>([]);
const [courses, setCourses] = useState<any[]>([]); // To display names correctly
const [employees, setEmployees] = useState<any[]>([]); // To display names
const [isLoadingData, setIsLoadingData] = useState(true);
const loadData = async () => {
try {
setIsLoadingData(true);
const [clsRes, crsRes, empRes] = await Promise.all([
fetch('/api/turmas'),
fetch('/api/cursos'),
fetch('/api/funcionarios')
]);
const clsData = await clsRes.json();
const crsData = await crsRes.json();
const empData = await empRes.json();
const mappedClasses = (clsData.turmas || []).map((t: any) => ({
id: t.id,
name: t.nome,
courseId: t.curso_id,
teacher: t.professor,
schedule: t.horario,
scheduleDay: t.dia_semana,
maxStudents: Number(t.max_alunos || 0),
startDate: t.data_inicio ? t.data_inicio.substring(0, 10) : '',
endDate: t.data_fim ? t.data_fim.substring(0, 10) : '',
defaultStartTime: t.horario_inicio_padrao,
defaultEndTime: t.horario_fim_padrao
}));
setClasses(mappedClasses);
setCourses(crsData.cursos || []);
setEmployees(empData.funcionarios || []);
} catch (err) {
console.error('Erro ao buscar turmas/cursos/funcionários:', err);
showAlert('Erro', 'Falha ao carregar dados do servidor.', 'error');
} finally {
setIsLoadingData(false);
}
};
React.useEffect(() => {
loadData();
}, []);
const [formData, setFormData] = useState<Omit<Class, 'id'>>({
name: '',
courseId: '',
@ -57,16 +102,16 @@ const Classes: React.FC<ClassesProps> = ({ 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<ClassesProps> = ({ 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<ClassesProps> = ({ 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<ClassesProps> = ({ data, updateData, onNavigateToClass }
<div className="space-y-6 animate-in fade-in duration-300">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Turmas</h2>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Turmas <span className="text-sm font-normal text-green-600 bg-green-50 px-2 py-1 rounded-md ml-2 border border-green-200">PostgreSQL</span></h2>
<p className="text-slate-500">Controle de horários e ocupação das salas.</p>
</div>
<button
@ -240,10 +323,10 @@ const Classes: React.FC<ClassesProps> = ({ data, updateData, onNavigateToClass }
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{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<ClassesProps> = ({ data, updateData, onNavigateToClass }
</span>
)}
</div>
<span className="text-[10px] font-black text-indigo-600 uppercase tracking-[0.2em]">{course?.name || 'Sem Curso Vinculado'}</span>
<span className="text-[10px] font-black text-indigo-600 uppercase tracking-[0.2em]">{course?.nome || course?.name || 'Sem Curso Vinculado'}</span>
{cls.defaultStartTime && cls.defaultEndTime && (
<div className="mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 bg-indigo-50 text-indigo-700 text-xs font-bold rounded-lg border border-indigo-100">
<Clock size={12} /> {cls.defaultStartTime} - {cls.defaultEndTime}
@ -380,7 +463,7 @@ const Classes: React.FC<ClassesProps> = ({ data, updateData, onNavigateToClass }
</div>
);
})}
{data.classes.length === 0 && (
{classes.length === 0 && (
<div className="col-span-full py-20 text-center text-slate-400 border-4 border-dashed border-slate-200 rounded-xl">
<Book size={48} className="mx-auto mb-4 opacity-10" />
<p className="font-bold text-lg">Nenhuma turma cadastrada ainda.</p>
@ -419,7 +502,7 @@ const Classes: React.FC<ClassesProps> = ({ data, updateData, onNavigateToClass }
<select required className={inputClass}
value={formData.courseId} onChange={e => setFormData({...formData, courseId: e.target.value})}>
<option value="">Selecione um curso...</option>
{data.courses.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
{courses.map((c: any) => <option key={c.id} value={c.id}>{c.nome}</option>)}
</select>
</div>
</div>
@ -469,15 +552,11 @@ const Classes: React.FC<ClassesProps> = ({ data, updateData, onNavigateToClass }
<select required className={inputClass}
value={formData.teacher} onChange={e => setFormData({...formData, teacher: e.target.value})}>
<option value="">Selecione um professor...</option>
{(data.employees || [])
.filter(e => {
const catName = (data.employeeCategories || []).find(c => c.id === e.categoryId)?.name?.toLowerCase() || '';
return catName.includes('professor') || catName.includes('prof');
})
.map(emp => (
<option key={emp.id} value={emp.name}>{emp.name}</option>
{employees
.map((emp: any) => (
<option key={emp.id} value={emp.nome || emp.name}>{emp.nome || emp.name}</option>
))}
{formData.teacher && !(data.employees || []).some(e => e.name === formData.teacher) && (
{formData.teacher && !employees.some((e: any) => (e.nome || e.name) === formData.teacher) && (
<option value={formData.teacher}>{formData.teacher} (Manual)</option>
)}
</select>

View File

@ -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<SchoolData>) => void;
}
@ -30,7 +30,41 @@ const Courses: React.FC<CoursesProps> = ({ data, updateData }) => {
return match ? parseInt(match[0]) : 12;
};
const handleSubmit = (e: React.FormEvent) => {
const [courses, setCourses] = useState<Course[]>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
const loadData = async () => {
try {
setIsLoadingData(true);
const res = await fetch('/api/cursos');
const json = await res.json();
const mappedCourses = (json.cursos || []).map((c: any) => ({
id: c.id,
name: c.nome,
duration: c.duracao,
durationMonths: c.duracao_meses,
registrationFee: Number(c.taxa_matricula || 0),
monthlyFee: Number(c.mensalidade || 0),
description: c.descricao,
finePercentage: Number(c.multa_percentual || 0),
interestPercentage: Number(c.juros_percentual || 0)
}));
setCourses(mappedCourses);
} catch (err) {
console.error('Erro ao buscar cursos:', err);
showAlert('Erro', 'Falha ao carregar cursos do servidor.', 'error');
} finally {
setIsLoadingData(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.duration || formData.monthlyFee <= 0) {
@ -45,14 +79,38 @@ const Courses: React.FC<CoursesProps> = ({ 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<CoursesProps> = ({ 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<CoursesProps> = ({ data, updateData }) => {
<div className="space-y-6 animate-in fade-in duration-300">
<div className="flex justify-between items-center">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Cursos</h2>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Cursos <span className="text-sm font-normal text-green-600 bg-green-50 px-2 py-1 rounded-md ml-2 border border-green-200">PostgreSQL</span></h2>
<p className="text-slate-500">Gerencie os cursos oferecidos pela escola.</p>
</div>
<button onClick={() => setIsModalOpen(true)} className="bg-indigo-600 text-white px-6 py-3 rounded-xl flex items-center gap-2 hover:bg-indigo-700 transition-all shadow-lg font-bold"><Plus size={20} /> Novo Curso</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{data.courses.map(course => (
{courses.map(course => (
<div key={course.id} className="bg-white p-7 rounded-xl border border-slate-200 shadow-sm hover:shadow-xl transition-all group flex flex-col h-full relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 bg-white/80 backdrop-blur-sm rounded-bl-2xl">
<button onClick={() => handleEdit(course)} className="p-2 text-slate-400 hover:text-indigo-600 rounded-lg hover:bg-indigo-50 transition-all"><Edit2 size={16} /></button>

View File

@ -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<SchoolData>) => void;
}
const Employees: React.FC<EmployeesProps> = ({ 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<EmployeesProps> = ({ data, updateData }) => {
const [categoryFormData, setCategoryFormData] = useState({ name: '' });
const employees = data.employees || [];
const categories = data.employeeCategories || [];
const [employees, setEmployees] = useState<Employee[]>([]);
const [categories, setCategories] = useState<EmployeeCategory[]>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
const loadData = async () => {
try {
setIsLoadingData(true);
const [empRes, catRes] = await Promise.all([
fetch('/api/funcionarios'),
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<EmployeesProps> = ({ 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<EmployeesProps> = ({ 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<EmployeesProps> = ({ 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<EmployeesProps> = ({ data, updateData }) => {
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Funcionários</h2>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Funcionários <span className="text-sm font-normal text-green-600 bg-green-50 px-2 py-1 rounded-md ml-2 border border-green-200">PostgreSQL</span></h2>
<p className="text-slate-500">Gerencie sua equipe e categorias profissionais.</p>
</div>
<div className="flex gap-2 w-full md:w-auto">
@ -252,7 +323,7 @@ const Employees: React.FC<EmployeesProps> = ({ data, updateData }) => {
})}
</div>
{employees.length === 0 && (
{!isLoadingData && employees.length === 0 && (
<div className="bg-white border-2 border-dashed border-slate-200 rounded-3xl p-12 text-center">
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-4 text-slate-300">
<Users size={40} />

View File

@ -37,10 +37,30 @@ const ReportCard: React.FC<ReportCardProps> = ({ data, updateData }) => {
const [studentSubmissions, setStudentSubmissions] = useState<Record<string, {acertos: number, erros: number}>>({}); // examId -> { acertos, erros }
const [classGrades, setClassGrades] = useState<Grade[]>([]);
const subjects = data.subjects || [];
const [subjects, setSubjects] = useState<Subject[]>([]);
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<ReportCardProps> = ({ 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<ReportCardProps> = ({ 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');
}
}
);
};

View File

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

View File

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

View File

@ -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)
// ============================================================

View File

@ -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');