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 {
|
||||
Plus, Trash2, Save, Eye, EyeOff, Download, GripVertical, Link2,
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -14,7 +14,8 @@ interface Props {
|
|||
|
||||
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'
|
||||
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'];
|
||||
|
|
@ -339,13 +340,9 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
|
|||
{config.turmasPermitidas.map(id => {
|
||||
const turma = turmas.find(t => t.id === id);
|
||||
if (!turma) return null;
|
||||
const dayName = (turma.dia_semana !== null && turma.dia_semana !== undefined && DAY_NAMES[parseInt(turma.dia_semana)])
|
||||
? DAY_NAMES[parseInt(turma.dia_semana)]
|
||||
: (turma.horario || '');
|
||||
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';
|
||||
const horarioStr = turma.horario_inicio_padrao && turma.horario_fim_padrao
|
||||
? `${turma.horario_inicio_padrao} às ${turma.horario_fim_padrao}`
|
||||
: 'Horário não definido';
|
||||
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 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).
|
||||
</p>
|
||||
</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>
|
||||
<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}
|
||||
|
|
@ -679,7 +731,25 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
|
|||
<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>
|
||||
<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 className="p-4">
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ import {
|
|||
getFrequencias, insertFrequencia, updateFrequencia, deleteFrequencia,
|
||||
getProvas, getQuestoesDaProva, insertProva, updateProva, deleteProva, syncQuestoesProva
|
||||
} 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';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
|
@ -163,11 +163,16 @@ async function init(){
|
|||
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();
|
||||
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;
|
||||
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>';
|
||||
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)">';
|
||||
|
||||
// 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>';
|
||||
data.turmas.forEach(t=>{
|
||||
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+vagasInfo;
|
||||
const label=t.esgotado?t.nome+info+' [ESGOTADO]':t.nome+info;
|
||||
const disabled=t.esgotado?' disabled':'';
|
||||
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>';
|
||||
(f.opcoes||[]).forEach(o=>{html+='<option value="'+o+'">'+o+'</option>'});
|
||||
html+='</select>';
|
||||
|
|
@ -255,7 +261,36 @@ async function submitForm(e){
|
|||
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}
|
||||
|
||||
// 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{
|
||||
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){
|
||||
|
|
@ -2998,7 +3033,8 @@ async function startServer() {
|
|||
mensagemSucesso: r.mensagem_sucesso,
|
||||
turmasPermitidas: r.turmas_permitidas || [],
|
||||
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 }); }
|
||||
});
|
||||
|
|
@ -3009,11 +3045,11 @@ async function startServer() {
|
|||
await pool.query(
|
||||
`UPDATE prematricula_config SET titulo=$1, descricao=$2, slug=$3, status=$4,
|
||||
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.logoUrl || '', c.mensagemSucesso || '', c.turmasPermitidas || [],
|
||||
c.exibirEmail !== false, c.exibirTelefone !== false
|
||||
c.exibirEmail !== false, c.exibirTelefone !== false, c.bannerUrl || ''
|
||||
]
|
||||
);
|
||||
res.json({ success: true });
|
||||
|
|
@ -3105,6 +3141,26 @@ async function startServer() {
|
|||
} 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
|
||||
app.post('/api/prematricula/submit', async (req, res) => {
|
||||
try {
|
||||
|
|
@ -3114,6 +3170,27 @@ async function startServer() {
|
|||
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.' });
|
||||
|
||||
// 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
|
||||
if (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 }); }
|
||||
});
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
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']
|
||||
'SELECT * FROM prematricula_config WHERE (slug = $1 OR id = 1) ORDER BY id ASC LIMIT 1',
|
||||
[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];
|
||||
|
||||
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');
|
||||
|
||||
// Buscar contagens de pré-matrículas ativas por turma para calcular as vagas restantes
|
||||
|
|
@ -3169,7 +3249,8 @@ async function startServer() {
|
|||
res.json({
|
||||
config: {
|
||||
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 => ({
|
||||
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 vagas = t.vagas_prematricula;
|
||||
const esgotado = vagas !== null && count >= vagas;
|
||||
const DAY_NAMES = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'];
|
||||
const dayName = t.dia_semana !== null && t.dia_semana !== undefined && DAY_NAMES[parseInt(t.dia_semana)]
|
||||
? 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 = t.horario_inicio_padrao && t.horario_fim_padrao
|
||||
? `${t.horario_inicio_padrao} às ${t.horario_fim_padrao}`
|
||||
: '';
|
||||
const horarioStr = `${dayName}${timeStr}`.trim() || 'Horário não definido';
|
||||
return {
|
||||
id: t.id,
|
||||
nome: t.nome,
|
||||
|
|
@ -3218,8 +3294,8 @@ async function startServer() {
|
|||
try {
|
||||
// Verifica se a slug atual corresponde a alguma configuração de pré-matrícula publicada
|
||||
const { rows } = await pool.query(
|
||||
'SELECT slug FROM prematricula_config WHERE slug = $1 AND status = $2 LIMIT 1',
|
||||
[slug, 'published']
|
||||
'SELECT slug FROM prematricula_config WHERE slug = $1 LIMIT 1',
|
||||
[slug]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
// Se bater, serve a página pública renderizando a slug dinâmica!
|
||||
|
|
|
|||
|
|
@ -194,3 +194,4 @@ ensureBucketExists('logos');
|
|||
ensureBucketExists('fotos-alunos');
|
||||
ensureBucketExists('atestados');
|
||||
ensureBucketExists('exames');
|
||||
ensureBucketExists('prematriculas');
|
||||
|
|
|
|||
|
|
@ -281,6 +281,7 @@ export interface PreMatriculaConfig {
|
|||
turmasPermitidas?: string[];
|
||||
exibirEmail?: boolean;
|
||||
exibirTelefone?: boolean;
|
||||
bannerUrl?: string;
|
||||
}
|
||||
|
||||
export interface PreMatriculaInscricao {
|
||||
|
|
|
|||
Loading…
Reference in New Issue