feat(prematricula): duplicate checks, draft warning, flyer banner uploader in MinIO, clean hours
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 2m9s
Details
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 2m9s
Details
This commit is contained in:
parent
c112570065
commit
640604a5df
|
|
@ -3,7 +3,7 @@ import { SchoolData, PreMatriculaCampo, PreMatriculaConfig, PreMatriculaInscrica
|
||||||
import {
|
import {
|
||||||
Plus, Trash2, Save, Eye, EyeOff, Download, GripVertical, Link2,
|
Plus, Trash2, Save, Eye, EyeOff, Download, GripVertical, Link2,
|
||||||
ClipboardPen, Copy, Check, ChevronDown, ChevronRight, Users, RefreshCw,
|
ClipboardPen, Copy, Check, ChevronDown, ChevronRight, Users, RefreshCw,
|
||||||
FileText, X, Settings2, ArrowLeft, ExternalLink, AlertCircle, UserCheck, Edit
|
FileText, X, Settings2, ArrowLeft, ExternalLink, AlertCircle, UserCheck, Edit, Upload
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -14,7 +14,8 @@ interface Props {
|
||||||
|
|
||||||
const FIELD_TYPES: Record<string, string> = {
|
const FIELD_TYPES: Record<string, string> = {
|
||||||
text: 'Texto', email: 'Email', phone: 'Telefone', cpf: 'CPF',
|
text: 'Texto', email: 'Email', phone: 'Telefone', cpf: 'CPF',
|
||||||
date: 'Data', select: 'Seleção', textarea: 'Texto Longo', number: 'Número'
|
date: 'Data', select: 'Seleção', textarea: 'Texto Longo', number: 'Número',
|
||||||
|
file: 'Imagem/Documento'
|
||||||
};
|
};
|
||||||
|
|
||||||
const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'];
|
const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'];
|
||||||
|
|
@ -339,13 +340,9 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
|
||||||
{config.turmasPermitidas.map(id => {
|
{config.turmasPermitidas.map(id => {
|
||||||
const turma = turmas.find(t => t.id === id);
|
const turma = turmas.find(t => t.id === id);
|
||||||
if (!turma) return null;
|
if (!turma) return null;
|
||||||
const dayName = (turma.dia_semana !== null && turma.dia_semana !== undefined && DAY_NAMES[parseInt(turma.dia_semana)])
|
const horarioStr = turma.horario_inicio_padrao && turma.horario_fim_padrao
|
||||||
? DAY_NAMES[parseInt(turma.dia_semana)]
|
? `${turma.horario_inicio_padrao} às ${turma.horario_fim_padrao}`
|
||||||
: (turma.horario || '');
|
: 'Horário não definido';
|
||||||
const timeStr = turma.horario_inicio_padrao && turma.horario_fim_padrao
|
|
||||||
? ` das ${turma.horario_inicio_padrao} às ${turma.horario_fim_padrao}`
|
|
||||||
: '';
|
|
||||||
const horarioStr = `${dayName}${timeStr}`.trim() || 'Horário não definido';
|
|
||||||
return (
|
return (
|
||||||
<div key={id} className="flex items-center justify-between p-3.5 bg-slate-50 border border-slate-100 rounded-2xl shadow-sm text-xs group hover:border-indigo-100 transition-colors">
|
<div key={id} className="flex items-center justify-between p-3.5 bg-slate-50 border border-slate-100 rounded-2xl shadow-sm text-xs group hover:border-indigo-100 transition-colors">
|
||||||
<div className="flex-1 min-w-0 mr-3">
|
<div className="flex-1 min-w-0 mr-3">
|
||||||
|
|
@ -391,6 +388,61 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
|
||||||
Se nenhuma turma for adicionada, todas as turmas ativas aparecerão para seleção do aluno. Defina o limite de vagas (deixe em branco para ilimitado).
|
Se nenhuma turma for adicionada, todas as turmas ativas aparecerão para seleção do aluno. Defina o limite de vagas (deixe em branco para ilimitado).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Folder / Panfleto de Promoção (Banner)</label>
|
||||||
|
{config?.bannerUrl ? (
|
||||||
|
<div className="relative group border border-slate-200 rounded-xl overflow-hidden shadow-sm mb-3">
|
||||||
|
<img src={config.bannerUrl} alt="Banner" className="w-full h-32 object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-slate-900/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfig(prev => prev ? { ...prev, bannerUrl: '' } : prev)}
|
||||||
|
className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-[11px] font-bold rounded-lg shadow transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Remover Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center w-full mb-3">
|
||||||
|
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-slate-200 border-dashed rounded-xl cursor-pointer bg-slate-50 hover:bg-slate-100/70 border-slate-300 transition-all">
|
||||||
|
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
|
<Upload className="w-8 h-8 text-slate-400 mb-2" />
|
||||||
|
<p className="text-xs text-slate-500 font-bold">Upload do Folder/Flyer</p>
|
||||||
|
<p className="text-[9px] text-slate-400 mt-1">PNG, JPG ou WEBP até 10MB</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/prematricula/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const result = await res.json();
|
||||||
|
setConfig(prev => prev ? { ...prev, bannerUrl: result.url } : prev);
|
||||||
|
} else {
|
||||||
|
alert('Falha ao enviar o folder.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Erro de conexão ao enviar.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Mensagem de Sucesso</label>
|
<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}
|
<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}
|
||||||
|
|
@ -679,7 +731,25 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<p className="font-bold text-slate-800">{insc.nome}</p>
|
<p className="font-bold text-slate-800">{insc.nome}</p>
|
||||||
{insc.respostas && Object.keys(insc.respostas).length > 0 && (
|
{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>
|
<div className="mt-2 space-y-1 bg-slate-50 p-2.5 rounded-xl border border-slate-100 text-[11px] max-w-xs">
|
||||||
|
{Object.entries(insc.respostas).map(([key, value]) => {
|
||||||
|
const field = campos.find(c => c.id === key);
|
||||||
|
const label = field ? field.label : key;
|
||||||
|
const isUrl = typeof value === 'string' && (value.startsWith('http') || value.startsWith('/storage/'));
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col sm:flex-row sm:items-center gap-1">
|
||||||
|
<span className="font-bold text-slate-500">{label}:</span>
|
||||||
|
{isUrl ? (
|
||||||
|
<a href={value} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-700 hover:underline font-extrabold flex items-center gap-1">
|
||||||
|
📄 Ver Documento
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-800 font-semibold truncate">{String(value)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ import {
|
||||||
getFrequencias, insertFrequencia, updateFrequencia, deleteFrequencia,
|
getFrequencias, insertFrequencia, updateFrequencia, deleteFrequencia,
|
||||||
getProvas, getQuestoesDaProva, insertProva, updateProva, deleteProva, syncQuestoesProva
|
getProvas, getQuestoesDaProva, insertProva, updateProva, deleteProva, syncQuestoesProva
|
||||||
} from './services/database.js';
|
} from './services/database.js';
|
||||||
import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, uploadReceipt as uploadReceiptToStorage, getMinioStats, s3Client, getBucketObjects, deleteMinioObject } from './services/storage.js';
|
import { uploadLogo as uploadLogoToStorage, uploadCarne as uploadCarneToStorage, uploadReceipt as uploadReceiptToStorage, getMinioStats, s3Client, getBucketObjects, deleteMinioObject, uploadFile } from './services/storage.js';
|
||||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
|
@ -163,11 +163,16 @@ async function init(){
|
||||||
const res=await fetch('/api/prematricula/public/'+slug);
|
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}
|
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 data=await res.json();
|
||||||
|
if(data.draft){
|
||||||
|
app.innerHTML='<div class="error-page"><h2>Todas as turmas estão lotadas</h2><p style="margin-top:12px;font-weight:600;color:#64748b">Em breve abriremos novas vagas. Fique atento!</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const c=data.config;
|
const c=data.config;
|
||||||
document.documentElement.style.setProperty('--primary',c.corPrimaria||'#4f46e5');
|
document.documentElement.style.setProperty('--primary',c.corPrimaria||'#4f46e5');
|
||||||
let html='<div class="top-bar"></div><div class="header">';
|
let html='<div class="top-bar"></div><div class="header">';
|
||||||
if(data.escola.logo)html+='<img src="'+data.escola.logo+'" alt="Logo">';
|
if(data.escola.logo)html+='<img src="'+data.escola.logo+'" alt="Logo">';
|
||||||
html+='<h1>'+c.titulo+'</h1><p>'+c.descricao+'</p></div>';
|
html+='<h1>'+c.titulo+'</h1><p>'+c.descricao+'</p></div>';
|
||||||
|
if(c.bannerUrl)html+='<div style="padding:0 32px 24px;text-align:center"><img src="'+c.bannerUrl+'" alt="Banner" style="width:100%;max-height:350px;object-fit:cover;border-radius:16px;box-shadow:0 4px 12px rgba(0,0,0,.05)"></div>';
|
||||||
html+='<form class="form" id="preForm" onsubmit="return submitForm(event)">';
|
html+='<form class="form" id="preForm" onsubmit="return submitForm(event)">';
|
||||||
|
|
||||||
// Render fields dynamically in their exact database order
|
// Render fields dynamically in their exact database order
|
||||||
|
|
@ -185,8 +190,7 @@ async function init(){
|
||||||
html+='<select name="_turma"'+(f.obrigatorio?' required':'')+'><option value="">Selecione a turma...</option>';
|
html+='<select name="_turma"'+(f.obrigatorio?' required':'')+'><option value="">Selecione a turma...</option>';
|
||||||
data.turmas.forEach(t=>{
|
data.turmas.forEach(t=>{
|
||||||
const info=t.horario?' ('+t.horario+')':'';
|
const info=t.horario?' ('+t.horario+')':'';
|
||||||
const vagasInfo=t.vagas!==null?' - '+t.vagasRestantes+' vagas restando':'';
|
const label=t.esgotado?t.nome+info+' [ESGOTADO]':t.nome+info;
|
||||||
const label=t.esgotado?t.nome+info+' [ESGOTADO]':t.nome+info+vagasInfo;
|
|
||||||
const disabled=t.esgotado?' disabled':'';
|
const disabled=t.esgotado?' disabled':'';
|
||||||
html+='<option value="'+t.id+'" data-nome="'+t.nome+'"'+disabled+'>'+label+'</option>';
|
html+='<option value="'+t.id+'" data-nome="'+t.nome+'"'+disabled+'>'+label+'</option>';
|
||||||
});
|
});
|
||||||
|
|
@ -209,7 +213,9 @@ async function init(){
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(f.tipo==='select'){
|
if(f.tipo==='file'){
|
||||||
|
html+='<input type="file" name="'+f.id+'"'+(f.obrigatorio?' required':'')+' accept="image/*,application/pdf">';
|
||||||
|
}else if(f.tipo==='select'){
|
||||||
html+='<select name="'+f.id+'"'+(f.obrigatorio?' required':'')+'><option value="">Selecione...</option>';
|
html+='<select name="'+f.id+'"'+(f.obrigatorio?' required':'')+'><option value="">Selecione...</option>';
|
||||||
(f.opcoes||[]).forEach(o=>{html+='<option value="'+o+'">'+o+'</option>'});
|
(f.opcoes||[]).forEach(o=>{html+='<option value="'+o+'">'+o+'</option>'});
|
||||||
html+='</select>';
|
html+='</select>';
|
||||||
|
|
@ -255,7 +261,36 @@ async function submitForm(e){
|
||||||
const turmaId=turmaSelect?turmaSelect.value:'';
|
const turmaId=turmaSelect?turmaSelect.value:'';
|
||||||
const turmaNome=turmaSelect?turmaSelect.options[turmaSelect.selectedIndex]?.dataset?.nome||'':'';
|
const turmaNome=turmaSelect?turmaSelect.options[turmaSelect.selectedIndex]?.dataset?.nome||'':'';
|
||||||
const respostas={};
|
const respostas={};
|
||||||
for(const[k,v]of fd.entries()){if(!k.startsWith('_'))respostas[k]=v}
|
|
||||||
|
// Upload file inputs to MinIO first
|
||||||
|
const fileInputs = form.querySelectorAll('input[type="file"]');
|
||||||
|
for (const input of fileInputs) {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (file) {
|
||||||
|
btn.textContent = 'Enviando arquivo...';
|
||||||
|
const fileFd = new FormData();
|
||||||
|
fileFd.append('file', file);
|
||||||
|
try {
|
||||||
|
const uploadRes = await fetch('/api/prematricula/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fileFd
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok) throw new Error();
|
||||||
|
const uploadData = await uploadRes.json();
|
||||||
|
respostas[input.name] = uploadData.url;
|
||||||
|
} catch(err) {
|
||||||
|
alert('Falha ao enviar arquivo. Tente novamente.');
|
||||||
|
btn.disabled=false;btn.textContent='Enviar Pré-Matrícula';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const[k,v]of fd.entries()){
|
||||||
|
if(!k.startsWith('_') && !(v instanceof File)){
|
||||||
|
respostas[k]=v;
|
||||||
|
}
|
||||||
|
}
|
||||||
try{
|
try{
|
||||||
const res=await fetch('/api/prematricula/submit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nome,email,telefone,turmaId,turmaNome,respostas})});
|
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){
|
if(res.ok){
|
||||||
|
|
@ -2998,7 +3033,8 @@ async function startServer() {
|
||||||
mensagemSucesso: r.mensagem_sucesso,
|
mensagemSucesso: r.mensagem_sucesso,
|
||||||
turmasPermitidas: r.turmas_permitidas || [],
|
turmasPermitidas: r.turmas_permitidas || [],
|
||||||
exibirEmail: r.exibir_email !== false,
|
exibirEmail: r.exibir_email !== false,
|
||||||
exibirTelefone: r.exibir_telefone !== false
|
exibirTelefone: r.exibir_telefone !== false,
|
||||||
|
bannerUrl: r.banner_url || ''
|
||||||
}});
|
}});
|
||||||
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
|
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
@ -3009,11 +3045,11 @@ async function startServer() {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE prematricula_config SET titulo=$1, descricao=$2, slug=$3, status=$4,
|
`UPDATE prematricula_config SET titulo=$1, descricao=$2, slug=$3, status=$4,
|
||||||
cor_primaria=$5, logo_url=$6, mensagem_sucesso=$7, turmas_permitidas=$8,
|
cor_primaria=$5, logo_url=$6, mensagem_sucesso=$7, turmas_permitidas=$8,
|
||||||
exibir_email=$9, exibir_telefone=$10, updated_at=NOW() WHERE id=1`,
|
exibir_email=$9, exibir_telefone=$10, banner_url=$11, updated_at=NOW() WHERE id=1`,
|
||||||
[
|
[
|
||||||
c.titulo, c.descricao, c.slug, c.status, c.corPrimaria || '#4f46e5',
|
c.titulo, c.descricao, c.slug, c.status, c.corPrimaria || '#4f46e5',
|
||||||
c.logoUrl || '', c.mensagemSucesso || '', c.turmasPermitidas || [],
|
c.logoUrl || '', c.mensagemSucesso || '', c.turmasPermitidas || [],
|
||||||
c.exibirEmail !== false, c.exibirTelefone !== false
|
c.exibirEmail !== false, c.exibirTelefone !== false, c.bannerUrl || ''
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|
@ -3105,6 +3141,26 @@ async function startServer() {
|
||||||
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
|
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upload público de arquivos de pré-matrícula para o MinIO
|
||||||
|
const prematriculaUpload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 } // 10MB
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/prematricula/upload', prematriculaUpload.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
|
||||||
|
}
|
||||||
|
const fileName = `pre_${Date.now()}_${Math.random().toString(36).substring(7)}_${req.file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
|
||||||
|
const url = await uploadFile('prematriculas', fileName, req.file.buffer, req.file.mimetype);
|
||||||
|
res.json({ url });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PreMatricula:Upload] Erro de upload:', e);
|
||||||
|
res.status(500).json({ error: 'Erro ao fazer upload do arquivo no storage local.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Submissão pública
|
// Submissão pública
|
||||||
app.post('/api/prematricula/submit', async (req, res) => {
|
app.post('/api/prematricula/submit', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -3114,6 +3170,27 @@ async function startServer() {
|
||||||
const config = (await pool.query('SELECT status FROM prematricula_config WHERE id = 1')).rows[0];
|
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.' });
|
if (!config || config.status !== 'published') return res.status(403).json({ error: 'Formulário não disponível.' });
|
||||||
|
|
||||||
|
// Impedir duplicidade por e-mail ou telefone
|
||||||
|
if ((email && email.trim()) || (telefone && telefone.trim())) {
|
||||||
|
let dupQuery = 'SELECT id FROM prematriculas WHERE 1=0';
|
||||||
|
const params = [];
|
||||||
|
if (email && email.trim()) {
|
||||||
|
dupQuery += ' OR LOWER(email) = LOWER($' + (params.length + 1) + ')';
|
||||||
|
params.push(email.trim());
|
||||||
|
}
|
||||||
|
if (telefone && telefone.trim()) {
|
||||||
|
const cleanPhone = telefone.replace(/\D/g, '');
|
||||||
|
if (cleanPhone) {
|
||||||
|
dupQuery += " OR REGEXP_REPLACE(telefone, '\\D', '', 'g') = $" + (params.length + 1);
|
||||||
|
params.push(cleanPhone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { rows: dupRows } = await pool.query(dupQuery, params);
|
||||||
|
if (dupRows.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Você já realizou uma pré-matrícula com esses dados de contato.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validação rigorosa de vagas
|
// Validação rigorosa de vagas
|
||||||
if (turmaId) {
|
if (turmaId) {
|
||||||
const { rows: tRows } = await pool.query('SELECT vagas_prematricula FROM turmas WHERE id = $1', [turmaId]);
|
const { rows: tRows } = await pool.query('SELECT vagas_prematricula FROM turmas WHERE id = $1', [turmaId]);
|
||||||
|
|
@ -3137,16 +3214,19 @@ async function startServer() {
|
||||||
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
|
} 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)
|
|
||||||
// Handler compartilhado para evitar duplicação
|
|
||||||
async function handlePublicPreMatricula(req, res, slug) {
|
async function handlePublicPreMatricula(req, res, slug) {
|
||||||
try {
|
try {
|
||||||
const { rows: cfgRows } = await pool.query(
|
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',
|
'SELECT * FROM prematricula_config WHERE (slug = $1 OR id = 1) ORDER BY id ASC LIMIT 1',
|
||||||
[slug, 'published']
|
[slug]
|
||||||
);
|
);
|
||||||
if (cfgRows.length === 0) return res.status(404).json({ error: 'Formulário não encontrado ou não publicado.' });
|
if (cfgRows.length === 0) return res.status(404).json({ error: 'Formulário não encontrado.' });
|
||||||
const cfg = cfgRows[0];
|
const cfg = cfgRows[0];
|
||||||
|
|
||||||
|
if (cfg.status === 'draft') {
|
||||||
|
return res.json({ draft: true });
|
||||||
|
}
|
||||||
|
|
||||||
const { rows: camposRows } = await pool.query('SELECT * FROM prematricula_campos WHERE ativo = true ORDER BY ordem ASC');
|
const { rows: camposRows } = await pool.query('SELECT * FROM prematricula_campos WHERE ativo = true ORDER BY ordem ASC');
|
||||||
|
|
||||||
// Buscar contagens de pré-matrículas ativas por turma para calcular as vagas restantes
|
// Buscar contagens de pré-matrículas ativas por turma para calcular as vagas restantes
|
||||||
|
|
@ -3169,7 +3249,8 @@ async function startServer() {
|
||||||
res.json({
|
res.json({
|
||||||
config: {
|
config: {
|
||||||
titulo: cfg.titulo, descricao: cfg.descricao, corPrimaria: cfg.cor_primaria,
|
titulo: cfg.titulo, descricao: cfg.descricao, corPrimaria: cfg.cor_primaria,
|
||||||
logoUrl: cfg.logo_url, mensagemSucesso: cfg.mensagem_sucesso
|
logoUrl: cfg.logo_url, mensagemSucesso: cfg.mensagem_sucesso,
|
||||||
|
bannerUrl: cfg.banner_url
|
||||||
},
|
},
|
||||||
campos: camposRows.map(r => ({
|
campos: camposRows.map(r => ({
|
||||||
id: r.id, label: r.label, tipo: r.tipo, placeholder: r.placeholder,
|
id: r.id, label: r.label, tipo: r.tipo, placeholder: r.placeholder,
|
||||||
|
|
@ -3179,14 +3260,9 @@ async function startServer() {
|
||||||
const count = prematriculaCounts[t.id] || 0;
|
const count = prematriculaCounts[t.id] || 0;
|
||||||
const vagas = t.vagas_prematricula;
|
const vagas = t.vagas_prematricula;
|
||||||
const esgotado = vagas !== null && count >= vagas;
|
const esgotado = vagas !== null && count >= vagas;
|
||||||
const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'];
|
const horarioStr = t.horario_inicio_padrao && t.horario_fim_padrao
|
||||||
const dayName = t.dia_semana !== null && t.dia_semana !== undefined && DAY_NAMES[parseInt(t.dia_semana)]
|
? `${t.horario_inicio_padrao} às ${t.horario_fim_padrao}`
|
||||||
? DAY_NAMES[parseInt(t.dia_semana)]
|
|
||||||
: (t.horario || '');
|
|
||||||
const timeStr = t.horario_inicio_padrao && t.horario_fim_padrao
|
|
||||||
? ` das ${t.horario_inicio_padrao} às ${t.horario_fim_padrao}`
|
|
||||||
: '';
|
: '';
|
||||||
const horarioStr = `${dayName}${timeStr}`.trim() || 'Horário não definido';
|
|
||||||
return {
|
return {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
nome: t.nome,
|
nome: t.nome,
|
||||||
|
|
@ -3218,8 +3294,8 @@ async function startServer() {
|
||||||
try {
|
try {
|
||||||
// Verifica se a slug atual corresponde a alguma configuração de pré-matrícula publicada
|
// Verifica se a slug atual corresponde a alguma configuração de pré-matrícula publicada
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
'SELECT slug FROM prematricula_config WHERE slug = $1 AND status = $2 LIMIT 1',
|
'SELECT slug FROM prematricula_config WHERE slug = $1 LIMIT 1',
|
||||||
[slug, 'published']
|
[slug]
|
||||||
);
|
);
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
// Se bater, serve a página pública renderizando a slug dinâmica!
|
// Se bater, serve a página pública renderizando a slug dinâmica!
|
||||||
|
|
|
||||||
|
|
@ -194,3 +194,4 @@ ensureBucketExists('logos');
|
||||||
ensureBucketExists('fotos-alunos');
|
ensureBucketExists('fotos-alunos');
|
||||||
ensureBucketExists('atestados');
|
ensureBucketExists('atestados');
|
||||||
ensureBucketExists('exames');
|
ensureBucketExists('exames');
|
||||||
|
ensureBucketExists('prematriculas');
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,7 @@ export interface PreMatriculaConfig {
|
||||||
turmasPermitidas?: string[];
|
turmasPermitidas?: string[];
|
||||||
exibirEmail?: boolean;
|
exibirEmail?: boolean;
|
||||||
exibirTelefone?: boolean;
|
exibirTelefone?: boolean;
|
||||||
|
bannerUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PreMatriculaInscricao {
|
export interface PreMatriculaInscricao {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue