diff --git a/GEMINI.md b/GEMINI.md index 066fe9f..098a8cb 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -66,4 +66,7 @@ 37. **Server Entry Point Safety**: The original `server.js` files in both Manager and Portal are OBSOLETE and kept only for historical context. You MUST NEVER modify or edit `server.js`. All backend changes must be applied exclusively to `server.selfhosted.js`. 38. **Database Connection & MCP Integration**: Direct PostgreSQL access has been successfully configured via the `@modelcontextprotocol/server-postgres` MCP server to connect directly to the production database on the VPS (`150.230.87.131`). Database telemetry and operations can be executed via MCP SQL tools. 39. **Portal SQL-First Migration**: O Portal do Aluno consome dados majoritariamente de tabelas PostgreSQL (`alunos`, `aulas`, `frequencias`, `contratos`) usando `SELECT` e implementa Fallbacks legados lendo do JSON apenas se o BD retornar vazio. - +40. **CamelCase Backend Mapping**: Funções do backend que buscam dados do PostgreSQL (`getAlunos`, `getProvas`, `getFuncionarios`) DEVEM mapear as chaves de `snake_case` (ex: `turma_id`, `is_deleted`) para `camelCase` (ex: `classId`, `isDeleted`) antes de enviar para o Frontend. O Frontend deve usar fallbacks (`p.classId || p.turma_id`) durante a fase de transição para evitar quebras em views legadas (White Screen) e perdas de filtro de contagem (ex: "0 alunos"). +41. **Database Schema Sync**: Sempre garanta que as tabelas de destino possuam colunas como `is_deleted` e demais modificadores antes de habilitar rotas PUT/DELETE, pois a ausência do schema falhará silenciosamente no Express 5. +42. **Cronological Display Standard**: Consultas a cadastros secundários (como Disciplinas e Categorias) DEVEM utilizar `ORDER BY created_at ASC` no SQL para manter a mesma ordem de listagem original do sistema (evitando reordenação alfabética não-intencional). +43. **Mass Contracts Update**: A edição de um "Modelo de Contrato" dispara atualizações em massa (PUT `/api/contratos/:id`) recalcularizando as variáveis de todos os contratos já emitidos para os alunos vinculados àquele modelo, garantindo atualização em tempo real no Portal do Aluno via SQL. diff --git a/manager/components/Contracts.tsx b/manager/components/Contracts.tsx index f58974b..799d91a 100644 --- a/manager/components/Contracts.tsx +++ b/manager/components/Contracts.tsx @@ -88,52 +88,58 @@ const Contracts: React.FC = ({ data, updateData }) => { content: '' }); + const generateContractContent = (student: any, templateContent: string) => { + const cls = dbClasses.find(c => c.id === student?.classId); + const course = dbCourses.find(c => c.id === cls?.courseId); + if (!student || !course) return templateContent; + + let template = templateContent; + // Aluno + template = template.replace(/{{aluno}}/g, student.name || ''); + template = template.replace(/{{aluno_cpf}}/g, student.cpf || ''); + template = template.replace(/{{aluno_rg}}/g, student.rg || ''); + template = template.replace(/{{aluno_nascimento}}/g, student.birthDate ? new Date(student.birthDate).toLocaleDateString('pt-BR') : ''); + template = template.replace(/{{aluno_email}}/g, student.email || ''); + template = template.replace(/{{aluno_telefone}}/g, student.phone || ''); + template = template.replace(/{{aluno_cep}}/g, student.addressZip || ''); + template = template.replace(/{{aluno_endereco}}/g, `${student.addressStreet || ''}, ${student.addressNumber || ''}`); + template = template.replace(/{{aluno_bairro}}/g, student.addressNeighborhood || ''); + template = template.replace(/{{aluno_cidade}}/g, student.addressCity || ''); + template = template.replace(/{{aluno_estado}}/g, student.addressState || ''); + + // Responsável + template = template.replace(/{{responsavel_nome}}/g, student.guardianName || ''); + template = template.replace(/{{responsavel_cpf}}/g, student.guardianCpf || ''); + template = template.replace(/{{responsavel_nascimento}}/g, student.guardianBirthDate ? new Date(student.guardianBirthDate).toLocaleDateString('pt-BR') : ''); + + // Curso e Turma + template = template.replace(/{{curso}}/g, course.name || ''); + template = template.replace(/{{mensalidade}}/g, course.monthlyFee ? `R$ ${course.monthlyFee.toFixed(2)}` : 'R$ 0,00'); + template = template.replace(/{{duracao}}/g, course.duration || ''); + template = template.replace(/{{curso_taxa_matricula}}/g, course.registrationFee ? `R$ ${course.registrationFee.toFixed(2)}` : 'R$ 0,00'); + template = template.replace(/{{turma_nome}}/g, cls?.name || ''); + template = template.replace(/{{turma_professor}}/g, cls?.teacher || ''); + template = template.replace(/{{turma_horario}}/g, cls?.schedule || ''); + + // Escola + template = template.replace(/{{data}}/g, new Date().toLocaleDateString('pt-BR')); + template = template.replace(/{{escola}}/g, data.profile.name || ''); + template = template.replace(/{{cnpj_escola}}/g, data.profile.cnpj || ''); + + return template; + }; + // Pre-load content when student is selected based on template useEffect(() => { if (formData.studentId && !formData.content) { const student = data.students.find(s => s.id === formData.studentId); - const cls = dbClasses.find(c => c.id === student?.classId); - const course = dbCourses.find(c => c.id === cls?.courseId); const templateObj = dbTemplates?.find(t => t.id === student?.contractTemplateId); - if (student && course) { - let template = templateObj?.content || ''; - - // Aluno - template = template.replace(/{{aluno}}/g, student.name || ''); - template = template.replace(/{{aluno_cpf}}/g, student.cpf || ''); - template = template.replace(/{{aluno_rg}}/g, student.rg || ''); - template = template.replace(/{{aluno_nascimento}}/g, student.birthDate ? new Date(student.birthDate).toLocaleDateString('pt-BR') : ''); - template = template.replace(/{{aluno_email}}/g, student.email || ''); - template = template.replace(/{{aluno_telefone}}/g, student.phone || ''); - template = template.replace(/{{aluno_cep}}/g, student.addressZip || ''); - template = template.replace(/{{aluno_endereco}}/g, `${student.addressStreet || ''}, ${student.addressNumber || ''}`); - template = template.replace(/{{aluno_bairro}}/g, student.addressNeighborhood || ''); - template = template.replace(/{{aluno_cidade}}/g, student.addressCity || ''); - template = template.replace(/{{aluno_estado}}/g, student.addressState || ''); - - // Responsável - template = template.replace(/{{responsavel_nome}}/g, student.guardianName || ''); - template = template.replace(/{{responsavel_cpf}}/g, student.guardianCpf || ''); - template = template.replace(/{{responsavel_nascimento}}/g, student.guardianBirthDate ? new Date(student.guardianBirthDate).toLocaleDateString('pt-BR') : ''); - - // Curso e Turma - template = template.replace(/{{curso}}/g, course.name || ''); - template = template.replace(/{{mensalidade}}/g, course.monthlyFee ? `R$ ${course.monthlyFee.toFixed(2)}` : 'R$ 0,00'); - template = template.replace(/{{duracao}}/g, course.duration || ''); - template = template.replace(/{{curso_taxa_matricula}}/g, course.registrationFee ? `R$ ${course.registrationFee.toFixed(2)}` : 'R$ 0,00'); - template = template.replace(/{{turma_nome}}/g, cls?.name || ''); - template = template.replace(/{{turma_professor}}/g, cls?.teacher || ''); - template = template.replace(/{{turma_horario}}/g, cls?.schedule || ''); - - // Escola - template = template.replace(/{{data}}/g, new Date().toLocaleDateString('pt-BR')); - template = template.replace(/{{escola}}/g, data.profile.name || ''); - template = template.replace(/{{cnpj_escola}}/g, data.profile.cnpj || ''); - + if (student) { + const finalContent = generateContractContent(student, templateObj?.content || ''); setFormData(prev => ({ ...prev, - content: template, + content: finalContent, title: prev.title || `Contrato de Matrícula - ${student.name}` })); } @@ -158,11 +164,33 @@ const Contracts: React.FC = ({ data, updateData }) => { return; } + if ((formData as any).id) { + // Editar individual + fetch(`/api/contratos/${(formData as any).id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData) + }).then(() => loadData()); + + updateData({ + contracts: dbContracts.map(c => c.id === (formData as any).id ? { ...c, ...formData } : c) + }); + closeModal(); + return; + } + const newContract: Contract = { ...formData, id: crypto.randomUUID(), createdAt: new Date().toISOString() }; + + fetch('/api/contratos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newContract) + }).then(() => loadData()); + updateData({ contracts: [...dbContracts, newContract] }); closeModal(); }; @@ -188,7 +216,30 @@ const Contracts: React.FC = ({ data, updateData }) => { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(templateFormData) - }).then(() => loadData()); + }).then(async () => { + // Propagar para todos os alunos que usam este template e já tem contrato gerado + const affectedStudents = data.students.filter(s => s.contractTemplateId === templateFormData.id); + const promises = []; + + affectedStudents.forEach(student => { + const existingContract = dbContracts.find(c => c.studentId === student.id); + if (existingContract) { + const newContent = generateContractContent(student, templateFormData.content); + promises.push( + fetch(`/api/contratos/${existingContract.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: existingContract.title, content: newContent }) + }) + ); + } + }); + + if (promises.length > 0) { + await Promise.all(promises); + } + loadData(); + }); } else { fetch('/api/modelos-contrato', { method: 'POST', @@ -409,6 +460,16 @@ const Contracts: React.FC = ({ data, updateData }) => { )} + diff --git a/manager/scratch/alter_provas.cjs b/manager/scratch/alter_provas.cjs new file mode 100644 index 0000000..d7d399a --- /dev/null +++ b/manager/scratch/alter_provas.cjs @@ -0,0 +1,23 @@ +const { Pool } = require('pg'); + +const pool = new Pool({ + host: '150.230.87.131', + port: 5432, + database: 'edumanager', + user: 'edumanager', + password: 'EduManager2026!Seguro', + ssl: false +}); + +async function runAlter() { + try { + await pool.query("ALTER TABLE provas ADD COLUMN is_deleted BOOLEAN DEFAULT false"); + await pool.query("ALTER TABLE provas ADD COLUMN evaluation_type TEXT DEFAULT 'exam'"); + console.log("Colunas adicionadas!"); + } catch(e) { + console.log(e.message); + } finally { + await pool.end(); + } +} +runAlter(); diff --git a/manager/scratch/migrate_questoes.cjs b/manager/scratch/migrate_questoes.cjs new file mode 100644 index 0000000..d07d6e1 --- /dev/null +++ b/manager/scratch/migrate_questoes.cjs @@ -0,0 +1,70 @@ +const { Pool } = require('pg'); + +const pool = new Pool({ + host: '150.230.87.131', + port: 5432, + database: 'edumanager', + user: 'edumanager', + password: 'EduManager2026!Seguro', + ssl: false +}); + +async function migrateQuestoes() { + const client = await pool.connect(); + try { + const { rows } = await client.query('SELECT data FROM school_data LIMIT 1'); + if (!rows.length) { console.log('school_data vazio!'); return; } + + const exams = rows[0].data.exams || []; + console.log(`\n📄 Verificando ${exams.length} provas no JSON...`); + + let totalQuestions = 0; + let questionsMigrated = 0; + + for (const exam of exams) { + if (!exam.questions || exam.questions.length === 0) continue; + + const pId = exam.id; + // Verificar se a prova já existe no banco + const checkProva = await client.query('SELECT id FROM provas WHERE id = $1', [pId]); + if (checkProva.rows.length === 0) { + console.warn(`Prova ${pId} não encontrada no BD SQL. Ignorando questoes.`); + continue; + } + + for (let i = 0; i < exam.questions.length; i++) { + const q = exam.questions[i]; + totalQuestions++; + try { + await client.query( + `INSERT INTO questoes_provas (id, prova_id, texto, opcoes, indice_correto, ordem, imagem_url) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO NOTHING`, + [ + q.id || `q-${pId}-${i}`, + pId, + q.text || '', + JSON.stringify(q.options || []), + q.correctAnswer !== undefined ? parseInt(q.correctAnswer) : 0, + i, + q.imageUrl || null + ] + ); + questionsMigrated++; + } catch (e) { + console.warn(`Erro na questão ${q.id}: ${e.message}`); + } + } + } + + console.log(`\n✅ Migração concluída: ${questionsMigrated}/${totalQuestions} questões migradas!`); + + } catch (err) { + console.error('❌ ERRO:', err); + } finally { + client.release(); + await pool.end(); + } +} + +migrateQuestoes(); diff --git a/manager/server.selfhosted.js b/manager/server.selfhosted.js index 9f1f91a..e5b9eca 100644 --- a/manager/server.selfhosted.js +++ b/manager/server.selfhosted.js @@ -41,7 +41,7 @@ import { getDisciplinas, insertDisciplina, updateDisciplina, deleteDisciplina, getAlunos, insertAluno, updateAluno, deleteAluno, getModelosContrato, insertModeloContrato, updateModeloContrato, deleteModeloContrato, - getContratos, insertContrato, deleteContrato, + getContratos, insertContrato, updateContrato, deleteContrato, getAulasByTurma, getAllAulas, insertAulas, deleteAulas, getProvas, getQuestoesDaProva, insertProva, updateProva, deleteProva, syncQuestoesProva } from './services/database.js'; @@ -546,6 +546,9 @@ app.get('/api/contratos', async (req, res) => { 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.put('/api/contratos/:id', async (req, res) => { + try { await updateContrato(req.params.id, req.body); res.json({ success: true }); } catch (e) { res.status(500).json({ error: 'Erro' }); } +}); 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' }); } }); diff --git a/manager/services/database.js b/manager/services/database.js index 3dece29..b9734cf 100644 --- a/manager/services/database.js +++ b/manager/services/database.js @@ -515,7 +515,7 @@ export async function deleteTurma(id) { } export async function getDisciplinas() { - const { rows } = await pool.query('SELECT * FROM disciplinas ORDER BY nome ASC'); + const { rows } = await pool.query('SELECT * FROM disciplinas ORDER BY created_at ASC'); return rows; } @@ -720,6 +720,10 @@ 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 updateContrato(id, c) { + await pool.query('UPDATE contratos SET titulo=$1, conteudo=$2 WHERE id=$3', [c.title, c.content, id]); +} + export async function deleteContrato(id) { await pool.query('DELETE FROM contratos WHERE id=$1', [id]); } @@ -820,9 +824,9 @@ export async function getProvas() { examId: q.prova_id, text: q.texto, options: q.opcoes || [], - correctAnswer: q.resposta_correta, + correctAnswer: q.indice_correto, order: q.ordem, - imageUrl: q.url_imagem + imageUrl: q.imagem_url })) }); }