diff --git a/TABELA_DE_MIGRACAO_SQL.md b/TABELA_DE_MIGRACAO_SQL.md index 54bc2d6..0244447 100644 --- a/TABELA_DE_MIGRACAO_SQL.md +++ b/TABELA_DE_MIGRACAO_SQL.md @@ -1,6 +1,6 @@ # 📊 Tabela de Migração SQL-First — EduManager -> **Última atualização:** 25/05/2026 às 19:17 (BRT) +> **Última atualização:** 28/05/2026 às 08:30 (BRT) > > Este documento rastreia o progresso da migração de cada módulo do sistema legado JSON (`school_data` no PostgreSQL) para tabelas relacionais estruturadas com arquitetura **SQL-First**. @@ -28,6 +28,7 @@ | **Frequências (Chamadas)** | `attendance` | `frequencias` | SQL Estruturado (`GET /api/frequencias` → estado `dbAttendance`) | SQL Estruturado (`POST`/`PUT`/`DELETE` `/api/frequencias`) | SQL Estruturado (Query direta no Postgres) | 🟢 **100% SQL-First** — Zero chamadas `dbService.saveData` no frontend. Reverse-sync automático no backend. | | **Aulas e Diários** | `lessons` | `aulas` | SQL Estruturado (`GET /api/aulas` → estado `dbLessons`) | SQL Estruturado (`POST`/`DELETE` `/api/aulas/lote`) | SQL Estruturado (Query direta no Postgres) | 🟢 **100% SQL-First** — Zero chamadas `dbService.saveData` no frontend. Reverse-sync automático no backend. | | **Contratos** | `contracts`, `contractTemplates` | `contratos`, `modelos_contrato` | SQL Estruturado (`GET /api/contratos` e `GET /api/modelos-contrato`) | SQL Estruturado (`POST`/`PUT`/`DELETE`) | SQL Estruturado (Lê de `contratos`) | 🟢 **100% SQL-First** — Zero chamadas `dbService.saveData` no frontend. Reverse-sync no backend. | +| **Pré-Matrícula** | _(novo módulo)_ | `prematricula_config`, `prematricula_campos`, `prematriculas` | SQL Estruturado (`GET /api/prematricula/*`) | SQL Estruturado (`POST`/`PUT`/`DELETE` `/api/prematricula/*`) | Página Pública (`/pre-matricula/:slug`) | 🟢 **100% SQL-First** — Módulo nativo SQL sem dependência de JSON. | | **Configurações Globais** | `profile`, `evolutionConfig`, `messageTemplates` | `configuracoes` | Pendente (Lê contexto JSON) | Pendente (Escreve via `dbService.saveData` no JSON) | Pendente (Lê do JSON) | 🔴 **Pendente** — Último bloco a ser migrado. | --- @@ -36,7 +37,7 @@ | Status | Qtd. Módulos | Módulos | |:---|:---:|:---| -| 🟢 100% SQL-First | **8** | Alunos, Financeiro, Funcionários, Boletim, Avaliações, Frequências, Aulas, Contratos | +| 🟢 100% SQL-First | **9** | Alunos, Financeiro, Funcionários, Boletim, Avaliações, Frequências, Aulas, Contratos, Pré-Matrícula | | 🟡 Híbrido | **0** | Nenhum | | 🔴 Pendente | **1** | Configurações Globais | diff --git a/manager/components/PreMatricula.tsx b/manager/components/PreMatricula.tsx new file mode 100644 index 0000000..af64315 --- /dev/null +++ b/manager/components/PreMatricula.tsx @@ -0,0 +1,444 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { SchoolData, PreMatriculaCampo, PreMatriculaConfig, PreMatriculaInscricao } from '../types'; +import { + Plus, Trash2, Save, Eye, EyeOff, Download, GripVertical, Link2, + ClipboardPen, Copy, Check, ChevronDown, ChevronRight, Users, RefreshCw, + FileText, X, Settings2, ArrowLeft, ExternalLink, AlertCircle +} from 'lucide-react'; + +interface Props { + data: SchoolData; + updateData: (d: Partial) => void; +} + +const FIELD_TYPES: Record = { + text: 'Texto', email: 'Email', phone: 'Telefone', cpf: 'CPF', + date: 'Data', select: 'Seleção', textarea: 'Texto Longo', number: 'Número' +}; + +const PreMatricula: React.FC = ({ data }) => { + const [config, setConfig] = useState(null); + const [campos, setCampos] = useState([]); + const [inscricoes, setInscricoes] = useState([]); + const [turmas, setTurmas] = useState([]); + const [activeTab, setActiveTab] = useState<'editor' | 'inscricoes'>('editor'); + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [copied, setCopied] = useState(false); + const [selectedTurma, setSelectedTurma] = useState('all'); + const [editingCampo, setEditingCampo] = useState(null); + const [showNewField, setShowNewField] = useState(false); + const [newField, setNewField] = useState({ label: '', tipo: 'text', placeholder: '', obrigatorio: false, opcoes: '' }); + + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const [cfgRes, camposRes, inscRes, turmasRes] = await Promise.all([ + fetch('/api/prematricula/config'), + fetch('/api/prematricula/campos'), + fetch('/api/prematricula/inscricoes'), + fetch('/api/turmas') + ]); + if (cfgRes.ok) { const j = await cfgRes.json(); setConfig(j.config); } + if (camposRes.ok) { const j = await camposRes.json(); setCampos(j.campos || []); } + if (inscRes.ok) { const j = await inscRes.json(); setInscricoes(j.inscricoes || []); } + if (turmasRes.ok) { const j = await turmasRes.json(); setTurmas(j.turmas || []); } + } catch (e) { console.error(e); } + finally { setIsLoading(false); } + }, []); + + useEffect(() => { loadData(); }, [loadData]); + + const saveConfig = async () => { + if (!config) return; + setIsSaving(true); + try { + await fetch('/api/prematricula/config', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + } catch (e) { console.error(e); } + finally { setIsSaving(false); } + }; + + const togglePublish = async () => { + if (!config) return; + const newStatus = config.status === 'published' ? 'draft' : 'published'; + const updated = { ...config, status: newStatus as 'draft' | 'published' }; + setConfig(updated); + await fetch('/api/prematricula/config', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updated) + }); + }; + + const addField = async () => { + if (!newField.label.trim()) return; + const campo = { + id: crypto.randomUUID(), + label: newField.label, + tipo: newField.tipo, + placeholder: newField.placeholder, + obrigatorio: newField.obrigatorio, + opcoes: newField.tipo === 'select' ? newField.opcoes.split(',').map(o => o.trim()).filter(Boolean) : [], + ordem: campos.length, + ativo: true + }; + try { + const res = await fetch('/api/prematricula/campos', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(campo) + }); + if (res.ok) { + setCampos(prev => [...prev, campo as PreMatriculaCampo]); + setNewField({ label: '', tipo: 'text', placeholder: '', obrigatorio: false, opcoes: '' }); + setShowNewField(false); + } + } catch (e) { console.error(e); } + }; + + const deleteField = async (id: string) => { + try { + const res = await fetch(`/api/prematricula/campos/${id}`, { method: 'DELETE' }); + if (res.ok) setCampos(prev => prev.filter(c => c.id !== id)); + } catch (e) { console.error(e); } + }; + + const updateField = async (campo: PreMatriculaCampo) => { + try { + await fetch(`/api/prematricula/campos/${campo.id}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(campo) + }); + setCampos(prev => prev.map(c => c.id === campo.id ? campo : c)); + setEditingCampo(null); + } catch (e) { console.error(e); } + }; + + const deleteInscricao = async (id: string) => { + try { + const res = await fetch(`/api/prematricula/inscricoes/${id}`, { method: 'DELETE' }); + if (res.ok) setInscricoes(prev => prev.filter(i => i.id !== id)); + } catch (e) { console.error(e); } + }; + + const exportCSV = (turmaId: string) => { + const filtered = turmaId === 'all' ? inscricoes : inscricoes.filter(i => i.turmaId === turmaId); + if (filtered.length === 0) return; + const allKeys = new Set(); + filtered.forEach(i => Object.keys(i.respostas || {}).forEach(k => allKeys.add(k))); + const headers = ['Nome', 'Email', 'Telefone', 'Turma', 'Status', 'Data', ...Array.from(allKeys)]; + const rows = filtered.map(i => [ + i.nome, i.email, i.telefone, i.turmaNome, i.status, + new Date(i.createdAt).toLocaleDateString('pt-BR'), + ...Array.from(allKeys).map(k => (i.respostas || {})[k] || '') + ]); + const csv = [headers.join(';'), ...rows.map(r => r.join(';'))].join('\n'); + const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); a.href = url; + const turmaName = turmaId === 'all' ? 'todas' : turmas.find(t => t.id === turmaId)?.nome || turmaId; + a.download = `pre-matriculas-${turmaName}.csv`; a.click(); + URL.revokeObjectURL(url); + }; + + const copyLink = () => { + if (!config) return; + const url = `${window.location.origin}/pre-matricula/${config.slug}`; + navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const filteredInscricoes = selectedTurma === 'all' + ? inscricoes : inscricoes.filter(i => i.turmaId === selectedTurma); + + const turmasComInscritos = turmas.map(t => ({ + ...t, count: inscricoes.filter(i => i.turmaId === t.id).length + })); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ + Pré-Matrícula +

+

Gerencie o formulário público de pré-matrícula e visualize inscrições.

+
+
+ + +
+
+ + {/* Tab Switcher */} +
+ + +
+ + {activeTab === 'editor' ? ( +
+ {/* Left: Config */} +
+
+

Configurações

+
+ + setConfig(prev => prev ? { ...prev, titulo: e.target.value } : prev)} /> +
+
+ + '; + }else{ + let t=f.tipo;if(t==='phone'||t==='cpf')t='text'; + html+=''; + } + html+='
'; + }); + html+=''; + app.innerHTML=html; + window._successMsg=c.mensagemSucesso||'Pré-matrícula enviada com sucesso!'; + }catch(e){app.innerHTML='

Erro

Não foi possível carregar o formulário.

'} +} +async function submitForm(e){ + e.preventDefault(); + const form=document.getElementById('preForm'); + const btn=document.getElementById('submitBtn'); + btn.disabled=true;btn.textContent='Enviando...'; + const fd=new FormData(form); + const nome=fd.get('_nome'),email=fd.get('_email'),telefone=fd.get('_telefone'); + const turmaSelect=form.querySelector('[name="_turma"]'); + const turmaId=turmaSelect?turmaSelect.value:''; + const turmaNome=turmaSelect?turmaSelect.options[turmaSelect.selectedIndex]?.dataset?.nome||'':''; + const respostas={}; + for(const[k,v]of fd.entries()){if(!k.startsWith('_'))respostas[k]=v} + try{ + const res=await fetch('/api/prematricula/submit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nome,email,telefone,turmaId,turmaNome,respostas})}); + if(res.ok){ + app.innerHTML='
âś…

Enviado com sucesso!

'+window._successMsg+'

'; + }else{ + const err=await res.json();alert(err.error||'Erro ao enviar.');btn.disabled=false;btn.textContent='Enviar Pré-Matrícula'; + } + }catch(err){alert('Erro de conexão.');btn.disabled=false;btn.textContent='Enviar Pré-Matrícula'} + return false; +} +init(); + +`; +} const sentCache = new Set(); const lockCache = new Set(); let activeCronJob = null; // Referência global para o agendamento preventivo @@ -2776,6 +2883,143 @@ async function startServer() { } catch (error) { return res.status(500).json({ error: 'Erro interno.' }); } }); + // =================================================== + // PRÉ-MATRÍCULA — ROTAS CRUD (SQL-First) + // =================================================== + + // Config + app.get('/api/prematricula/config', async (req, res) => { + try { + const { rows } = await pool.query('SELECT * FROM prematricula_config WHERE id = 1'); + const r = rows[0]; + if (!r) return res.json({ config: null }); + res.json({ config: { + id: r.id, titulo: r.titulo, descricao: r.descricao, slug: r.slug, + status: r.status, corPrimaria: r.cor_primaria, logoUrl: r.logo_url, + mensagemSucesso: r.mensagem_sucesso + }}); + } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } + }); + + app.put('/api/prematricula/config', async (req, res) => { + try { + const c = req.body; + await pool.query( + `UPDATE prematricula_config SET titulo=$1, descricao=$2, slug=$3, status=$4, + cor_primaria=$5, logo_url=$6, mensagem_sucesso=$7, updated_at=NOW() WHERE id=1`, + [c.titulo, c.descricao, c.slug, c.status, c.corPrimaria || '#4f46e5', c.logoUrl || '', c.mensagemSucesso || ''] + ); + res.json({ success: true }); + } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } + }); + + // Campos + app.get('/api/prematricula/campos', async (req, res) => { + try { + const { rows } = await pool.query('SELECT * FROM prematricula_campos WHERE ativo = true ORDER BY ordem ASC'); + res.json({ campos: rows.map(r => ({ + id: r.id, label: r.label, tipo: r.tipo, placeholder: r.placeholder, + obrigatorio: r.obrigatorio, opcoes: r.opcoes || [], ordem: r.ordem, ativo: r.ativo + }))}); + } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } + }); + + app.post('/api/prematricula/campos', async (req, res) => { + try { + const c = req.body; + await pool.query( + `INSERT INTO prematricula_campos (id, label, tipo, placeholder, obrigatorio, opcoes, ordem, ativo) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [c.id, c.label, c.tipo, c.placeholder || '', c.obrigatorio || false, c.opcoes || [], c.ordem || 0, true] + ); + res.json({ success: true }); + } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } + }); + + app.put('/api/prematricula/campos/:id', async (req, res) => { + try { + const c = req.body; + await pool.query( + `UPDATE prematricula_campos SET label=$1, tipo=$2, placeholder=$3, obrigatorio=$4, opcoes=$5, ordem=$6 WHERE id=$7`, + [c.label, c.tipo, c.placeholder, c.obrigatorio, c.opcoes || [], c.ordem, req.params.id] + ); + res.json({ success: true }); + } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } + }); + + app.delete('/api/prematricula/campos/:id', async (req, res) => { + try { + await pool.query('DELETE FROM prematricula_campos WHERE id = $1', [req.params.id]); + res.json({ success: true }); + } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } + }); + + // Inscrições + app.get('/api/prematricula/inscricoes', async (req, res) => { + try { + const { rows } = await pool.query('SELECT * FROM prematriculas ORDER BY created_at DESC'); + res.json({ inscricoes: rows.map(r => ({ + id: r.id, nome: r.nome, email: r.email, telefone: r.telefone, + turmaId: r.turma_id, turmaNome: r.turma_nome, respostas: r.respostas || {}, + status: r.status, observacoes: r.observacoes, createdAt: r.created_at + }))}); + } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } + }); + + app.delete('/api/prematricula/inscricoes/:id', async (req, res) => { + try { + await pool.query('DELETE FROM prematriculas WHERE id = $1', [req.params.id]); + res.json({ success: true }); + } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } + }); + + // Submissão pública + app.post('/api/prematricula/submit', async (req, res) => { + try { + const { nome, email, telefone, turmaId, turmaNome, respostas } = req.body; + if (!nome) return res.status(400).json({ error: 'Nome é obrigatório.' }); + const config = (await pool.query('SELECT status FROM prematricula_config WHERE id = 1')).rows[0]; + if (!config || config.status !== 'published') return res.status(403).json({ error: 'Formulário não disponível.' }); + const id = crypto.randomUUID(); + await pool.query( + `INSERT INTO prematriculas (id, nome, email, telefone, turma_id, turma_nome, respostas, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'pendente')`, + [id, nome, email || '', telefone || '', turmaId || null, turmaNome || '', JSON.stringify(respostas || {})] + ); + res.json({ success: true, id }); + } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } + }); + + // Página pública de pré-matrícula (API que retorna dados do formulário) + app.get('/api/prematricula/public/:slug', async (req, res) => { + try { + const { rows: cfgRows } = await pool.query('SELECT * FROM prematricula_config WHERE slug = $1 AND status = $2', [req.params.slug, 'published']); + if (cfgRows.length === 0) return res.status(404).json({ error: 'Formulário não encontrado ou não publicado.' }); + const cfg = cfgRows[0]; + const { rows: camposRows } = await pool.query('SELECT * FROM prematricula_campos WHERE ativo = true ORDER BY ordem ASC'); + const turmasResult = await pool.query('SELECT id, nome FROM turmas ORDER BY nome ASC'); + const appData = await getSchoolData(); + res.json({ + config: { + titulo: cfg.titulo, descricao: cfg.descricao, corPrimaria: cfg.cor_primaria, + logoUrl: cfg.logo_url, mensagemSucesso: cfg.mensagem_sucesso + }, + campos: camposRows.map(r => ({ + id: r.id, label: r.label, tipo: r.tipo, placeholder: r.placeholder, + obrigatorio: r.obrigatorio, opcoes: r.opcoes || [] + })), + turmas: turmasResult.rows.map(t => ({ id: t.id, nome: t.nome })), + escola: { nome: appData?.profile?.name || 'EduManager', logo: appData?.logo || '' } + }); + } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } + }); + + // Rota que serve a página HTML pública do formulário de pré-matrícula + app.get('/pre-matricula/:slug', (req, res) => { + const { slug } = req.params; + res.send(getPreMatriculaHTML(slug)); + }); + // =================================================== // SERVE FRONTEND (Final Catch-all) // =================================================== diff --git a/manager/types.ts b/manager/types.ts index 7b2db39..35d55fe 100644 --- a/manager/types.ts +++ b/manager/types.ts @@ -258,6 +258,41 @@ export interface Exam { isDeleted?: boolean; } +export interface PreMatriculaCampo { + id: string; + label: string; + tipo: 'text' | 'email' | 'phone' | 'cpf' | 'date' | 'select' | 'textarea' | 'number'; + placeholder?: string; + obrigatorio: boolean; + opcoes?: string[]; + ordem: number; + ativo: boolean; +} + +export interface PreMatriculaConfig { + id: number; + titulo: string; + descricao: string; + slug: string; + status: 'draft' | 'published'; + corPrimaria: string; + logoUrl: string; + mensagemSucesso: string; +} + +export interface PreMatriculaInscricao { + id: string; + nome: string; + email: string; + telefone: string; + turmaId: string; + turmaNome: string; + respostas: Record; + status: 'pendente' | 'aprovado' | 'rejeitado' | 'matriculado'; + observacoes: string; + createdAt: string; +} + export interface SchoolData { users: User[]; courses: Course[]; @@ -319,5 +354,6 @@ export enum View { Settings = 'settings', Users = 'users', Messages = 'messages', - Exams = 'exams' + Exams = 'exams', + PreMatricula = 'pre_matricula' } \ No newline at end of file