feat: add pre-enrollment module with public form, admin editor, and SQL-First persistence
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 2m7s Details

This commit is contained in:
Sidney 2026-05-28 08:34:09 -03:00
parent 81ba8b1919
commit a3bfea8120
7 changed files with 831 additions and 4 deletions

View File

@ -1,6 +1,6 @@
# 📊 Tabela de Migração SQL-First — EduManager # 📊 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**. > 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. | | **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. | | **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. | | **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. | | **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 | | 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 | | 🟡 Híbrido | **0** | Nenhum |
| 🔴 Pendente | **1** | Configurações Globais | | 🔴 Pendente | **1** | Configurações Globais |

View File

@ -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<SchoolData>) => void;
}
const FIELD_TYPES: Record<string, string> = {
text: 'Texto', email: 'Email', phone: 'Telefone', cpf: 'CPF',
date: 'Data', select: 'Seleção', textarea: 'Texto Longo', number: 'Número'
};
const PreMatricula: React.FC<Props> = ({ data }) => {
const [config, setConfig] = useState<PreMatriculaConfig | null>(null);
const [campos, setCampos] = useState<PreMatriculaCampo[]>([]);
const [inscricoes, setInscricoes] = useState<PreMatriculaInscricao[]>([]);
const [turmas, setTurmas] = useState<any[]>([]);
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<string>('all');
const [editingCampo, setEditingCampo] = useState<PreMatriculaCampo | null>(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<string>();
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 (
<div className="flex items-center justify-center h-64">
<RefreshCw size={32} className="text-indigo-600 animate-spin" />
</div>
);
}
return (
<div className="space-y-6 animate-in fade-in duration-500 pb-10">
{/* Header */}
<header 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 flex items-center gap-3">
<ClipboardPen className="text-indigo-600" size={28} />
Pré-Matrícula
</h2>
<p className="text-slate-500 font-medium">Gerencie o formulário público de pré-matrícula e visualize inscrições.</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={togglePublish}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl font-bold text-sm transition-all active:scale-95 shadow-sm ${
config?.status === 'published'
? 'bg-emerald-600 text-white hover:bg-emerald-700'
: 'bg-amber-500 text-white hover:bg-amber-600'
}`}
>
{config?.status === 'published' ? <><Eye size={18} /> Publicado</> : <><EyeOff size={18} /> Rascunho</>}
</button>
<button onClick={copyLink}
className="flex items-center gap-2 px-4 py-2.5 bg-slate-100 text-slate-700 rounded-xl font-bold text-sm hover:bg-slate-200 transition-all active:scale-95"
>
{copied ? <><Check size={18} className="text-emerald-600" /> Copiado!</> : <><Link2 size={18} /> Copiar Link</>}
</button>
</div>
</header>
{/* Tab Switcher */}
<div className="flex bg-slate-100 p-1 rounded-xl border border-slate-200 w-fit">
<button onClick={() => setActiveTab('editor')}
className={`px-5 py-2.5 rounded-lg text-sm font-bold transition-all ${activeTab === 'editor' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
<Settings2 size={16} className="inline mr-2 -mt-0.5" />Editor do Formulário
</button>
<button onClick={() => setActiveTab('inscricoes')}
className={`px-5 py-2.5 rounded-lg text-sm font-bold transition-all ${activeTab === 'inscricoes' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
<Users size={16} className="inline mr-2 -mt-0.5" />Inscrições ({inscricoes.length})
</button>
</div>
{activeTab === 'editor' ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left: Config */}
<div className="lg:col-span-1 space-y-6">
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm space-y-5">
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Configurações</h3>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Título da Página</label>
<input className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium"
value={config?.titulo || ''} onChange={e => setConfig(prev => prev ? { ...prev, titulo: e.target.value } : prev)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Descrição</label>
<textarea className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium resize-none" rows={3}
value={config?.descricao || ''} onChange={e => setConfig(prev => prev ? { ...prev, descricao: e.target.value } : prev)} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Slug do Link (após /pre-matricula/)</label>
<div className="flex items-center bg-slate-50 border border-slate-200 rounded-lg overflow-hidden">
<span className="px-3 text-xs text-slate-400 font-mono whitespace-nowrap">/pre-matricula/</span>
<input className="flex-1 px-2 py-3 bg-transparent focus:outline-none text-sm font-mono font-bold text-indigo-700"
value={config?.slug || ''} onChange={e => setConfig(prev => prev ? { ...prev, slug: e.target.value.replace(/[^a-z0-9-]/g, '') } : prev)} />
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Cor Primária</label>
<div className="flex items-center gap-3">
<input type="color" className="w-10 h-10 rounded-lg border border-slate-200 cursor-pointer"
value={config?.corPrimaria || '#4f46e5'} onChange={e => setConfig(prev => prev ? { ...prev, corPrimaria: e.target.value } : prev)} />
<span className="text-xs font-mono text-slate-500">{config?.corPrimaria}</span>
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Mensagem de Sucesso</label>
<textarea className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium resize-none" rows={2}
value={config?.mensagemSucesso || ''} onChange={e => setConfig(prev => prev ? { ...prev, mensagemSucesso: e.target.value } : prev)} />
</div>
<button onClick={saveConfig} disabled={isSaving}
className="w-full py-3 bg-indigo-600 text-white rounded-xl font-bold text-sm hover:bg-indigo-700 transition-all active:scale-95 flex items-center justify-center gap-2 disabled:opacity-50"
>
{isSaving ? <RefreshCw size={16} className="animate-spin" /> : <Save size={16} />} Salvar Configurações
</button>
</div>
{/* Preview Link */}
<div className="bg-gradient-to-br from-indigo-50 to-violet-50 p-5 rounded-2xl border border-indigo-100">
<p className="text-[10px] font-black text-indigo-600 uppercase tracking-widest mb-2">Link Público</p>
<p className="text-xs font-mono text-indigo-800 break-all mb-3">{window.location.origin}/pre-matricula/{config?.slug}</p>
<a href={`/pre-matricula/${config?.slug}`} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 text-xs font-bold text-indigo-600 hover:text-indigo-700"
>
<ExternalLink size={14} /> Abrir Prévia
</a>
</div>
</div>
{/* Right: Fields Editor */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Campos do Formulário</h3>
<button onClick={() => setShowNewField(!showNewField)}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-xl font-bold text-xs hover:bg-indigo-700 transition-all active:scale-95"
>
<Plus size={16} /> Novo Campo
</button>
</div>
{/* New Field Form */}
{showNewField && (
<div className="bg-indigo-50 p-5 rounded-xl border border-indigo-100 mb-6 space-y-4 animate-in slide-in-from-top-2 duration-300">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Nome do Campo</label>
<input className="w-full px-4 py-3 bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium"
placeholder="Ex: Nome Completo" value={newField.label} onChange={e => setNewField({ ...newField, label: e.target.value })} />
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Tipo</label>
<select className="w-full px-4 py-3 bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium"
value={newField.tipo} onChange={e => setNewField({ ...newField, tipo: e.target.value })}>
{Object.entries(FIELD_TYPES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Placeholder</label>
<input className="w-full px-4 py-3 bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium"
placeholder="Texto de ajuda" value={newField.placeholder} onChange={e => setNewField({ ...newField, placeholder: e.target.value })} />
</div>
{newField.tipo === 'select' && (
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Opções (separadas por vírgula)</label>
<input className="w-full px-4 py-3 bg-white border border-slate-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-medium"
placeholder="Opção 1, Opção 2, Opção 3" value={newField.opcoes} onChange={e => setNewField({ ...newField, opcoes: e.target.value })} />
</div>
)}
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm font-bold text-slate-600 cursor-pointer">
<input type="checkbox" className="w-4 h-4 text-indigo-600 rounded"
checked={newField.obrigatorio} onChange={e => setNewField({ ...newField, obrigatorio: e.target.checked })} />
Campo Obrigatório
</label>
<div className="flex gap-2">
<button onClick={() => setShowNewField(false)} className="px-4 py-2 text-slate-600 font-bold text-xs rounded-lg hover:bg-slate-100">Cancelar</button>
<button onClick={addField} className="px-4 py-2 bg-indigo-600 text-white font-bold text-xs rounded-xl hover:bg-indigo-700 active:scale-95 transition-all">Adicionar</button>
</div>
</div>
</div>
)}
{/* Fields List */}
<div className="space-y-3">
{campos.length === 0 && (
<div className="text-center py-12 text-slate-400">
<ClipboardPen size={48} className="mx-auto mb-3 opacity-30" />
<p className="font-bold">Nenhum campo criado</p>
<p className="text-xs mt-1">Clique em "Novo Campo" para começar a montar seu formulário.</p>
</div>
)}
{campos.sort((a, b) => a.ordem - b.ordem).map((campo, idx) => (
<div key={campo.id} className="flex items-center gap-3 p-4 bg-slate-50 rounded-xl border border-slate-200 group hover:border-indigo-200 transition-colors">
<GripVertical size={16} className="text-slate-300 cursor-grab flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-bold text-slate-800 text-sm truncate">{campo.label}</p>
{campo.obrigatorio && <span className="text-red-500 text-xs font-black">*</span>}
</div>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">
{FIELD_TYPES[campo.tipo] || campo.tipo}
{campo.tipo === 'select' && campo.opcoes?.length ? ` · ${campo.opcoes.length} opções` : ''}
</p>
</div>
<button onClick={() => deleteField(campo.id)}
className="p-2 text-slate-300 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
</div>
</div>
</div>
) : (
/* Inscricoes Tab */
<div className="space-y-6">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-wrap">
<select className="px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm font-bold text-slate-700 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
value={selectedTurma} onChange={e => setSelectedTurma(e.target.value)}>
<option value="all">Todas as Turmas ({inscricoes.length})</option>
{turmasComInscritos.map(t => (
<option key={t.id} value={t.id}>{t.nome} ({t.count})</option>
))}
</select>
<button onClick={() => loadData()} className="p-2.5 bg-slate-100 rounded-xl text-slate-600 hover:bg-slate-200 transition-all" title="Atualizar">
<RefreshCw size={16} />
</button>
</div>
<button onClick={() => exportCSV(selectedTurma)}
className="flex items-center gap-2 px-4 py-2.5 bg-emerald-600 text-white rounded-xl font-bold text-sm hover:bg-emerald-700 transition-all active:scale-95"
>
<Download size={16} /> Exportar CSV
</button>
</div>
{filteredInscricoes.length === 0 ? (
<div className="bg-white p-12 rounded-2xl border border-slate-200 text-center">
<Users size={48} className="mx-auto mb-3 text-slate-300" />
<p className="font-bold text-slate-500">Nenhuma inscrição encontrada</p>
<p className="text-xs text-slate-400 mt-1">As pré-matrículas aparecerão aqui quando forem preenchidas.</p>
</div>
) : (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-100 text-xs uppercase text-slate-500 font-bold tracking-wider">
<th className="p-4">Nome</th>
<th className="p-4">Contato</th>
<th className="p-4">Turma</th>
<th className="p-4">Status</th>
<th className="p-4">Data</th>
<th className="p-4 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50 text-sm">
{filteredInscricoes.map(insc => (
<tr key={insc.id} className="hover:bg-slate-50 transition-colors">
<td className="p-4">
<p className="font-bold text-slate-800">{insc.nome}</p>
{insc.respostas && Object.keys(insc.respostas).length > 0 && (
<p className="text-[10px] text-slate-400 mt-0.5">{Object.keys(insc.respostas).length} campos extras</p>
)}
</td>
<td className="p-4">
<p className="text-slate-600">{insc.email || '-'}</p>
<p className="text-xs text-slate-400">{insc.telefone || '-'}</p>
</td>
<td className="p-4 text-slate-600 font-medium">{insc.turmaNome || '-'}</td>
<td className="p-4">
<span className={`px-2.5 py-1 rounded-full text-[10px] font-bold uppercase ${
insc.status === 'pendente' ? 'bg-amber-100 text-amber-700' :
insc.status === 'aprovado' ? 'bg-emerald-100 text-emerald-700' :
insc.status === 'rejeitado' ? 'bg-red-100 text-red-700' :
'bg-indigo-100 text-indigo-700'
}`}>
{insc.status}
</span>
</td>
<td className="p-4 text-slate-500 text-xs">{new Date(insc.createdAt).toLocaleDateString('pt-BR')}</td>
<td className="p-4 text-right">
<button onClick={() => deleteInscricao(insc.id)} className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
);
};
export default PreMatricula;

View File

@ -22,7 +22,8 @@ import {
Briefcase, Briefcase,
LogOut, LogOut,
MessageSquare, MessageSquare,
ClipboardList ClipboardList,
ClipboardPen
} from 'lucide-react'; } from 'lucide-react';
import { isSupabaseConfigured } from '../services/supabase'; import { isSupabaseConfigured } from '../services/supabase';
import { View, User } from '../types'; import { View, User } from '../types';
@ -55,6 +56,7 @@ const Sidebar: React.FC<SidebarProps> = ({ currentView, setView, user, logo, onL
{ id: View.Employees, icon: Briefcase, label: 'Funcionários' }, { id: View.Employees, icon: Briefcase, label: 'Funcionários' },
{ id: View.Users, icon: Shield, label: 'Usuários' }, { id: View.Users, icon: Shield, label: 'Usuários' },
{ id: View.Messages, icon: MessageSquare, label: 'Mensagens' }, { id: View.Messages, icon: MessageSquare, label: 'Mensagens' },
{ id: View.PreMatricula, icon: ClipboardPen, label: 'Pré-Matrícula' },
{ id: View.Settings, icon: Settings, label: 'Configurações' }, { id: View.Settings, icon: Settings, label: 'Configurações' },
]; ];

View File

@ -21,6 +21,7 @@ import Employees from './components/Employees';
import Messages from './components/Messages'; import Messages from './components/Messages';
import AdminNotifications from './components/AdminNotifications'; import AdminNotifications from './components/AdminNotifications';
import Exams from './components/Exams'; import Exams from './components/Exams';
import PreMatricula from './components/PreMatricula';
import { Cloud, CloudOff, RefreshCw, AlertCircle } from 'lucide-react'; import { Cloud, CloudOff, RefreshCw, AlertCircle } from 'lucide-react';
import { supabase, isSupabaseConfigured } from './services/supabase'; import { supabase, isSupabaseConfigured } from './services/supabase';
import { DialogProvider } from './DialogContext'; import { DialogProvider } from './DialogContext';
@ -252,6 +253,8 @@ const App = () => {
return <Messages data={data} updateData={updateData} />; return <Messages data={data} updateData={updateData} />;
case View.Settings: case View.Settings:
return <Settings data={data} updateData={updateData} setData={setData} />; return <Settings data={data} updateData={updateData} setData={setData} />;
case View.PreMatricula:
return <PreMatricula data={data} updateData={updateData} />;
default: default:
return <Dashboard data={data} />; return <Dashboard data={data} />;
} }

View File

@ -0,0 +1,97 @@
/**
* Script de Migração: Criação das tabelas de Pré-Matrícula
* Execução: node scratch/create_prematricula_tables.cjs
*/
const { Pool } = require('pg');
const pool = new Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager'
});
async function migrate() {
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. Tabela de configuração do formulário de pré-matrícula
await client.query(`
CREATE TABLE IF NOT EXISTS prematricula_config (
id SERIAL PRIMARY KEY,
titulo TEXT NOT NULL DEFAULT 'Pré-Matrícula Online',
descricao TEXT DEFAULT '',
slug TEXT NOT NULL DEFAULT 'pre-matricula',
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
cor_primaria TEXT DEFAULT '#4f46e5',
logo_url TEXT DEFAULT '',
mensagem_sucesso TEXT DEFAULT 'Sua pré-matrícula foi enviada com sucesso! Entraremos em contato em breve.',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`);
// Inserir registro default se não existir
await client.query(`
INSERT INTO prematricula_config (id, titulo, descricao, slug, status)
VALUES (1, 'Pré-Matrícula Online', 'Preencha o formulário abaixo para realizar sua pré-matrícula.', 'pre-matricula', 'draft')
ON CONFLICT (id) DO NOTHING;
`);
// 2. Tabela de campos personalizáveis do formulário
await client.query(`
CREATE TABLE IF NOT EXISTS prematricula_campos (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
tipo TEXT NOT NULL DEFAULT 'text' CHECK (tipo IN ('text', 'email', 'phone', 'cpf', 'date', 'select', 'textarea', 'number')),
placeholder TEXT DEFAULT '',
obrigatorio BOOLEAN DEFAULT false,
opcoes TEXT[] DEFAULT '{}',
ordem INTEGER NOT NULL DEFAULT 0,
ativo BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`);
// 3. Tabela de inscrições de pré-matrícula
await client.query(`
CREATE TABLE IF NOT EXISTS prematriculas (
id TEXT PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT DEFAULT '',
telefone TEXT DEFAULT '',
turma_id TEXT,
turma_nome TEXT DEFAULT '',
respostas JSONB DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pendente' CHECK (status IN ('pendente', 'aprovado', 'rejeitado', 'matriculado')),
observacoes TEXT DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`);
// Índices
await client.query(`
CREATE INDEX IF NOT EXISTS idx_prematriculas_turma ON prematriculas(turma_id);
CREATE INDEX IF NOT EXISTS idx_prematriculas_status ON prematriculas(status);
CREATE INDEX IF NOT EXISTS idx_prematriculas_created ON prematriculas(created_at DESC);
`);
await client.query('COMMIT');
console.log('✅ Tabelas de pré-matrícula criadas com sucesso!');
// Verificar
const { rows } = await client.query(`
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_name LIKE 'prematricula%'
ORDER BY table_name;
`);
console.log('📊 Tabelas criadas:', rows.map(r => r.table_name));
} catch (error) {
await client.query('ROLLBACK');
console.error('❌ Erro na migração:', error.message);
} finally {
client.release();
await pool.end();
}
}
migrate();

View File

@ -64,6 +64,113 @@ app.use(express.json({ limit: '50mb' }));
app.use(cors()); app.use(cors());
const cancelCache = new Set(); const cancelCache = new Set();
// === Gerador da Página Pública de Pré-Matrícula ===
function getPreMatriculaHTML(slug) {
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Pré-Matrícula Online</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',sans-serif;background:#f8fafc;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
.container{max-width:640px;width:100%;background:#fff;border-radius:24px;box-shadow:0 25px 50px -12px rgba(0,0,0,.1);overflow:hidden}
.header{padding:40px 32px 24px;text-align:center}
.header img{max-height:56px;margin:0 auto 16px}
.header h1{font-size:28px;font-weight:900;color:#0f172a;margin-bottom:8px}
.header p{font-size:14px;color:#64748b;line-height:1.6}
.form{padding:0 32px 32px}
.field{margin-bottom:20px}
.field label{display:block;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px}
.field label .req{color:#ef4444;margin-left:2px}
.field input,.field select,.field textarea{width:100%;padding:14px 16px;background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:12px;font-size:14px;font-family:'Inter',sans-serif;font-weight:500;color:#0f172a;transition:.2s;outline:none}
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--primary,#4f46e5);box-shadow:0 0 0 3px rgba(79,70,229,.1)}
.field textarea{resize:vertical;min-height:80px}
.btn{display:block;width:100%;padding:16px;background:var(--primary,#4f46e5);color:#fff;border:none;border-radius:14px;font-size:15px;font-weight:800;cursor:pointer;transition:.2s;text-transform:uppercase;letter-spacing:1px}
.btn:hover{opacity:.9;transform:translateY(-1px)}
.btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
.success{text-align:center;padding:60px 32px}
.success .icon{width:64px;height:64px;background:#d1fae5;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 20px;font-size:28px}
.success h2{font-size:22px;font-weight:800;color:#0f172a;margin-bottom:8px}
.success p{color:#64748b;font-size:14px;line-height:1.6}
.error-page{text-align:center;padding:80px 32px}
.error-page h2{font-size:22px;font-weight:800;color:#0f172a;margin-bottom:8px}
.error-page p{color:#94a3b8;font-size:14px}
.loading{text-align:center;padding:80px 32px;color:#94a3b8}
.top-bar{height:5px;width:100%;background:var(--primary,#4f46e5)}
</style>
</head>
<body>
<div class="container" id="app"><div class="loading">Carregando formulário...</div></div>
<script>
const slug='${slug}';
const app=document.getElementById('app');
async function init(){
try{
const res=await fetch('/api/prematricula/public/'+slug);
if(!res.ok){app.innerHTML='<div class="error-page"><h2>Formulário indisponível</h2><p>Este formulário não está publicado ou o link é inválido.</p></div>';return}
const data=await res.json();
const c=data.config;
document.documentElement.style.setProperty('--primary',c.corPrimaria||'#4f46e5');
let html='<div class="top-bar"></div><div class="header">';
if(data.escola.logo)html+='<img src="'+data.escola.logo+'" alt="Logo">';
html+='<h1>'+c.titulo+'</h1><p>'+c.descricao+'</p></div>';
html+='<form class="form" id="preForm" onsubmit="return submitForm(event)">';
html+='<div class="field"><label>Nome Completo <span class="req">*</span></label><input name="_nome" required placeholder="Seu nome completo"></div>';
html+='<div class="field"><label>Email</label><input name="_email" type="email" placeholder="seu@email.com"></div>';
html+='<div class="field"><label>Telefone / WhatsApp</label><input name="_telefone" placeholder="(00) 00000-0000"></div>';
if(data.turmas.length>0){
html+='<div class="field"><label>Turma de Interesse <span class="req">*</span></label><select name="_turma" required><option value="">Selecione a turma...</option>';
data.turmas.forEach(t=>{html+='<option value="'+t.id+'" data-nome="'+t.nome+'">'+t.nome+'</option>'});
html+='</select></div>';
}
data.campos.forEach(f=>{
html+='<div class="field"><label>'+f.label+(f.obrigatorio?' <span class="req">*</span>':'')+'</label>';
if(f.tipo==='select'){
html+='<select name="'+f.id+'"'+(f.obrigatorio?' required':'')+'><option value="">Selecione...</option>';
(f.opcoes||[]).forEach(o=>{html+='<option value="'+o+'">'+o+'</option>'});
html+='</select>';
}else if(f.tipo==='textarea'){
html+='<textarea name="'+f.id+'"'+(f.obrigatorio?' required':'')+' placeholder="'+(f.placeholder||'')+'"></textarea>';
}else{
let t=f.tipo;if(t==='phone'||t==='cpf')t='text';
html+='<input type="'+t+'" name="'+f.id+'"'+(f.obrigatorio?' required':'')+' placeholder="'+(f.placeholder||'')+'">';
}
html+='</div>';
});
html+='<button type="submit" class="btn" id="submitBtn">Enviar Pré-Matrícula</button></form>';
app.innerHTML=html;
window._successMsg=c.mensagemSucesso||'Pré-matrícula enviada com sucesso!';
}catch(e){app.innerHTML='<div class="error-page"><h2>Erro</h2><p>Não foi possível carregar o formulário.</p></div>'}
}
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='<div class="top-bar"></div><div class="success"><div class="icon">✅</div><h2>Enviado com sucesso!</h2><p>'+window._successMsg+'</p></div>';
}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();
</script>
</body></html>`;
}
const sentCache = new Set(); const sentCache = new Set();
const lockCache = new Set(); const lockCache = new Set();
let activeCronJob = null; // Referência global para o agendamento preventivo 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.' }); } } 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) // SERVE FRONTEND (Final Catch-all)
// =================================================== // ===================================================

View File

@ -258,6 +258,41 @@ export interface Exam {
isDeleted?: boolean; 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<string, any>;
status: 'pendente' | 'aprovado' | 'rejeitado' | 'matriculado';
observacoes: string;
createdAt: string;
}
export interface SchoolData { export interface SchoolData {
users: User[]; users: User[];
courses: Course[]; courses: Course[];
@ -319,5 +354,6 @@ export enum View {
Settings = 'settings', Settings = 'settings',
Users = 'users', Users = 'users',
Messages = 'messages', Messages = 'messages',
Exams = 'exams' Exams = 'exams',
PreMatricula = 'pre_matricula'
} }