coneccion muchos endpoint y validacions
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
// @components/input.tsx
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export interface InputProps
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @components/table.tsx
|
||||
import React, { useState, useMemo } from "react";
|
||||
import Pagination from "./pagination";
|
||||
|
||||
|
||||
+24
-6
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+4
-2
@@ -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 {
|
||||
|
||||
Vendored
+15
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user