feat: enhance pre-enrollment with direct link, custom allowed classes, and matricular conversion flow
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 1m47s Details

This commit is contained in:
Sidney 2026-05-28 08:49:10 -03:00
parent a3bfea8120
commit 65de858755
6 changed files with 190 additions and 17 deletions

View File

@ -3,12 +3,13 @@ import { SchoolData, PreMatriculaCampo, PreMatriculaConfig, PreMatriculaInscrica
import {
Plus, Trash2, Save, Eye, EyeOff, Download, GripVertical, Link2,
ClipboardPen, Copy, Check, ChevronDown, ChevronRight, Users, RefreshCw,
FileText, X, Settings2, ArrowLeft, ExternalLink, AlertCircle
FileText, X, Settings2, ArrowLeft, ExternalLink, AlertCircle, UserCheck
} from 'lucide-react';
interface Props {
data: SchoolData;
updateData: (d: Partial<SchoolData>) => void;
onConvert?: (preData: any) => void;
}
const FIELD_TYPES: Record<string, string> = {
@ -16,7 +17,7 @@ const FIELD_TYPES: Record<string, string> = {
date: 'Data', select: 'Seleção', textarea: 'Texto Longo', number: 'Número'
};
const PreMatricula: React.FC<Props> = ({ data }) => {
const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
const [config, setConfig] = useState<PreMatriculaConfig | null>(null);
const [campos, setCampos] = useState<PreMatriculaCampo[]>([]);
const [inscricoes, setInscricoes] = useState<PreMatriculaInscricao[]>([]);
@ -144,7 +145,9 @@ const PreMatricula: React.FC<Props> = ({ data }) => {
const copyLink = () => {
if (!config) return;
const url = `${window.location.origin}/pre-matricula/${config.slug}`;
const url = config.slug === 'pre-matricula'
? `${window.location.origin}/pre-matricula`
: `${window.location.origin}/pre-matricula/${config.slug}`;
navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
@ -241,6 +244,35 @@ const PreMatricula: React.FC<Props> = ({ data }) => {
<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-2">Turmas Disponíveis no Formulário</label>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3 max-h-40 overflow-y-auto space-y-1">
{turmas.map(t => {
const isChecked = config?.turmasPermitidas?.includes(t.id);
return (
<label key={t.id} className="flex items-center gap-2 text-xs font-bold text-slate-700 cursor-pointer hover:bg-slate-100 p-1.5 rounded transition-all">
<input
type="checkbox"
className="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"
checked={isChecked}
onChange={(e) => {
const current = config?.turmasPermitidas || [];
const next = e.target.checked
? [...current, t.id]
: current.filter(id => id !== t.id);
setConfig(prev => prev ? { ...prev, turmasPermitidas: next } : prev);
}}
/>
{t.nome}
</label>
);
})}
{turmas.length === 0 && (
<p className="text-slate-400 text-xs italic">Nenhuma turma cadastrada</p>
)}
</div>
<p className="text-[10px] text-slate-400 mt-1 font-medium">Se nenhuma for marcada, todas as turmas aparecerão por padrão.</p>
</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}
@ -256,8 +288,10 @@ const PreMatricula: React.FC<Props> = ({ data }) => {
{/* 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"
<p className="text-xs font-mono text-indigo-800 break-all mb-3">
{window.location.origin}/pre-matricula{config?.slug === 'pre-matricula' ? '' : `/${config?.slug}`}
</p>
<a href={config?.slug === 'pre-matricula' ? '/pre-matricula' : `/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
@ -423,7 +457,14 @@ const PreMatricula: React.FC<Props> = ({ data }) => {
</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">
<td className="p-4 text-right flex items-center justify-end gap-2">
<button
onClick={() => onConvert && onConvert(insc)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 rounded-lg font-bold text-xs transition-colors"
title="Converter em Matrícula"
>
<UserCheck size={14} /> Matricular
</button>
<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>

View File

@ -15,10 +15,11 @@ interface StudentsProps {
updateData: (newData: Partial<SchoolData>) => void;
deepLinkStudentId?: string | null;
deepLinkClassId?: string | null;
deepLinkPreMatricula?: any | null;
clearDeepLink?: () => void;
}
const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId, deepLinkClassId, clearDeepLink }) => {
const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId, deepLinkClassId, deepLinkPreMatricula, clearDeepLink }) => {
const { showAlert, showConfirm } = useDialog();
const [searchTerm, setSearchTerm] = useState('');
const [showModal, setShowModal] = useState(false);
@ -122,7 +123,7 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
const streamRef = useRef<MediaStream | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Process Deep Links (from Classes or Notifications)
// Process Deep Links (from Classes, Notifications, or Pre-Matricula)
useEffect(() => {
if (deepLinkClassId) {
setSelectedClassId(deepLinkClassId);
@ -138,7 +139,74 @@ const Students: React.FC<StudentsProps> = ({ data, updateData, deepLinkStudentId
}
if (clearDeepLink) clearDeepLink();
}
}, [deepLinkStudentId, deepLinkClassId, data.students]);
if (deepLinkPreMatricula) {
setEditingStudent(null);
const parsedData = {
name: deepLinkPreMatricula.nome || '',
email: deepLinkPreMatricula.email || '',
phone: deepLinkPreMatricula.telefone || '',
birthDate: '',
cpf: '',
rg: '',
rgIssueDate: '',
guardianName: '',
guardianPhone: '',
guardianCpf: '',
guardianBirthDate: '',
classId: deepLinkPreMatricula.turmaId || '',
status: 'active',
registrationDate: new Date().toLocaleDateString('en-US', { timeZone: 'America/Sao_Paulo' }).split(',')[0].split('/').map((x,i,a) => i===2?x:x.padStart(2,'0')).join('-'), // Force Brazil timezone YYYY-MM-DD
addressZip: '',
addressStreet: '',
addressNumber: '',
addressNeighborhood: '',
addressCity: '',
addressState: '',
discount: 0,
hasGuardian: false,
contractTemplateId: '',
generateFee: false,
generateContract: true,
sexo: ''
};
// Custom formatting for YYYY-MM-DD for registrationDate
const now = new Date();
const yr = now.toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo', year: 'numeric' });
const mt = now.toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo', month: '2-digit' });
const dy = now.toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo', day: '2-digit' });
parsedData.registrationDate = `${yr}-${mt}-${dy}`;
// Map dynamic respostas to standard fields
const resps = deepLinkPreMatricula.respostas || {};
Object.keys(resps).forEach(key => {
const val = resps[key];
if (!val) return;
// We need to fetch the field label to map properly, but we can also match key if the key was saved as string.
// Usually, the key is the field ID (UUID). Let's do a case-insensitive search if we can match any standard field names.
const label = String(key).toLowerCase();
if (label.includes('nascimento') || label.includes('nasc') || label.includes('data')) {
parsedData.birthDate = val;
} else if (label.includes('cpf')) {
parsedData.cpf = val;
} else if (label.includes('rg')) {
parsedData.rg = val;
} else if (label.includes('sexo') || label.includes('gênero') || label.includes('genero')) {
parsedData.sexo = val;
} else if (label.includes('mãe') || label.includes('pai') || label.includes('responsável') || label.includes('responsavel')) {
parsedData.guardianName = val;
parsedData.hasGuardian = true;
}
});
setFormData(parsedData as any);
setShowModal(true);
if (clearDeepLink) clearDeepLink();
}
}, [deepLinkStudentId, deepLinkClassId, deepLinkPreMatricula, dbStudents]);
// Fetch Academic History when modal opens
useEffect(() => {

View File

@ -38,6 +38,7 @@ const App = () => {
}, [currentView]);
const [deepLinkStudentId, setDeepLinkStudentId] = useState<string | null>(null);
const [deepLinkClassId, setDeepLinkClassId] = useState<string | null>(null);
const [deepLinkPreMatricula, setDeepLinkPreMatricula] = useState<any | null>(null);
// Initial load from LocalStorage for speed (fallback), then IDB
const [data, setData] = useState<SchoolData>(dbService.getData());
@ -219,6 +220,11 @@ const App = () => {
setCurrentView(View.Students);
};
const handleNavigateToStudentsWithPreMatricula = (preData: any) => {
setDeepLinkPreMatricula(preData);
setCurrentView(View.Students);
};
const renderView = () => {
switch (currentView) {
case View.Dashboard:
@ -226,7 +232,20 @@ const App = () => {
case View.Courses:
return <Courses data={data} updateData={updateData} />;
case View.Students:
return <Students data={data} updateData={updateData} deepLinkStudentId={deepLinkStudentId} deepLinkClassId={deepLinkClassId} clearDeepLink={() => { setDeepLinkStudentId(null); setDeepLinkClassId(null); }} />;
return (
<Students
data={data}
updateData={updateData}
deepLinkStudentId={deepLinkStudentId}
deepLinkClassId={deepLinkClassId}
deepLinkPreMatricula={deepLinkPreMatricula}
clearDeepLink={() => {
setDeepLinkStudentId(null);
setDeepLinkClassId(null);
setDeepLinkPreMatricula(null);
}}
/>
);
case View.Classes:
return <Classes data={data} updateData={updateData} onNavigateToClass={handleNavigateToClass} />;
case View.Finance:
@ -254,7 +273,7 @@ const App = () => {
case View.Settings:
return <Settings data={data} updateData={updateData} setData={setData} />;
case View.PreMatricula:
return <PreMatricula data={data} updateData={updateData} />;
return <PreMatricula data={data} updateData={updateData} onConvert={handleNavigateToStudentsWithPreMatricula} />;
default:
return <Dashboard data={data} />;
}

View File

@ -0,0 +1,23 @@
const { Pool } = require('pg');
const pool = new Pool({
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager'
});
async function run() {
const client = await pool.connect();
try {
await client.query(`
ALTER TABLE prematricula_config
ADD COLUMN IF NOT EXISTS turmas_permitidas TEXT[] DEFAULT '{}';
`);
console.log('✅ Coluna turmas_permitidas adicionada com sucesso!');
} catch (e) {
console.error('❌ Erro ao adicionar coluna:', e.message);
} finally {
client.release();
await pool.end();
}
}
run();

View File

@ -2896,7 +2896,8 @@ async function startServer() {
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
mensagemSucesso: r.mensagem_sucesso,
turmasPermitidas: r.turmas_permitidas || []
}});
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
});
@ -2906,8 +2907,8 @@ async function startServer() {
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 || '']
cor_primaria=$5, logo_url=$6, mensagem_sucesso=$7, turmas_permitidas=$8, updated_at=NOW() WHERE id=1`,
[c.titulo, c.descricao, c.slug, c.status, c.corPrimaria || '#4f46e5', c.logoUrl || '', c.mensagemSucesso || '', c.turmasPermitidas || []]
);
res.json({ success: true });
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
@ -2991,13 +2992,23 @@ async function startServer() {
});
// Página pública de pré-matrícula (API que retorna dados do formulário)
app.get('/api/prematricula/public/:slug', async (req, res) => {
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']);
const slug = req.params.slug || 'pre-matricula';
const { rows: cfgRows } = await pool.query(
'SELECT * FROM prematricula_config WHERE (slug = $1 OR id = 1) AND status = $2 ORDER BY id ASC LIMIT 1',
[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');
let turmasRows = turmasResult.rows;
if (cfg.turmas_permitidas && cfg.turmas_permitidas.length > 0) {
turmasRows = turmasRows.filter(t => cfg.turmas_permitidas.includes(t.id));
}
const appData = await getSchoolData();
res.json({
config: {
@ -3008,13 +3019,23 @@ async function startServer() {
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 })),
turmas: turmasRows.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', async (req, res) => {
try {
const { rows } = await pool.query('SELECT slug FROM prematricula_config WHERE id = 1');
const slug = rows[0]?.slug || 'pre-matricula';
res.send(getPreMatriculaHTML(slug));
} catch (e) {
res.send(getPreMatriculaHTML('pre-matricula'));
}
});
app.get('/pre-matricula/:slug', (req, res) => {
const { slug } = req.params;
res.send(getPreMatriculaHTML(slug));

View File

@ -278,6 +278,7 @@ export interface PreMatriculaConfig {
corPrimaria: string;
logoUrl: string;
mensagemSucesso: string;
turmasPermitidas?: string[];
}
export interface PreMatriculaInscricao {