coneccion muchos endpoint y validacions

This commit is contained in:
jorgemike
2025-04-04 16:43:18 -06:00
parent f522db5211
commit 6c51e326dd
16 changed files with 855 additions and 115 deletions
@@ -8,7 +8,11 @@ import Input from '@/components/input';
import Button from '@/components/button';
import FloatingInput from '@/components/floating-input';
import { plantillasDisponibles } from '@/data/plantillas';
import { FormularioCreacion, PreguntaFormulario, SeccionFormulario } from '@/types/crear-formulario';
import {
FormularioCreacion,
PreguntaFormulario,
SeccionFormulario,
} from '@/types/crear-formulario';
import toast from 'react-hot-toast';
export default function AdminFormPage() {
@@ -79,6 +83,8 @@ export default function AdminFormPage() {
tipo: 'Abierta',
opciones: [],
obligatoria: false,
limite: 250,
validacion: undefined,
});
setSecciones(nuevas);
};
@@ -109,7 +115,9 @@ export default function AdminFormPage() {
const agregarOpcion = (seccionIndex: number, preguntaIndex: number) => {
const nuevas = [...secciones];
if (nuevas[seccionIndex]?.preguntas[preguntaIndex]?.opciones) {
nuevas[seccionIndex].preguntas[preguntaIndex].opciones!.push({ valor: '' });
nuevas[seccionIndex].preguntas[preguntaIndex].opciones!.push({
valor: '',
});
}
setSecciones(nuevas);
};
@@ -138,7 +146,9 @@ export default function AdminFormPage() {
nuevas[seccionIndex]?.preguntas[preguntaIndex]?.opciones &&
nuevas[seccionIndex].preguntas[preguntaIndex].opciones![opcionIndex]
) {
nuevas[seccionIndex].preguntas[preguntaIndex].opciones![opcionIndex].valor = valor;
nuevas[seccionIndex].preguntas[preguntaIndex].opciones![
opcionIndex
].valor = valor;
}
setSecciones(nuevas);
};
@@ -167,20 +177,21 @@ export default function AdminFormPage() {
console.log('Formulario a guardar:', formData);
setLoading(true);
await toast.promise(
axiosInstance.post('/cuestionario', formData),
{
await toast
.promise(axiosInstance.post('/cuestionario', formData), {
loading: 'Guardando formulario...',
success: 'Formulario guardado exitosamente!',
error: 'Error al guardar el formulario.',
}
).then((res) => {
console.log('Formulario guardado:', res.data);
}).catch((error) => {
console.error('Error al guardar el formulario:', error);
}).finally(() => {
setLoading(false);
});
})
.then((res) => {
console.log('Formulario guardado:', res.data);
})
.catch((error) => {
console.error('Error al guardar el formulario:', error);
})
.finally(() => {
setLoading(false);
});
};
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -442,6 +453,47 @@ export default function AdminFormPage() {
}
/>
{pregunta.tipo === 'Abierta' && (
<div className="row">
<div className="col-md-6">
<Input
label="Límite de caracteres"
type="number"
value={pregunta.limite?.toString() || ''}
onChange={(e) =>
actualizarPregunta(
paginaActual - 1,
pIdx,
'limite',
parseInt(e.target.value, 10)
)
}
placeholder="Máximo de caracteres permitidos"
/>
</div>
<div className="col-md-6">
<Select
label="Validación"
value={pregunta.validacion || ''}
options={[
{ label: 'Sin validación', value: '' },
{ label: 'Correo', value: 'correo' },
{ label: 'Número de cuenta', value: 'cuenta_alumno' },
{ label: 'Nombre/Texto', value: 'nombre' },
]}
onChange={(e) =>
actualizarPregunta(
paginaActual - 1,
pIdx,
'validacion',
e.target.value as PreguntaFormulario['validacion']
)
}
/>
</div>
</div>
)}
<div className="form-check form-switch mb-3">
<input
className="form-check-input"
@@ -1,12 +1,143 @@
'use client';
import Input from '@/components/input';
import Table, { Header } from '@/components/table';
import { ParticipacionEvento } from '@/types/participante-evento';
import axiosInstance from '@/utils/api-config';
import { useParams } from 'next/navigation';
import React from 'react';
import React, { useEffect } from 'react';
import toast from 'react-hot-toast';
export default function Page() {
const params = useParams<{
id_cuestionario: string;
}>();
const id_cuestionario = params?.id_cuestionario;
const params = useParams<{ id_formulario: string }>();
const id_formulario = Number(params?.id_formulario);
return <div>{id_cuestionario}</div>;
const [participantes, setParticipantes] = React.useState<
ParticipacionEvento[]
>([]);
const [loadingAsistencia, setLoadingAsistencia] = React.useState<
Record<number, boolean>
>({});
const [busquedaCorreo, setBusquedaCorreo] = React.useState('');
useEffect(() => {
if (!id_formulario) return;
const fetchParticipantes = async () => {
try {
const { data } = await axiosInstance.get<ParticipacionEvento[]>(
`/participante-evento/evento/${id_formulario}`
);
setParticipantes(data);
} catch (error) {
console.error('Error al obtener participantes:', error);
}
};
fetchParticipantes();
}, [id_formulario]);
const confirmarAsistencia = async (
id_participante: number,
id_evento: number
) => {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: true }));
try {
await axiosInstance.post(
`/participante-evento/asistencia/${id_participante}/${id_evento}`
);
toast.success('Asistencia confirmada');
// Actualiza lista
const { data } = await axiosInstance.get<ParticipacionEvento[]>(
`/participante-evento/evento/${id_evento}`
);
setParticipantes(data);
} catch (error) {
toast.error('Error al confirmar asistencia');
console.error(error);
} finally {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: false }));
}
};
const headers: Header<ParticipacionEvento>[] = [
{
key: 'id_participante',
label: '#',
render: (_, row) => row.participante.id_participante,
},
{
key: 'participante',
label: 'Correo',
render: (_, row) => row.participante.correo,
},
{
key: 'fecha_inscripcion',
label: 'Fecha inscripción',
render: (val) => new Date(val as string).toLocaleString(),
},
{
key: 'estatus',
label: 'Asistencia',
render: (_, row) => {
const isLoading = loadingAsistencia[row.id_participante];
return row.fecha_asistencia ? (
<span className="text-success">
<i className="bi bi-check-circle-fill me-2"></i>
Asistió
</span>
) : (
<button
className="btn btn-sm btn-outline-success"
onClick={() =>
confirmarAsistencia(row.id_participante, row.id_evento)
}
disabled={isLoading}
>
{isLoading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Confirmando...
</>
) : (
'Confirmar asistencia'
)}
</button>
);
},
},
];
const participantesFiltrados = participantes.filter((p) =>
p.participante.correo.toLowerCase().includes(busquedaCorreo.toLowerCase())
);
return (
<div>
<h2 className="mb-4">Participantes del formulario #{id_formulario}</h2>
{participantes.length > 0 && (
<>
<Input
label="Buscar por correo"
placeholder="ejemplo@correo.com"
value={busquedaCorreo}
onChange={(e) => setBusquedaCorreo(e.target.value)}
/>
<div className="mt-4">
<Table
headers={headers}
data={participantesFiltrados}
rowKey={(row) => row.id_participante}
/>
</div>
</>
)}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
import Link from 'next/link';
import React from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className='d-flex flex-column min-vh-100'>
<Header />
<main className='container flex-grow-1'>
<nav className='d-flex gap-2 my-4'>
<Link href={'/staff/'} className='text-decoration-none'>
<div className='box'>Escaner</div>
</Link>
<Link
href={'/staff/lista-manual'}
className='text-decoration-none'
>
<div className='box'>Lista manual</div>
</Link>
</nav>
{children}
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,160 @@
'use client';
import Button from '@/components/button';
import Input from '@/components/input';
import Select from '@/components/select';
import Table, { Header } from '@/components/table';
import { GetCuestionario } from '@/types/cuestionario';
import { ParticipacionEvento } from '@/types/participante-evento';
import axiosInstance from '@/utils/api-config';
import React, { useEffect } from 'react';
import toast from 'react-hot-toast';
export default function Page() {
const headers: Header<ParticipacionEvento>[] = [
{
key: 'id_participante',
label: '#',
render: (_, row) => row.participante.id_participante,
},
{
key: 'participante',
label: 'Correo',
render: (_, row) => row.participante.correo,
},
{
key: 'fecha_inscripcion',
label: 'Fecha inscripción',
render: (val) => new Date(val as string).toLocaleString(),
},
{
key: 'estatus',
label: 'Asistencia',
render: (_, row) => {
const isLoading = loadingAsistencia[row.id_participante];
return row.fecha_asistencia ? (
<span className="text-success">
<i className="bi bi-check-circle-fill me-2"></i>
Asistió
</span>
) : (
<Button
size="sm"
outline
onClick={() =>
confirmarAsistencia(row.id_participante, row.id_evento)
}
disabled={isLoading}
>
{isLoading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Confirmando...
</>
) : (
'Confirmar asistencia'
)}
</Button>
);
},
},
];
const [eventos, setEventos] = React.useState<GetCuestionario[]>([]);
const [participantes, setParticipantes] = React.useState<
ParticipacionEvento[]
>([]);
const [loadingAsistencia, setLoadingAsistencia] = React.useState<
Record<number, boolean>
>({});
const [busquedaCorreo, setBusquedaCorreo] = React.useState('');
useEffect(() => {
const getEventos = async () => {
try {
const { data } = await axiosInstance.get(`/cuestionario`);
if (!data) throw new Error('No se encontraron eventos');
setEventos(data);
} catch (error) {
console.error('Error en getEventos:', error);
}
};
getEventos();
}, []);
const handleOnSelect = async (idSeleccionado: number) => {
try {
const { data } = await axiosInstance.get<ParticipacionEvento[]>(
`/participante-evento/evento/${idSeleccionado}`
);
setParticipantes(data);
} catch (error) {
console.error('Error al obtener participante-evento:', error);
}
};
const confirmarAsistencia = async (
id_participante: number,
id_evento: number
) => {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: true }));
try {
await axiosInstance.post(
`/participante-evento/asistencia/${id_participante}/${id_evento}`
);
toast.success('Asistencia confirmada');
// Actualiza lista
await handleOnSelect(id_evento);
} catch (error) {
toast.error('Error al confirmar asistencia');
console.error(error);
} finally {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: false }));
}
};
const participantesFiltrados = participantes.filter((p) =>
p.participante.correo.toLowerCase().includes(busquedaCorreo.toLowerCase())
);
return (
<div>
<Select
label="Selecciona un evento"
options={eventos.map((evento) => ({
value: evento.id_cuestionario,
label: evento.nombre_form,
}))}
onChange={(e) => {
const id = Number(e?.target?.value ?? e); // Por si el componente Select devuelve directamente el value
if (!isNaN(id)) handleOnSelect(id);
}}
placeholder="Selecciona un evento"
/>
{participantes.length > 0 && (
<>
<Input
label="Buscar por correo"
placeholder="ejemplo@correo.com"
value={busquedaCorreo}
onChange={(e) => setBusquedaCorreo(e.target.value)}
/>
<div className="mt-4">
<Table
headers={headers}
data={participantesFiltrados}
rowKey={(row) => row.id_participante}
/>
</div>
</>
)}
</div>
);
}
+61 -70
View File
@@ -5,8 +5,6 @@ import dynamic from 'next/dynamic';
import { Scanner } from '@yudiel/react-qr-scanner';
import Button from '@/components/button';
import axiosInstance from '@/utils/api-config';
import Header from '@/components/layout/header';
import Footer from '@/components/layout/footer';
const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
@@ -61,79 +59,72 @@ export default function Page() {
};
return (
<div className="d-flex flex-column min-vh-100">
<Header />
<main className='container flex-grow-1 d-flex flex-column align-items-center justify-content-center'>
<h1 className="mb-4">Lectura de QRs</h1>
<main className="container flex-grow-1 d-flex flex-column align-items-center justify-content-center">
<h1 className="mb-4">Lectura de QRs</h1>
{enableScan ? (
{enableScan ? (
<>
<div style={{ width: '100%', maxWidth: '400px' }}>
<Scanner
onScan={(codes) => {
if (codes.length > 0) {
handleScan(codes[0].rawValue);
}
}}
onError={(err) => console.error('QR Error:', err)}
constraints={{ facingMode: 'environment' }}
/>
</div>
<Button
onClick={() => setEnableScan(false)}
variant="outline-danger"
className="mt-3"
>
Detener cámara
</Button>
</>
) : (
<>
<p className="text-muted mb-3">La cámara está detenida.</p>
<Button onClick={() => setEnableScan(true)} variant="outline-primary">
Activar cámara
</Button>
</>
)}
{statusMessage && <p className="mt-3">{statusMessage}</p>}
<Modal
isVisible={showModal}
size="md"
onClose={() => {
setShowModal(false);
setEnableScan(true);
setScannedData(null);
}}
closeButton
className={{
content: 'p-3',
body: 'text-center',
}}
>
<h5 className="mb-3">Datos escaneados</h5>
{scannedData ? (
<>
<div style={{ width: '100%', maxWidth: '400px' }}>
<Scanner
onScan={(codes) => {
if (codes.length > 0) {
handleScan(codes[0].rawValue);
}
}}
onError={(err) => console.error('QR Error:', err)}
constraints={{ facingMode: 'environment' }}
/>
</div>
<Button
onClick={() => setEnableScan(false)}
variant="outline-danger"
className="mt-3"
>
Detener cámara
<p>
<strong>ID Participante:</strong> {scannedData.id_participante}
</p>
<p>
<strong>ID Evento:</strong> {scannedData.id_evento}
</p>
<Button variant="success" onClick={handleConfirm}>
Confirmar lectura
</Button>
</>
) : (
<>
<p className="text-muted mb-3">La cámara está detenida.</p>
<Button
onClick={() => setEnableScan(true)}
variant="outline-primary"
>
Activar cámara
</Button>
</>
<p>QR inválido</p>
)}
{statusMessage && <p className="mt-3">{statusMessage}</p>}
<Modal
isVisible={showModal}
size="md"
onClose={() => {
setShowModal(false);
setEnableScan(true);
setScannedData(null);
}}
closeButton
className={{
content: 'p-3',
body: 'text-center',
}}
>
<h5 className="mb-3">Datos escaneados</h5>
{scannedData ? (
<>
<p>
<strong>ID Participante:</strong> {scannedData.id_participante}
</p>
<p>
<strong>ID Evento:</strong> {scannedData.id_evento}
</p>
<Button variant="success" onClick={handleConfirm}>
Confirmar lectura
</Button>
</>
) : (
<p>QR inválido</p>
)}
</Modal>
</main>
<Footer />
</div>
</Modal>
</main>
);
}
+1
View File
@@ -49,6 +49,7 @@ export default async function Page() {
key={cuestionario.id_cuestionario}
cuestionario={cuestionario}
link={`/registro/${cuestionario.id_cuestionario}`}
button_message='Registrarse'
/>
))}
{eventos.length === 0 && (
@@ -5,6 +5,7 @@ import Input from '@/components/input';
import RadioOptionGroup from '@/components/radio-option-group';
import { CuestionarioResponse } from '@/types/responder-formulario';
import axiosInstance from '@/utils/api-config';
import { validarRespuesta } from '@/utils/validador';
import Image from 'next/image';
import { useParams, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
@@ -120,29 +121,49 @@ export default function Page() {
const validarRespuestasObligatorias = (): boolean => {
const faltantes: number[] = [];
const errores: { id: number; mensaje: string }[] = [];
cuestionario?.cuestionario.secciones.forEach((seccion) => {
seccion.preguntas.forEach(({ pregunta }) => {
if (pregunta.obligatoria) {
const valor = respuestas[pregunta.id_pregunta];
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
const id = pregunta.id_pregunta;
const valor = respuestas[id];
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
const validacion = pregunta.validacion;
const estaRespondida =
(tipo === 'Abierta' &&
typeof valor === 'string' &&
valor.trim() !== '') ||
(tipo === 'Cerrada' && valor !== undefined) ||
(tipo === 'Multiple' && Array.isArray(valor) && valor.length > 0);
const respondida =
(tipo === 'Abierta' &&
typeof valor === 'string' &&
valor.trim() !== '') ||
(tipo === 'Cerrada' && valor !== undefined) ||
(tipo === 'Multiple' && Array.isArray(valor) && valor.length > 0);
if (!estaRespondida) {
faltantes.push(pregunta.id_pregunta);
if (!respondida) {
if (pregunta.obligatoria) {
faltantes.push(id);
}
return; // no hay nada más que validar si está vacía
}
if (tipo === 'Abierta' && validacion) {
const resultado = validarRespuesta(valor as string, validacion);
if (!resultado.valido) {
errores.push({ id, mensaje: resultado.mensaje });
}
}
});
});
if (faltantes.length > 0) {
alert(`Responde todas las preguntas obligatorias antes de enviar.`);
toast.error('Responde todas las preguntas obligatorias antes de enviar.');
return false;
}
if (errores.length > 0) {
toast.error(
`Corrige las respuestas con error de formato:\n${errores
.map((e) => `${e.mensaje}`)
.join('\n')}`
);
return false;
}
@@ -197,8 +218,23 @@ export default function Page() {
? respuestas[id].join(', ')
: respuestas[id] || ''
}
maxLength={250} // Aplica límite si existe
onChange={(e) => handleInputChange(id, e.target.value)}
required={pregunta.obligatoria}
placeholder={
pregunta.validacion === 'correo'
? 'ejemplo@correo.com'
: pregunta.validacion === 'cuenta_alumno'
? '123456789'
: undefined
}
type={
pregunta.validacion === 'correo'
? 'email'
: pregunta.validacion === 'cuenta_alumno'
? 'number'
: 'text'
}
/>
);
}
+21 -2
View File
@@ -4,13 +4,21 @@ import Link from 'next/link';
import { GetCuestionario } from '@/types/cuestionario';
import React, { useState } from 'react';
import Image from 'next/image';
import Button from './button';
import { useRouter } from 'next/navigation';
interface Props {
cuestionario: GetCuestionario;
link: string;
button_message?: string;
}
export default function FormularioCard({ cuestionario, link }: Props) {
export default function FormularioCard({
cuestionario,
link,
button_message,
}: Props) {
const router = useRouter();
const [verMas, setVerMas] = useState(false);
const descripcion = cuestionario.descripcion;
const limite = 100;
@@ -26,7 +34,13 @@ export default function FormularioCard({ cuestionario, link }: Props) {
<div>
<div className="card-image">
<Link href={link}>
<Image width={400} height={300} className="img" src="/banner1.png" alt="Banner formulario" />
<Image
width={400}
height={300}
className="img"
src="/banner1.png"
alt="Banner formulario"
/>
<div className="card-caption">{cuestionario.nombre_form}</div>
</Link>
<div className="ripple-cont"></div>
@@ -48,6 +62,11 @@ export default function FormularioCard({ cuestionario, link }: Props) {
</button>
)}
</p>
{button_message && (
<Button onClick={() => router.push(link)} outline className='mt-3 w-100 py-1 rounded' variant='primary'>
{button_message}
</Button>
)}
</div>
</div>
</div>
+1
View File
@@ -1,3 +1,4 @@
// @components/input.tsx
import React, { useState } from 'react';
export interface InputProps
+1
View File
@@ -1,3 +1,4 @@
// @components/table.tsx
import React, { useState, useMemo } from "react";
import Pagination from "./pagination";
+24 -6
View File
@@ -1,4 +1,13 @@
export const plantillasDisponibles = [
import { FormularioCreacion } from '@/types/crear-formulario';
interface Plantilla {
imagen: string;
nombre: string;
id: string;
datos: FormularioCreacion;
}
export const plantillasDisponibles: Plantilla[] = [
{
imagen: '/banner1.png',
nombre: 'Registro para la Feria',
@@ -23,20 +32,33 @@ export const plantillasDisponibles = [
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta',
obligatoria: true,
limite: 250,
validacion: 'correo',
},
{
titulo: 'Numero de cuenta',
tipo: 'Abierta',
obligatoria: true,
limite: 250,
validacion: 'cuenta_alumno',
},
{
titulo: 'Nombre(s)',
tipo: 'Abierta',
obligatoria: true,
limite: 250,
validacion: 'nombre',
},
{
titulo: 'Apellidos',
tipo: 'Abierta',
obligatoria: true,
limite: 250,
validacion: 'nombre',
},
{
titulo: 'Género',
@@ -52,11 +74,7 @@ export const plantillasDisponibles = [
titulo: 'Institución de procedencia',
tipo: 'Abierta',
obligatoria: true,
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta',
obligatoria: true,
limite: 250,
},
],
},
+1 -3
View File
@@ -11,7 +11,6 @@
thead {
tr {
box-shadow: var(--bs-box-shadow-sm);
th {
background-color: var(--bg-header-table);
position: sticky;
@@ -39,8 +38,7 @@
tr {
border-radius: 8px;
overflow: hidden;
box-shadow: var(--bs-box-shadow-sm);
&.clickable-row {
cursor: pointer;
&:hover {
+4 -2
View File
@@ -16,9 +16,11 @@ export interface SeccionFormulario {
export interface PreguntaFormulario {
titulo: string;
tipo: 'Cerrada' | 'Abierta' | 'Multiple';
opciones?: OpcionFormulario[];
tipo: 'Abierta' | 'Cerrada' | 'Multiple';
opciones?: { valor: string }[];
obligatoria: boolean;
limite?: number;
validacion?: 'correo' | 'cuenta_alumno' | 'nombre'; // puedes ampliar esta lista si lo necesitas
}
export interface OpcionFormulario {
+15
View File
@@ -0,0 +1,15 @@
export interface ParticipacionEvento {
id_participante: number;
id_evento: number;
fecha_inscripcion: string; // formato ISO
estatus: boolean;
fecha_asistencia: string | null;
participante: Participante;
}
export interface Participante {
id_participante: number;
correo: string;
id_tipo_user: number;
}
+1
View File
@@ -50,6 +50,7 @@ export interface CuestionarioResponse {
obligatoria: boolean;
id_tipo_pregunta: number;
id_opcion_dependiente: number | null;
validacion: string | null;
tipo_pregunta: TipoPregunta;
opciones: PreguntaOpcion[];
}
+287
View File
@@ -0,0 +1,287 @@
/**
* Módulo de validación para respuestas de cuestionarios
* Este módulo puede ser utilizado tanto en backend (NestJS) como en frontend (Angular, React, etc.)
*/
// Interfaz para el resultado de validación
export interface ResultadoValidacion {
valido: boolean;
mensaje: string;
}
// Clase base para todos los validadores
export abstract class Validador {
abstract validar(valor: string): ResultadoValidacion;
}
// Clase para validar correos electrónicos
export class ValidadorCorreo extends Validador {
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'El correo electrónico es requerido'
};
}
// Expresión regular para validar correos
// Valida el formato básico de correos: usuario@dominio.extensión
const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!regex.test(valor)) {
return {
valido: false,
mensaje: 'El formato del correo electrónico no es válido'
};
}
// Para correos institucionales (opcional)
if (this.validarDominioInstitucional) {
const dominios = ['acatlan.unam.mx', 'unam.mx', 'comunidad.unam.mx'];
const dominio = valor.split('@')[1];
if (!dominios.includes(dominio)) {
return {
valido: false,
mensaje: 'Debe ser un correo institucional (@acatlan.unam.mx, @unam.mx, @comunidad.unam.mx)'
};
}
}
return {
valido: true,
mensaje: 'Correo electrónico válido'
};
}
// Propiedad que permite configurar si se validan sólo correos institucionales
constructor(public validarDominioInstitucional: boolean = false) {
super();
}
}
// Clase para validar números telefónicos
export class ValidadorTelefono extends Validador {
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'El número telefónico es requerido'
};
}
// Eliminar espacios, guiones y paréntesis para la validación
const numeroLimpio = valor.replace(/[\s\-()]/g, '');
// Verificar que solo contenga dígitos
if (!/^\d+$/.test(numeroLimpio)) {
return {
valido: false,
mensaje: 'El número telefónico solo debe contener dígitos'
};
}
// Verificar longitud (para México, 10 dígitos)
if (numeroLimpio.length !== 10) {
return {
valido: false,
mensaje: 'El número telefónico debe tener 10 dígitos'
};
}
return {
valido: true,
mensaje: 'Número telefónico válido'
};
}
}
// Clase para validar nombres
export class ValidadorNombre extends Validador {
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'El nombre es requerido'
};
}
// Verificar longitud mínima
if (valor.trim().length < 2) {
return {
valido: false,
mensaje: 'El nombre debe tener al menos 2 caracteres'
};
}
// Verificar que solo contenga letras y espacios
if (!/^[a-zA-ZáéíóúÁÉÍÓÚñÑüÜ\\s]+$/.test(valor)) {
return {
valido: false,
mensaje: 'El nombre solo debe contener letras y espacios'
};
}
return {
valido: true,
mensaje: 'Nombre válido'
};
}
}
// Clase para validar números enteros
export class ValidadorEntero extends Validador {
constructor(
private min: number = Number.MIN_SAFE_INTEGER,
private max: number = Number.MAX_SAFE_INTEGER
) {
super();
}
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'El valor numérico es requerido'
};
}
// Verificar que sea un número entero
if (!/^-?\d+$/.test(valor)) {
return {
valido: false,
mensaje: 'El valor debe ser un número entero'
};
}
const numero = parseInt(valor, 10);
// Verificar rango
if (numero < this.min || numero > this.max) {
return {
valido: false,
mensaje: `El valor debe estar entre ${this.min} y ${this.max}`
};
}
return {
valido: true,
mensaje: 'Número entero válido'
};
}
}
// Clase para validar números decimales
export class ValidadorDecimal extends Validador {
constructor(
private min: number = Number.MIN_SAFE_INTEGER,
private max: number = Number.MAX_SAFE_INTEGER,
private decimales: number = 2
) {
super();
}
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'El valor numérico es requerido'
};
}
// Verificar que sea un número decimal válido
if (!/^-?\d+(\.\d+)?$/.test(valor)) {
return {
valido: false,
mensaje: 'El valor debe ser un número decimal válido'
};
}
const numero = parseFloat(valor);
// Verificar rango
if (numero < this.min || numero > this.max) {
return {
valido: false,
mensaje: `El valor debe estar entre ${this.min} y ${this.max}`
};
}
// Verificar precisión decimal
const partes = valor.split('.');
if (partes.length > 1 && partes[1].length > this.decimales) {
return {
valido: false,
mensaje: `El valor debe tener máximo ${this.decimales} decimales`
};
}
return {
valido: true,
mensaje: 'Número decimal válido'
};
}
}
// Clase para validar cuentas de alumno (formato específico UNAM - 9 dígitos)
export class ValidadorCuentaAlumno extends Validador {
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'La cuenta de alumno es requerida'
};
}
// Formato de cuenta UNAM: 9 dígitos para todos los alumnos
if (!/^\d{9}$/.test(valor)) {
return {
valido: false,
mensaje: 'La cuenta de alumno debe tener exactamente 9 dígitos numéricos'
};
}
return {
valido: true,
mensaje: 'Cuenta de alumno válida'
};
}
}
// Fábrica de validadores para crear el validador apropiado según el tipo
export class FabricaValidadores {
static crear(tipo: string): Validador | null {
switch (tipo?.toLowerCase()) {
case 'correo':
return new ValidadorCorreo();
case 'correo_institucional':
return new ValidadorCorreo(true);
case 'telefono':
return new ValidadorTelefono();
case 'nombre':
return new ValidadorNombre();
case 'entero':
return new ValidadorEntero();
case 'decimal':
return new ValidadorDecimal();
case 'cuenta_alumno':
return new ValidadorCuentaAlumno();
default:
return null;
}
}
}
// Función auxiliar para validar respuestas basada en el tipo de validación
export function validarRespuesta(respuesta: string, tipoValidacion: string): ResultadoValidacion {
const validador = FabricaValidadores.crear(tipoValidacion);
if (!validador) {
return {
valido: true, // Si no hay validador, consideramos válida la respuesta
mensaje: 'No se requiere validación específica'
};
}
return validador.validar(respuesta);
}