auto complete alumnos

This commit is contained in:
jorgemike
2025-04-06 16:04:56 -06:00
parent 6c51e326dd
commit a0e86b3873
7 changed files with 468 additions and 323 deletions
@@ -1,19 +1,23 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import React from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
const params = useParams<{ id_formulario: string }>();
return (
<div>
<nav className='d-flex gap-2 my-4'>
<Link href={'/administrador/'} className='text-decoration-none'>
<Link href={`/administrador/formulario/${params.id_formulario}`} className='text-decoration-none'>
<div className='box'>Registros</div>
</Link>
<Link
{/* <Link
href={'/administrador/crear-formulario'}
className='text-decoration-none'
>
<div className='box'>Editar formulario</div>
</Link>
</Link> */}
</nav>
{children}
</div>
+24 -13
View File
@@ -36,6 +36,17 @@ export default async function Page() {
return <div>Error al cargar los eventos</div>;
}
function slugify(text: string): string {
return text
.toString()
.normalize('NFD') // Quita tildes
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-') // Reemplaza cualquier cosa que no sea alfanumérica por guiones
.replace(/^-+|-+$/g, ''); // Quita guiones al inicio o final
}
return (
<>
<div className="mx-auto my-5">
@@ -44,19 +55,19 @@ export default async function Page() {
<div className="cards-1 mt-5">
<div className="container">
<div className="row">
{eventos.map((cuestionario) => (
<FormularioCard
key={cuestionario.id_cuestionario}
cuestionario={cuestionario}
link={`/registro/${cuestionario.id_cuestionario}`}
button_message='Registrarse'
/>
))}
{eventos.length === 0 && (
<p className="text-center text-muted">
No hay formularios disponibles.
</p>
)}
{eventos.map((cuestionario) => {
const slug = slugify(cuestionario.nombre_form); // Asumiendo que el nombre del evento está en `nombre`
const url = `/registro/${slug}-${cuestionario.id_cuestionario}`;
return (
<FormularioCard
key={cuestionario.id_cuestionario}
cuestionario={cuestionario}
link={url}
button_message="Registrarse"
/>
);
})}
</div>
</div>
</div>
@@ -1,306 +0,0 @@
'use client';
import Button from '@/components/button';
import CheckboxOptionGroup from '@/components/checkbox-option-group';
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';
import toast from 'react-hot-toast';
export default function Page() {
const router = useRouter();
const params = useParams<{ id_formulario: string }>();
const id_formulario = params?.id_formulario;
const [cuestionario, setCuestionario] = useState<CuestionarioResponse | null>(
null
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [respuestas, setRespuestas] = useState<
Record<number, string | number | Array<string | number>>
>({});
const [isSubmitting, setIsSubmitting] = useState(false); // New state for submission
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const { data } = await axiosInstance.get<CuestionarioResponse>(
`/cuestionario/${id_formulario}/formulario`
);
if (data) {
console.log(data);
setCuestionario(data);
} else {
setError('Error fetching data');
}
} catch (error) {
console.error('Error fetching data:', error);
setError('Error de conexión');
} finally {
setLoading(false);
}
};
if (id_formulario) fetchData();
}, [id_formulario]);
const handleInputChange = (id: number, value: string) => {
setRespuestas((prev) => ({ ...prev, [id]: value }));
};
const handleRadioChange = (id: number, opcion: { value: number }) => {
setRespuestas((prev) => ({ ...prev, [id]: opcion.value }));
};
const handleCheckboxChange = (id: number, opcion: { value: number }) => {
setRespuestas((prev) => {
const actuales: number[] = Array.isArray(prev[id])
? (prev[id] as number[])
: [];
const existe = actuales.includes(opcion.value);
const nuevos = existe
? actuales.filter((v: number) => v !== opcion.value)
: [...actuales, opcion.value];
return { ...prev, [id]: nuevos };
});
};
const formatearRespuestas = async () => {
if (!validarRespuestasObligatorias()) return;
const respuestasArray = Object.entries(respuestas).flatMap(
([id, valor]) => {
if (Array.isArray(valor)) {
return valor.map((v) => ({
id_pregunta: Number(id),
valor: v,
}));
}
return [{ id_pregunta: Number(id), valor }];
}
);
const correoEntry = respuestasArray.find(
(r) =>
typeof r.valor === 'string' &&
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r.valor)
);
const resultado = {
id_formulario: Number(id_formulario),
correo: correoEntry?.valor || 'correo@no-encontrado.com',
respuestas: respuestasArray,
fecha_envio: new Date().toISOString(),
};
setIsSubmitting(true); // Set submitting state to true
toast
.promise(
axiosInstance.post('/cuestionario-respondido/submit', resultado),
{
loading: 'Enviando formulario...',
success: 'Formulario enviado con éxito!',
error: 'Error al enviar el formulario.',
}
)
.then(() => {
router.push('/');
})
.catch((error) => {
console.error('Error al enviar el formulario:', error);
})
.finally(() => {
setIsSubmitting(false); // Reset submitting state
});
};
const validarRespuestasObligatorias = (): boolean => {
const faltantes: number[] = [];
const errores: { id: number; mensaje: string }[] = [];
cuestionario?.cuestionario.secciones.forEach((seccion) => {
seccion.preguntas.forEach(({ pregunta }) => {
const id = pregunta.id_pregunta;
const valor = respuestas[id];
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
const validacion = pregunta.validacion;
const respondida =
(tipo === 'Abierta' &&
typeof valor === 'string' &&
valor.trim() !== '') ||
(tipo === 'Cerrada' && valor !== undefined) ||
(tipo === 'Multiple' && Array.isArray(valor) && valor.length > 0);
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) {
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;
}
return true;
};
if (loading) return <p>Cargando...</p>;
if (error) return <p className="text-danger">{error}</p>;
if (!cuestionario) return <p>No hay datos</p>;
return (
<div className="container py-4">
<Button icon="arrow-left" onClick={() => router.back()}>
Volver
</Button>
<div className="text-center mb-4">
<Image
src={'/banner1.png'}
width={1000}
height={300}
alt="Ejemplo de banner"
className="rounded-4 shadow-sm img-fluid"
style={{
objectFit: 'cover',
objectPosition: 'center',
height: 300,
}}
/>
</div>
<div className="px-lg-5 mx-lg-5">
<h2>{cuestionario.cuestionario.nombre_form}</h2>
<p>{cuestionario.cuestionario.descripcion}</p>
{cuestionario.cuestionario.secciones.map((seccion, index) => (
<section key={index} className="mb-4">
<h4>{seccion.seccion.titulo}</h4>
<p>{seccion.seccion.descripcion}</p>
{seccion.preguntas.map((p) => {
const pregunta = p.pregunta;
const id = pregunta.id_pregunta;
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
if (tipo === 'Abierta') {
return (
<Input
key={id}
label={pregunta.pregunta}
name={`pregunta_${id}`}
value={
Array.isArray(respuestas[id])
? 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'
}
/>
);
}
if (tipo === 'Cerrada' || tipo === 'Multiple') {
const opciones = (pregunta.opciones ?? []).map((o) => ({
label: o.opcion.opcion,
value: o.opcion.id_opcion,
}));
if (tipo === 'Multiple') {
return (
<div key={id} className="mb-3">
<p>{pregunta.pregunta}</p>
<CheckboxOptionGroup
name={`pregunta_${id}`}
options={opciones}
selectedValues={
Array.isArray(respuestas[id]) ? respuestas[id] : []
}
onChange={(opcion) => {
if (typeof opcion.value === 'number') {
handleCheckboxChange(id, { value: opcion.value });
} else {
console.error('Invalid value type:', opcion.value);
}
}}
/>
</div>
);
}
return (
<div key={id} className="mb-3">
<p>{pregunta.pregunta}</p>
<RadioOptionGroup
name={`pregunta_${id}`}
options={opciones}
selectedValue={respuestas[id]}
onChange={(opcion) => {
if (typeof opcion.value === 'number') {
handleRadioChange(id, { value: opcion.value });
} else {
console.error('Invalid value type:', opcion.value);
}
}}
disabled={false}
/>
</div>
);
}
return null;
})}
</section>
))}
<button
type="button"
className="btn btn-primary"
onClick={formatearRespuestas}
disabled={isSubmitting} // Disable button while submitting
>
{isSubmitting ? 'Enviando...' : 'Enviar'}
</button>
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
'use client';
import Button from '@/components/button';
import Formulario from '@/containers/formulario';
import { CuestionarioResponse } from '@/types/responder-formulario';
import axiosInstance from '@/utils/api-config';
import Image from 'next/image';
import { useParams, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
export default function Page() {
const router = useRouter();
const params = useParams<{ slug: string }>();
const slug = params?.slug;
const id_formulario = slug?.split('-').pop(); // Toma el último fragmento como ID
const [cuestionario, setCuestionario] = useState<CuestionarioResponse | null>(
null
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const { data } = await axiosInstance.get<CuestionarioResponse>(
`/cuestionario/${id_formulario}/formulario`
);
if (data) {
console.log(data);
setCuestionario(data);
} else {
setError('Error fetching data');
}
} catch (error) {
console.error('Error fetching data:', error);
setError('Error de conexión');
} finally {
setLoading(false);
}
};
if (id_formulario) fetchData();
}, [id_formulario]);
if (loading) return <p>Cargando...</p>;
if (error) return <p className="text-danger">{error}</p>;
if (!cuestionario) return <p>No hay datos</p>;
return (
<div className="container py-4">
<Button icon="arrow-left" onClick={() => router.back()}>
Volver
</Button>
<div className="text-center mb-4">
<Image
src={'/banner1.png'}
width={1000}
height={300}
alt="Ejemplo de banner"
className="rounded-4 shadow-sm img-fluid"
style={{
objectFit: 'cover',
objectPosition: 'center',
height: 300,
}}
/>
</div>
<div className="px-lg-5 mx-lg-5">
<h2>{cuestionario.cuestionario.nombre_form}</h2>
<p>{cuestionario.cuestionario.descripcion}</p>
<Formulario
id_formulario={cuestionario.cuestionario.id_cuestionario}
secciones={cuestionario.cuestionario.secciones}
/>
</div>
</div>
);
}
+299
View File
@@ -0,0 +1,299 @@
'use client';
import React, { useEffect, useState } from 'react';
import { CuestionarioSeccion } from '@/types/responder-formulario';
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
import Input from '@/components/input';
import Button from '@/components/button';
import { validarRespuesta } from '@/utils/validador';
import toast from 'react-hot-toast';
import axiosInstance from '@/utils/api-config';
import { useRouter } from 'next/navigation';
export default function Formulario({
id_formulario,
secciones,
}: {
id_formulario: number;
secciones: CuestionarioSeccion[];
}) {
const router = useRouter();
const [esDeFES, setEsDeFES] = useState<boolean | null>(null);
const [cuenta, setCuenta] = useState('');
const [datosAuto, setDatosAuto] = useState<null | {
nombre: string;
apellidos: string;
correo: string;
}>(null);
const [respuestas, setRespuestas] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false); // New state for submission
const actualizarRespuesta = (idPregunta: string, valor: string) => {
setRespuestas((prev) => ({ ...prev, [idPregunta]: valor }));
};
useEffect(() => {
if (esDeFES && cuenta.length === 9) {
fetch(`/datos/alumnos/${cuenta}`)
.then((res) => res.json())
.then((data) => setDatosAuto(data))
.catch((err) => {
console.error('Error al obtener datos del alumno:', err);
setDatosAuto(null);
});
}
}, [cuenta, esDeFES]);
// Encuentra preguntas por texto o validación
const todasPreguntas = secciones.flatMap((s) =>
s.preguntas.map((p) => p.pregunta)
);
const preguntaFES = todasPreguntas.find(
(p) => p.pregunta === '¿Eres parte de la comunidad de la FES Acatlán?'
);
const preguntaCuenta = todasPreguntas.find(
(p) => p.validacion === 'cuenta_alumno'
);
const preguntasRestantes = todasPreguntas.filter(
(p) =>
![preguntaFES?.id_pregunta, preguntaCuenta?.id_pregunta].includes(
p.id_pregunta
)
);
const respuestaFES = respuestas[`pregunta_${preguntaFES?.id_pregunta}`];
const formatearRespuestas = async () => {
if (!validarRespuestasObligatorias()) return;
const respuestasArray = Object.entries(respuestas).flatMap(
([key, valor]) => {
const idNumerico = Number(key.replace('pregunta_', ''));
const pregunta = secciones
.flatMap((s) => s.preguntas)
.find((p) => p.pregunta.id_pregunta === idNumerico);
const tipo = pregunta?.pregunta.tipo_pregunta.tipo_pregunta;
// Si es múltiple, convertir cada valor individual si corresponde
if (Array.isArray(valor)) {
return valor.map((v) => ({
id_pregunta: idNumerico,
valor: tipo === 'Cerrada' || tipo === 'Multiple' ? Number(v) : v,
}));
}
return [
{
id_pregunta: idNumerico,
valor: tipo === 'Cerrada' ? Number(valor) : valor,
},
];
}
);
const correoEntry = respuestasArray.find(
(r) =>
typeof r.valor === 'string' &&
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r.valor)
);
const resultado = {
id_formulario: Number(id_formulario),
correo: correoEntry?.valor || 'correo@no-encontrado.com',
respuestas: respuestasArray,
fecha_envio: new Date().toISOString(),
};
console.log('Resultado a enviar:', resultado);
setIsSubmitting(true);
toast
.promise(
axiosInstance.post('/cuestionario-respondido/submit', resultado),
{
loading: 'Enviando formulario...',
success: 'Formulario enviado con éxito!',
error: 'Error al enviar el formulario.',
}
)
.then(() => router.push('/'))
.finally(() => setIsSubmitting(false));
};
const validarRespuestasObligatorias = (): boolean => {
const faltantes: number[] = [];
const errores: { id: number; mensaje: string }[] = [];
console.log('Validando respuestas:', respuestas);
secciones.forEach((seccion) => {
seccion.preguntas.forEach(({ pregunta }) => {
const id = pregunta.id_pregunta;
const valor = respuestas[`pregunta_${id}`];
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
const validacion = pregunta.validacion;
const respondida =
(tipo === 'Abierta' &&
typeof valor === 'string' &&
valor.trim() !== '') ||
(tipo === 'Cerrada' && valor !== undefined) ||
(tipo === 'Multiple' && Array.isArray(valor) && valor.length > 0);
if (!respondida && pregunta.obligatoria) {
faltantes.push(id);
}
if (tipo === 'Abierta' && validacion && typeof valor === 'string') {
const resultado = validarRespuesta(valor, validacion);
if (!resultado.valido) {
errores.push({ id, mensaje: resultado.mensaje });
}
}
});
});
if (faltantes.length > 0) {
console.log('Faltan preguntas obligatorias:', faltantes);
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;
}
return true;
};
return (
<form className="container py-4">
{/* Pregunta inicial */}
{preguntaFES && (
<div className="mb-4">
<label className="form-label">{preguntaFES.pregunta}</label>
<RadioOptionGroup
name="comunidad_fes"
options={preguntaFES.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}))}
selectedValue={respuestaFES ? Number(respuestaFES) : undefined}
onChange={(opt) => {
const idSeleccionado = opt.value;
const opcionSeleccionada = preguntaFES.opciones.find(
(o) => o.id_opcion === idSeleccionado
);
const valorTexto = opcionSeleccionada?.opcion.opcion ?? '';
setEsDeFES(valorTexto === 'Si'); // Se actualiza esDeFES con el texto
setCuenta('');
setDatosAuto(null);
actualizarRespuesta(
`pregunta_${preguntaFES.id_pregunta}`,
String(idSeleccionado)
);
}}
/>
</div>
)}
{/* Si dijo que sí, pedir número de cuenta */}
{esDeFES && preguntaCuenta && (
<Input
key={preguntaCuenta.id_pregunta}
label={preguntaCuenta.pregunta}
name={`pregunta_${preguntaCuenta.id_pregunta}`}
value={cuenta}
onChange={(e) => {
const value = e.target.value;
setCuenta(value);
actualizarRespuesta(
`pregunta_${preguntaCuenta.id_pregunta}`,
value
); // ✅ Guarda la respuesta
}}
/>
)}
{/* Si ya tiene datos o dijo que no es de la FES, mostrar el resto */}
{(esDeFES === false || (esDeFES && datosAuto)) &&
preguntasRestantes.map((p) => {
if (p.tipo_pregunta.tipo_pregunta === 'Abierta') {
let defaultValue = '';
let disabled = false;
if (esDeFES && datosAuto) {
if (p.validacion === 'nombre') {
defaultValue = p.pregunta.toLowerCase().includes('apellido')
? datosAuto.apellidos
: datosAuto.nombre;
disabled = true;
}
if (p.validacion === 'correo') {
defaultValue = datosAuto.correo;
disabled = true;
}
}
return (
<Input
key={p.id_pregunta}
label={p.pregunta}
name={`pregunta_${p.id_pregunta}`}
defaultValue={defaultValue}
disabled={disabled}
onChange={(e) =>
actualizarRespuesta(
`pregunta_${p.id_pregunta}`,
e.target.value
)
}
/>
);
}
if (p.tipo_pregunta.tipo_pregunta === 'Cerrada') {
const opciones: RadioOption<number>[] = p.opciones.map(
(op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
})
);
const respuestaActual = respuestas[`pregunta_${p.id_pregunta}`];
return (
<div key={p.id_pregunta}>
<label className="form-label">{p.pregunta}</label>
<RadioOptionGroup
name={`pregunta_${p.id_pregunta}`}
options={opciones}
selectedValue={
respuestaActual ? Number(respuestaActual) : undefined
} // ✅ Aquí se fija la opción
onChange={(opt) =>
actualizarRespuesta(
`pregunta_${p.id_pregunta}`,
String(opt.value)
)
}
/>
</div>
);
}
return null;
})}
{/* Botón de enviar */}
<Button onClick={formatearRespuestas} disabled={isSubmitting}>
{isSubmitting ? 'Enviando...' : 'Enviar'}
</Button>
</form>
);
}
+1 -1
View File
@@ -42,7 +42,7 @@ export const plantillasDisponibles: Plantilla[] = [
{
titulo: 'Numero de cuenta',
tipo: 'Abierta',
obligatoria: true,
obligatoria: false,
limite: 250,
validacion: 'cuenta_alumno',
},
+57
View File
@@ -0,0 +1,57 @@
import { CuestionarioResponse } from "@/types/responder-formulario";
import toast from "react-hot-toast";
import { validarRespuesta } from "./validador";
export const validarRespuestasObligatorias = (
cuestionario: CuestionarioResponse | null,
respuestas: Record<number, string | number | Array<string | number>>
): boolean => {
const faltantes: number[] = [];
const errores: { id: number; mensaje: string }[] = [];
cuestionario?.cuestionario.secciones.forEach((seccion) => {
seccion.preguntas.forEach(({ pregunta }) => {
const id = pregunta.id_pregunta;
const valor = respuestas[id];
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
const validacion = pregunta.validacion;
const respondida =
(tipo === 'Abierta' &&
typeof valor === 'string' &&
valor.trim() !== '') ||
(tipo === 'Cerrada' && valor !== undefined) ||
(tipo === 'Multiple' && Array.isArray(valor) && valor.length > 0);
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) {
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;
}
return true;
};