diff --git a/src/app/(admins)/administrador/crear-formulario/page.tsx b/src/app/(admins)/administrador/crear-formulario/page.tsx index 37a50a6..56ea72e 100644 --- a/src/app/(admins)/administrador/crear-formulario/page.tsx +++ b/src/app/(admins)/administrador/crear-formulario/page.tsx @@ -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) => { @@ -442,6 +453,47 @@ export default function AdminFormPage() { } /> + {pregunta.tipo === 'Abierta' && ( +
+
+ + actualizarPregunta( + paginaActual - 1, + pIdx, + 'limite', + parseInt(e.target.value, 10) + ) + } + placeholder="Máximo de caracteres permitidos" + /> +
+
+ (); - const id_cuestionario = params?.id_cuestionario; + const params = useParams<{ id_formulario: string }>(); + const id_formulario = Number(params?.id_formulario); - return
{id_cuestionario}
; + const [participantes, setParticipantes] = React.useState< + ParticipacionEvento[] + >([]); + const [loadingAsistencia, setLoadingAsistencia] = React.useState< + Record + >({}); + const [busquedaCorreo, setBusquedaCorreo] = React.useState(''); + + useEffect(() => { + if (!id_formulario) return; + const fetchParticipantes = async () => { + try { + const { data } = await axiosInstance.get( + `/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( + `/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[] = [ + { + 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 ? ( + + + Asistió + + ) : ( + + ); + }, + }, + ]; + + const participantesFiltrados = participantes.filter((p) => + p.participante.correo.toLowerCase().includes(busquedaCorreo.toLowerCase()) + ); + + return ( +
+

Participantes del formulario #{id_formulario}

+ + {participantes.length > 0 && ( + <> + setBusquedaCorreo(e.target.value)} + /> + +
+ row.id_participante} + /> + + + )} + + ); } diff --git a/src/app/(admins)/staff/layout.tsx b/src/app/(admins)/staff/layout.tsx new file mode 100644 index 0000000..2582403 --- /dev/null +++ b/src/app/(admins)/staff/layout.tsx @@ -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 ( +
+
+
+ + {children} +
+
+
+ ); +} diff --git a/src/app/(admins)/staff/lista-manual/page.tsx b/src/app/(admins)/staff/lista-manual/page.tsx new file mode 100644 index 0000000..8c6dea8 --- /dev/null +++ b/src/app/(admins)/staff/lista-manual/page.tsx @@ -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[] = [ + { + 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 ? ( + + + Asistió + + ) : ( + + ); + }, + }, + ]; + + const [eventos, setEventos] = React.useState([]); + const [participantes, setParticipantes] = React.useState< + ParticipacionEvento[] + >([]); + const [loadingAsistencia, setLoadingAsistencia] = React.useState< + Record + >({}); + 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( + `/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 ( +
+ setBusquedaCorreo(e.target.value)} + /> + +
+
row.id_participante} + /> + + + )} + + ); +} diff --git a/src/app/(admins)/staff/page.tsx b/src/app/(admins)/staff/page.tsx index 0dcccae..14128a3 100644 --- a/src/app/(admins)/staff/page.tsx +++ b/src/app/(admins)/staff/page.tsx @@ -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 ( -
-
-
-

Lectura de QRs

+
+

Lectura de QRs

- {enableScan ? ( + {enableScan ? ( + <> +
+ { + if (codes.length > 0) { + handleScan(codes[0].rawValue); + } + }} + onError={(err) => console.error('QR Error:', err)} + constraints={{ facingMode: 'environment' }} + /> +
+ + + ) : ( + <> +

La cámara está detenida.

+ + + )} + + {statusMessage &&

{statusMessage}

} + + { + setShowModal(false); + setEnableScan(true); + setScannedData(null); + }} + closeButton + className={{ + content: 'p-3', + body: 'text-center', + }} + > +
Datos escaneados
+ {scannedData ? ( <> -
- { - if (codes.length > 0) { - handleScan(codes[0].rawValue); - } - }} - onError={(err) => console.error('QR Error:', err)} - constraints={{ facingMode: 'environment' }} - /> -
- ) : ( - <> -

La cámara está detenida.

- - +

QR inválido

)} - - {statusMessage &&

{statusMessage}

} - - { - setShowModal(false); - setEnableScan(true); - setScannedData(null); - }} - closeButton - className={{ - content: 'p-3', - body: 'text-center', - }} - > -
Datos escaneados
- {scannedData ? ( - <> -

- ID Participante: {scannedData.id_participante} -

-

- ID Evento: {scannedData.id_evento} -

- - - ) : ( -

QR inválido

- )} -
-
-
-
+ + ); } diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index 906c8a1..6157155 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -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 && ( diff --git a/src/app/(public)/registro/[id_formulario]/page.tsx b/src/app/(public)/registro/[id_formulario]/page.tsx index fd69d0d..2592fea 100644 --- a/src/app/(public)/registro/[id_formulario]/page.tsx +++ b/src/app/(public)/registro/[id_formulario]/page.tsx @@ -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' + } /> ); } diff --git a/src/components/formulario-card.tsx b/src/components/formulario-card.tsx index f586d6b..b7df933 100644 --- a/src/components/formulario-card.tsx +++ b/src/components/formulario-card.tsx @@ -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) {
- +
{cuestionario.nombre_form}
@@ -48,6 +62,11 @@ export default function FormularioCard({ cuestionario, link }: Props) { )}

+ {button_message && ( + + )}
diff --git a/src/components/input.tsx b/src/components/input.tsx index 62520e3..b0dc389 100644 --- a/src/components/input.tsx +++ b/src/components/input.tsx @@ -1,3 +1,4 @@ +// @components/input.tsx import React, { useState } from 'react'; export interface InputProps diff --git a/src/components/table.tsx b/src/components/table.tsx index 68c5f31..89f3a25 100644 --- a/src/components/table.tsx +++ b/src/components/table.tsx @@ -1,3 +1,4 @@ +// @components/table.tsx import React, { useState, useMemo } from "react"; import Pagination from "./pagination"; diff --git a/src/data/plantillas.ts b/src/data/plantillas.ts index 8cf1805..1a0d771 100644 --- a/src/data/plantillas.ts +++ b/src/data/plantillas.ts @@ -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, }, ], }, diff --git a/src/styles/sass/_table.scss b/src/styles/sass/_table.scss index 29788e0..1e8e1c3 100644 --- a/src/styles/sass/_table.scss +++ b/src/styles/sass/_table.scss @@ -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 { diff --git a/src/types/crear-formulario.d.ts b/src/types/crear-formulario.d.ts index 4deda91..911c832 100644 --- a/src/types/crear-formulario.d.ts +++ b/src/types/crear-formulario.d.ts @@ -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 { diff --git a/src/types/participante-evento.d.ts b/src/types/participante-evento.d.ts new file mode 100644 index 0000000..27d2152 --- /dev/null +++ b/src/types/participante-evento.d.ts @@ -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; + } + \ No newline at end of file diff --git a/src/types/responder-formulario.ts b/src/types/responder-formulario.ts index f08968f..e189b72 100644 --- a/src/types/responder-formulario.ts +++ b/src/types/responder-formulario.ts @@ -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[]; } diff --git a/src/utils/validador.ts b/src/utils/validador.ts new file mode 100644 index 0000000..1ca4be1 --- /dev/null +++ b/src/utils/validador.ts @@ -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); +} \ No newline at end of file