feat: edicao individual e propagacao de modelos de contratos e sync sql

This commit is contained in:
Sidney 2026-05-24 20:35:31 -03:00
parent e33a5aac3d
commit d4b73df9b4
6 changed files with 208 additions and 44 deletions

View File

@ -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.

View File

@ -88,52 +88,58 @@ const Contracts: React.FC<ContractsProps> = ({ 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<ContractsProps> = ({ 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<ContractsProps> = ({ 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<ContractsProps> = ({ data, updateData }) => {
<Printer size={20} />
)}
</button>
<button
onClick={() => {
setFormData({ studentId: contract.studentId, title: contract.title, content: contract.content, ...( { id: contract.id } as any ) });
setIsModalOpen(true);
}}
className="p-3 bg-white border border-slate-200 text-slate-400 hover:text-blue-600 rounded-xl transition-all shadow-sm"
title="Editar Contrato"
>
<Edit2 size={20} />
</button>
<button onClick={() => handleDelete(contract.id)} className="p-3 bg-white border border-slate-200 text-slate-400 hover:text-red-600 rounded-xl transition-all shadow-sm" title="Excluir"><Trash2 size={20} /></button>
</td>
</tr>

View File

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

View File

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

View File

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

View File

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