Merge pull request 'develop' (#32) from develop into master

Reviewed-on: #32
This commit was merged in pull request #32.
This commit is contained in:
2026-03-10 15:42:15 -06:00
19 changed files with 1330 additions and 508 deletions
+3 -3
View File
@@ -6,12 +6,12 @@ const nextConfig: NextConfig = {
{
protocol: 'http',
hostname: 'localhost',
port: '3342', //4200
port: '4200', //4200
},
{
protocol: 'https',
hostname: 'venus.acatlan.unam.mx'
}
hostname: 'venus.acatlan.unam.mx',
},
],
},
};
+90
View File
@@ -7403,6 +7403,96 @@
"peerDependencies": {
"@types/emscripten": ">=1.39.6"
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz",
"integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz",
"integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz",
"integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz",
"integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz",
"integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz",
"integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,224 @@
'use client';
import React, { useRef, useState } from 'react';
import axiosInstance from '@/utils/api-config';
import { Button, Form, Alert } from 'react-bootstrap';
import { FaUpload, FaDownload } from 'react-icons/fa';
type TipoCarga = 'alumnos' | 'trabajadores';
interface ResultadoCarga {
insertados: number;
omitidos: number;
}
interface SeccionState {
archivo: File | null;
loading: boolean;
error: string | null;
success: string | null;
resultado: ResultadoCarga | null;
}
const initialState: SeccionState = {
archivo: null,
loading: false,
error: null,
success: null,
resultado: null,
};
export default function Page() {
const [alumnos, setAlumnos] = useState<SeccionState>(initialState);
const [trabajadores, setTrabajadores] = useState<SeccionState>(initialState);
const alumnosRef = useRef<HTMLInputElement>(null);
const trabajadoresRef = useRef<HTMLInputElement>(null);
const getState = (tipo: TipoCarga) =>
tipo === 'alumnos' ? alumnos : trabajadores;
const setState = (tipo: TipoCarga) =>
tipo === 'alumnos' ? setAlumnos : setTrabajadores;
const validarExt = (file: File | null): boolean => {
if (!file) return false;
return /\.xlsx$/i.test(file.name);
};
const handleArchivoChange = (tipo: TipoCarga, file: File | null) => {
if (file && !validarExt(file)) {
setState(tipo)((prev) => ({
...prev,
archivo: null,
error: 'Selecciona un archivo .xlsx válido',
success: null,
}));
return;
}
setState(tipo)((prev) => ({
...prev,
archivo: file,
error: null,
success: null,
}));
};
const handleCargaMasiva = async (tipo: TipoCarga) => {
const estado = getState(tipo);
if (!estado.archivo) {
setState(tipo)((prev) => ({
...prev,
error: 'Selecciona un archivo xlsx',
}));
return;
}
const formData = new FormData();
formData.append('file', estado.archivo);
setState(tipo)((prev) => ({
...prev,
loading: true,
error: null,
success: null,
}));
try {
const res = await axiosInstance.post(`/${tipo}/cargar`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setState(tipo)((prev) => ({
...prev,
archivo: null,
success: `${tipo === 'alumnos' ? 'Alumnos' : 'Trabajadores'} cargados exitosamente`,
resultado: res.data ?? null,
}));
const ref = tipo === 'alumnos' ? alumnosRef : trabajadoresRef;
if (ref.current) ref.current.value = '';
} catch (error) {
const err = error as { response?: { data?: { message?: string } } };
setState(tipo)((prev) => ({
...prev,
error: err?.response?.data?.message || 'Error al cargar el archivo',
}));
} finally {
setState(tipo)((prev) => ({ ...prev, loading: false }));
}
};
const handleDescargarPlantilla = (tipo: TipoCarga) => {
const nombre = tipo === 'alumnos' ? 'carga-alumnos' : 'carga-trabajadores';
const link = document.createElement('a');
link.href = `/plantillas/${nombre}.xlsx`;
link.setAttribute('download', `${nombre}.xlsx`);
document.body.appendChild(link);
link.click();
link.remove();
};
const SeccionCarga = ({
tipo,
label,
}: {
tipo: TipoCarga;
label: string;
}) => {
const estado = getState(tipo);
const inputRef = tipo === 'alumnos' ? alumnosRef : trabajadoresRef;
return (
<div className="p-4 border rounded bg-light mb-4">
<h2 className="mb-3">{label}</h2>
{estado.error && (
<Alert
variant="danger"
onClose={() => setState(tipo)((p) => ({ ...p, error: null }))}
dismissible
>
{estado.error}
</Alert>
)}
{estado.success && (
<Alert
variant="success"
onClose={() =>
setState(tipo)((p) => ({ ...p, success: null, resultado: null }))
}
dismissible
>
<p className="mb-1">{estado.success}</p>
{estado.resultado && (
<ul className="mb-0">
<li>
Insertados: <strong>{estado.resultado.insertados}</strong>
</li>
<li>
Omitidos: <strong>{estado.resultado.omitidos}</strong>
</li>
</ul>
)}
</Alert>
)}
<Form.Group className="mb-3">
<Form.Label>Archivo Excel (.xlsx)</Form.Label>
<div
className="border p-4 text-center rounded"
style={{ cursor: 'pointer' }}
onClick={() => inputRef.current?.click()}
>
<FaUpload size={36} className="mb-2 text-secondary" />
<p className="mb-0">
{estado.archivo?.name ||
'Arrastra aquí tu archivo o haz click para buscar'}
</p>
</div>
<input
ref={inputRef}
type="file"
accept=".xlsx"
style={{ display: 'none' }}
onChange={(e) =>
handleArchivoChange(tipo, e.target.files?.[0] ?? null)
}
/>
</Form.Group>
<div className="d-flex gap-2 flex-wrap">
<Button
variant="primary"
disabled={estado.loading || !estado.archivo}
onClick={() => handleCargaMasiva(tipo)}
>
{estado.loading ? 'Cargando...' : 'Cargar archivo'}
</Button>
<Button
variant="outline-success"
onClick={() => handleDescargarPlantilla(tipo)}
>
<FaDownload className="me-2" />
Descargar plantilla
</Button>
</div>
</div>
);
};
return (
<div className="container-fluid my-4">
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h1 className="mb-1">Carga masiva de usuarios</h1>
<p className="mb-0 text-muted">
Sube un archivo Excel para registrar alumnos o trabajadores en el
sistema.
</p>
</div>
</div>
</div>
<SeccionCarga tipo="alumnos" label="Carga de alumnos" />
<SeccionCarga tipo="trabajadores" label="Carga de trabajadores" />
</div>
);
}
@@ -1,7 +1,6 @@
'use client';
import { plantillasDisponibles } from '@/data/plantillas';
import { FormularioCreacion } from '@/types/create-formulario';
import Image from 'next/image';
import React, { useState } from 'react';
import CreateFormulario from '@/containers/create-formulario';
import FormularioCardPreview from '@/components/formulario/formulario-card-preview';
@@ -193,21 +192,18 @@ export default function Page() {
</span>
</p>
<div className="d-flex gap-4 flex-wrap">
<div className="d-flex gap-2 flex-wrap">
{plantillasDisponibles.map((plantilla) => (
<div
className="mb-3 cursor-pointer"
className="cursor-pointer"
key={plantilla.id}
onClick={() => handleSeleccionarPlantilla(plantilla.id)}
>
<div className="text-center">
<Image
src={plantilla.imagen}
width={200}
height={200}
alt={`Plantilla de formulario ${plantilla.nombre}`}
className="img-fluid rounded shadow-sm"
/>
<div
className="d-flex align-items-center justify-content-center rounded shadow-sm border text-center fw-semibold bg-azul text-white"
style={{ padding: '1rem' }}
>
{plantilla.nombre}
</div>
</div>
))}
@@ -27,6 +27,40 @@ export default function Page() {
{}
);
type TipoCarga = 'alumnos' | 'trabajadores';
const [tipoCarga, setTipoCarga] = useState<TipoCarga>('alumnos');
const [archivoCarga, setArchivoCarga] = useState<File | null>(null);
const [loadingCarga, setLoadingCarga] = useState(false);
const handleCargaMasiva = async () => {
if (!archivoCarga) {
toast.error('Selecciona un archivo xlsx');
return;
}
const formData = new FormData();
formData.append('file', archivoCarga);
formData.append('id_evento', params.id_evento);
setLoadingCarga(true);
try {
await axiosInstance.post(
`/evento/${params.id_evento}/usuarios`,
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
}
);
toast.success(
`${tipoCarga === 'alumnos' ? 'Alumnos' : 'Trabajadores'} cargados exitosamente`
);
setArchivoCarga(null);
} catch (error) {
const err = error as { response?: { data?: { message?: string } } };
toast.error(err?.response?.data?.message || 'Error al cargar el archivo');
} finally {
setLoadingCarga(false);
}
};
const handleChange = (
field: keyof CreateEventoType,
value: string | Date
@@ -88,8 +122,61 @@ export default function Page() {
handleOnChange={handleEventoActualizado}
/>
{/* Carga masiva de alumnos o trabajadores */}
{tipo_usuario !== 'staff' && (
<div className="p-4 border rounded bg-light mb-4">
<h2 className="mb-1">Carga masiva de participantes</h2>
<p className="text-muted mb-3">
Sube un archivo <strong>.xlsx</strong> para registrar los
participantes que podrán inscribirse a este evento. Solo los
usuarios incluidos en la lista tendrán acceso al registro.
</p>
<div className="d-flex gap-3 align-items-end flex-wrap">
<div>
<label className="form-label mb-1">Tipo de participante</label>
<div className="d-flex gap-2">
<button
type="button"
className={`btn ${tipoCarga === 'alumnos' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setTipoCarga('alumnos')}
>
<i className="bi bi-mortarboard me-2"></i>Alumnos
</button>
<button
type="button"
className={`btn ${tipoCarga === 'trabajadores' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setTipoCarga('trabajadores')}
>
<i className="bi bi-person-badge me-2"></i>Trabajadores
</button>
</div>
</div>
<div className="flex-grow-1">
<label className="form-label mb-1">Archivo xlsx</label>
<input
type="file"
accept=".xlsx"
className="form-control"
onChange={(e) => setArchivoCarga(e.target.files?.[0] ?? null)}
/>
</div>
<Button
icon="upload"
variant="success"
onClick={handleCargaMasiva}
disabled={loadingCarga || !archivoCarga}
>
{loadingCarga ? 'Cargando...' : 'Cargar'}
</Button>
</div>
</div>
)}
{/* Validacion del tipo de usuario */}
{ tipo_usuario !== "staff" && (
{tipo_usuario !== 'staff' && (
<div className="p-4 border rounded bg-light">
<div className="d-flex justify-content-between align-items-center">
<div>
@@ -103,7 +190,9 @@ export default function Page() {
icon="plus"
variant="primary"
onClick={() =>
router.push(`/user/evento/${params.id_evento}/formularios/crear`)
router.push(
`/user/evento/${params.id_evento}/formularios/crear`
)
}
>
Crear nuevo formulario
+4
View File
@@ -29,6 +29,10 @@ const Navbar: React.FC = () => {
href: '/user/eventos/carga_masiva_banner',
label: 'Carga Masiva Imagenes',
},
{
href: '/user/carga_masiva_usuarios',
label: 'Carga Masiva Usuarios',
},
],
},
{ href: '/user/usuarios', label: 'Usuarios' },
+7
View File
@@ -214,6 +214,13 @@ export default function FormularioEditor({
</div>
)}
<SimpleInput
label="Mensaje de correo"
value={formulario.mensaje_correo || ''}
onChange={(e) => actualizarCampo('mensaje_correo', e.target.value)}
placeholder="Mensaje que se enviará al correo del participante al registrarse"
/>
<div className="row">
<div className="col-md-6">
<Select
+9
View File
@@ -55,6 +55,7 @@ export default function EditFormulario({
fecha_inicio: cuestionario.fecha_inicio,
fecha_fin: cuestionario.fecha_fin,
cupo_maximo: Number(cuestionario.cupo_maximo) || null,
mensaje_correo: cuestionario.mensaje_correo || null,
}
);
@@ -147,6 +148,14 @@ export default function EditFormulario({
}
/>
<SimpleInput
label="Mensaje de correo"
value={cuestionario.mensaje_correo ?? ''}
onChange={(e) =>
handleChange('mensaje_correo', e.target.value)
}
/>
<SimpleInput
label="Cupo máximo (Si no se requiere, dejar en blanco)"
type="number"
@@ -0,0 +1,132 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { SubmitResponse } from '@/types/submit';
interface Props {
response: SubmitResponse;
qrImageUrl: string | null;
nombreEvento?: string;
nombreFormulario?: string;
}
export default function FormularioExito({
response,
qrImageUrl,
nombreEvento,
nombreFormulario,
}: Props) {
return (
<div className="container my-5">
<div className="row justify-content-center">
<div className="col-12 col-lg-10 col-xl-8">
<div className="card shadow-sm">
<div className="card-body">
<div className="row g-4">
<div className="col-md-6">
<div className="h-100 d-flex flex-column justify-content-center">
<div className="text-center mb-4">
<div className="mb-3">
<div
className="d-inline-flex align-items-center justify-content-center bg-success rounded-circle"
style={{ width: '60px', height: '60px' }}
>
<i
className="bi bi-check-lg text-white"
style={{ fontSize: '2rem' }}
></i>
</div>
</div>
<h2 className="fw-bold text-success mb-2">¡Felicidades!</h2>
<p className="text-muted mb-0">
Has completado exitosamente el formulario
</p>
</div>
{nombreEvento && (
<div className="mb-3">
<div className="d-flex align-items-center mb-2">
<i
className="bi bi-calendar-event text-primary me-3"
style={{ fontSize: '1.3rem' }}
></i>
<h6 className="mb-0 text-primary">Evento</h6>
</div>
<p className="mb-0 fs-6 fw-semibold ms-5">{nombreEvento}</p>
</div>
)}
{nombreFormulario && (
<div className="mb-3">
<div className="d-flex align-items-center mb-2">
<i
className="bi bi-file-text text-info me-3"
style={{ fontSize: '1.3rem' }}
></i>
<h6 className="mb-0 text-info">Formulario</h6>
</div>
<p className="mb-0 fs-6 fw-semibold ms-5">{nombreFormulario}</p>
</div>
)}
{response.message && (
<div className="mb-3">
<div className="alert alert-info" role="alert">
<div className="d-flex align-items-start">
<i className="bi bi-info-circle me-2 mt-1"></i>
<div>
<strong>Mensaje:</strong>
<p className="mb-0 mt-1">{response.message}</p>
</div>
</div>
</div>
</div>
)}
</div>
</div>
<div className="col-md-6">
<div className="h-100 d-flex flex-column justify-content-center align-items-center">
{qrImageUrl ? (
<div className="text-center">
<h5 className="mb-3 text-secondary">
<i className="bi bi-qr-code me-2"></i>
Código QR
</h5>
<div className="p-3 bg-white rounded shadow-sm">
<Image
src={qrImageUrl}
alt="Código QR"
width={250}
height={250}
className="rounded"
style={{ maxWidth: '100%', height: 'auto' }}
unoptimized
/>
</div>
</div>
) : (
<div className="text-center text-muted">
<i
className="bi bi-qr-code-scan"
style={{ fontSize: '4rem' }}
></i>
<p className="mt-3 mb-0">No hay código QR disponible</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
<div className="text-center mt-4">
<Link href="/" className="btn btn-primary btn-lg me-3">
Ir al inicio
</Link>
</div>
</div>
</div>
</div>
);
}
+138 -362
View File
@@ -1,13 +1,10 @@
import React, { useState, useEffect } from 'react';
import { useGetApi } from '@/hooks/use-get-api';
import { GetCuestionario } from '@/types/evento';
import React from 'react';
import { SubmitResponse } from '@/types/submit';
import SimpleInput from '@/components/input';
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
import Button from '@/components/button';
import axiosInstance from '@/utils/api-config';
import toast from 'react-hot-toast';
import { getAxiosError } from '@/utils/errors-utils';
import { useFormulario } from '@/context/formulario';
export type UsuarioDataResponse = {
cuenta: string | null;
@@ -18,19 +15,7 @@ export type UsuarioDataResponse = {
rfc?: string | null;
};
type UsuarioData = {
nombre: string;
apellidos: string;
correo: string;
genero: 'M' | 'F';
carrera: string;
rfc?: string;
};
type RespuestaFormulario = Record<string, string | number>;
export default function FormularioRegistro({
id_cuestionario,
handleSubmitFormulario,
}: {
evento: string;
@@ -39,270 +24,61 @@ export default function FormularioRegistro({
id_cuestionario: number;
handleSubmitFormulario: (data: SubmitResponse) => void;
}) {
// Estados principales
const [respuestas, setRespuestas] = useState<RespuestaFormulario>({});
const [cuentaInfo, setCuentaInfo] = useState<UsuarioData | null>(null);
const [busquedaCompletada, setBusquedaCompletada] = useState<boolean>(false);
const [cuentaBuscada, setCuentaBuscada] = useState<string>('');
const [isSending, setIsSending] = useState(false);
// Carga del cuestionario
const { data, error } = useGetApi<GetCuestionario>(
`/cuestionario/${id_cuestionario}/formulario`
);
const {
state,
preguntaComunidad,
preguntaCuenta,
esComunidadSi,
esExclusivo,
mostrarFormularioCompleto,
setRespuesta,
submitFormulario,
} = useFormulario();
// Extraer todas las preguntas del cuestionario
const preguntas = data?.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas.map((pregunta) => pregunta.pregunta)
);
const { data, loadingData, errorData, respuestas, isSending, errorBusqueda, loadingBusqueda } = state;
// Identificar preguntas especiales
const preguntaComunidad = preguntas?.find(
(pregunta) =>
pregunta.validacion === 'comunidad_alumno' ||
pregunta.validacion === 'comunidad_trabajador'
);
const preguntaCuenta = preguntas?.find(
(pregunta) =>
pregunta.validacion === 'cuenta_alumno' ||
pregunta.validacion === 'cuenta_trabajador' ||
pregunta.validacion === 'rfc'
);
// Determinar tipo de comunidad seleccionada
const comunidadSeleccionada = preguntaComunidad
? respuestas[preguntaComunidad.id_pregunta]
const comunidadSeleccionada = preguntaComunidad
? respuestas[preguntaComunidad.id_pregunta]
: null;
const esComunidadSi = comunidadSeleccionada &&
preguntaComunidad?.opciones.find(op =>
op.id_opcion === Number(comunidadSeleccionada) &&
op.opcion.opcion.toLowerCase().includes('si')
);
// Efecto para buscar información de cuenta
useEffect(() => {
if (!preguntaCuenta || !esComunidadSi) {
setCuentaInfo(null);
setBusquedaCompletada(false);
setCuentaBuscada('');
return;
}
const cuenta = respuestas[preguntaCuenta.id_pregunta];
if (!cuenta || typeof cuenta !== 'string') {
setCuentaInfo(null);
setBusquedaCompletada(false);
setCuentaBuscada('');
return;
}
// Determinar longitudes válidas según el tipo
const longitudesValidas = {
'cuenta_alumno': 9,
'cuenta_trabajador': 6,
'rfc': 10
};
const longitudRequerida = longitudesValidas[preguntaCuenta.validacion as keyof typeof longitudesValidas];
const puedeBuscar = cuenta.length === longitudRequerida;
// Solo buscar si no se ha buscado ya esta cuenta
if (puedeBuscar && cuentaBuscada !== cuenta) {
setBusquedaCompletada(false);
const fetchCuentaInfo = async () => {
try {
let endpoint: string;
switch (preguntaCuenta.validacion) {
case 'cuenta_alumno':
endpoint = `/alumnos/${cuenta}`;
break;
case 'cuenta_trabajador':
endpoint = `/trabajadores/${cuenta}`;
break;
case 'rfc':
endpoint = `/trabajadores/${cuenta}`;
break;
default:
throw new Error('Tipo de validación no reconocido');
}
const res = await axiosInstance.get<UsuarioDataResponse>(endpoint);
console.log('Datos de cuenta obtenidos:', res.data);
// Verificar si hay datos válidos
const hayDatosValidos = res.data.nombre || res.data.apellidos ||
res.data.carrera || res.data.genero;
if (hayDatosValidos) {
setCuentaInfo({
nombre: res.data.nombre || '',
apellidos: res.data.apellidos || '',
correo: res.data.cuenta
? `${res.data.cuenta}@pcpuma.acatlan.unam.mx`
: '',
genero: (res.data.genero === 'M' || res.data.genero === 'F')
? res.data.genero
: 'M',
carrera: res.data.carrera || '',
rfc: res.data.rfc || undefined,
});
} else {
setCuentaInfo(null);
}
setCuentaBuscada(cuenta);
setBusquedaCompletada(true);
} catch (error) {
console.error('Error al obtener datos de cuenta:', error);
setCuentaInfo(null);
setCuentaBuscada(cuenta);
setBusquedaCompletada(true);
}
};
fetchCuentaInfo();
} else if (!puedeBuscar) {
setCuentaInfo(null);
setBusquedaCompletada(false);
setCuentaBuscada('');
}
}, [respuestas, preguntaCuenta, esComunidadSi, cuentaBuscada]);
// Efecto para prellenar respuestas cuando se obtiene información de cuenta
useEffect(() => {
if (!cuentaInfo || !data?.cuestionario.secciones) return;
const nuevasRespuestas: RespuestaFormulario = {};
preguntas?.forEach((pregunta) => {
const id = pregunta.id_pregunta;
switch (pregunta.validacion) {
case 'nombre':
nuevasRespuestas[id] = cuentaInfo.nombre;
break;
case 'apellidos':
nuevasRespuestas[id] = cuentaInfo.apellidos;
break;
case 'correo':
nuevasRespuestas[id] = cuentaInfo.correo;
break;
case 'carrera':
nuevasRespuestas[id] = cuentaInfo.carrera;
break;
case 'genero':
const generoTexto = cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
const opcionGenero = pregunta.opciones?.find(
(op) => op.opcion.opcion.toLowerCase() === generoTexto.toLowerCase()
);
if (opcionGenero) {
nuevasRespuestas[id] = String(opcionGenero.id_opcion);
}
break;
}
});
setRespuestas(prev => ({ ...prev, ...nuevasRespuestas }));
}, [cuentaInfo, data?.cuestionario.secciones, preguntas]);
// Función para actualizar respuestas
const actualizarRespuesta = (idPregunta: string, valor: string) => {
setRespuestas(prev => ({ ...prev, [idPregunta]: valor }));
};
// Función para enviar formulario
const handleOnSubmit = async () => {
setIsSending(true);
if (!data?.cuestionario.id_cuestionario) {
toast.error('No se ha cargado correctamente el formulario');
setIsSending(false);
return;
}
// Validar preguntas obligatorias
const preguntasObligatorias = data.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas
.filter((pregunta) => pregunta.pregunta.obligatoria)
.map((pregunta) => pregunta.pregunta)
.filter((p) => p.pregunta.obligatoria)
.map((p) => p.pregunta)
);
const faltantes = preguntasObligatorias.filter(
(pregunta) =>
respuestas[pregunta.id_pregunta] === undefined ||
respuestas[pregunta.id_pregunta] === '' ||
respuestas[pregunta.id_pregunta] === null
(p) =>
respuestas[p.id_pregunta] === undefined ||
respuestas[p.id_pregunta] === '' ||
respuestas[p.id_pregunta] === null
);
if (faltantes.length > 0) {
toast.error('Por favor responde todas las preguntas obligatorias.');
setIsSending(false);
return;
}
// Buscar correo
const preguntaCorreo = preguntas?.find(p =>
p.validacion === 'correo' || p.validacion === 'correo_institucional'
);
let correo = preguntaCorreo
? String(respuestas[preguntaCorreo.id_pregunta] || '')
: '';
if (!correo) {
const correoRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
for (const valor of Object.values(respuestas)) {
if (typeof valor === 'string' && correoRegex.test(valor)) {
correo = valor;
break;
}
}
}
const payload = {
id_cuestionario: id_cuestionario,
correo: correo,
fecha_envio: new Date().toISOString(),
respuestas: Object.entries(respuestas).map(([id_pregunta, valor]) => ({
id_pregunta: Number(id_pregunta),
valor: isNaN(Number(valor)) ? valor : Number(valor),
})),
};
try {
const res = await axiosInstance.post<SubmitResponse>(
'/cuestionario-respondido/submit',
payload
);
toast.success(res.data.message || '¡Formulario enviado exitosamente!');
handleSubmitFormulario(res.data);
} catch (error) {
console.error('Error al enviar formulario:', getAxiosError(error));
const res = await submitFormulario(data.cuestionario.id_cuestionario);
toast.success(res.message || '¡Formulario enviado exitosamente!');
handleSubmitFormulario(res);
} catch {
toast.error('Hubo un error al enviar el formulario');
} finally {
setIsSending(false);
}
};
// Condición para mostrar el resto del formulario
const mostrarFormularioCompleto =
!preguntaComunidad || // Sin pregunta de comunidad
(esComunidadSi && busquedaCompletada) || // Con comunidad "Sí" y búsqueda completada
(!esComunidadSi && comunidadSeleccionada); // Con comunidad "No"
if (error) {
console.error('Error al cargar el cuestionario:', error);
if (errorData) {
return <div>Error al cargar el cuestionario</div>;
}
if (!data) {
if (loadingData || !data) {
return <div>Cargando...</div>;
}
@@ -321,15 +97,15 @@ export default function FormularioRegistro({
value: op.id_opcion,
}))}
selectedValue={comunidadSeleccionada ? Number(comunidadSeleccionada) : undefined}
onChange={(opt) => {
actualizarRespuesta(preguntaComunidad.id_pregunta.toString(), String(opt.value));
}}
onChange={(opt) =>
setRespuesta(preguntaComunidad.id_pregunta.toString(), String(opt.value))
}
/>
</div>
)}
{/* Pregunta de cuenta (si respondió "Sí" a comunidad) */}
{esComunidadSi && preguntaCuenta && (
{/* Pregunta de cuenta (si respondió "Sí" a comunidad, o en cuestionario exclusivo) */}
{(esComunidadSi || esExclusivo) && preguntaCuenta && (
<div className="my-4">
<label className={`form-label ${preguntaCuenta.obligatoria ? 'required' : ''}`}>
{preguntaCuenta.pregunta}
@@ -341,107 +117,120 @@ export default function FormularioRegistro({
preguntaCuenta.validacion === 'cuenta_alumno'
? 'Ingrese su número de cuenta (9 dígitos)'
: preguntaCuenta.validacion === 'cuenta_trabajador'
? 'Ingrese su número de trabajador (6 dígitos)'
: preguntaCuenta.validacion === 'rfc'
? 'Ingrese su RFC sin homoclave (10 caracteres)'
: `Ingrese su ${preguntaCuenta.pregunta}`
? 'Ingrese su número de trabajador (6 dígitos)'
: preguntaCuenta.validacion === 'rfc'
? 'Ingrese su RFC sin homoclave (10 caracteres)'
: `Ingrese su ${preguntaCuenta.pregunta}`
}
maxLength={
preguntaCuenta.validacion === 'rfc'
? 10
: preguntaCuenta.validacion === 'cuenta_alumno'
? 9
: 6
}
maxLength={preguntaCuenta.validacion === 'rfc' ? 10 :
preguntaCuenta.validacion === 'cuenta_alumno' ? 9 : 6}
value={respuestas[preguntaCuenta.id_pregunta] || ''}
onChange={(e) => actualizarRespuesta(
preguntaCuenta.id_pregunta.toString(),
e.target.value
)}
onChange={(e) =>
setRespuesta(preguntaCuenta.id_pregunta.toString(), e.target.value)
}
/>
{loadingBusqueda && (
<div className="text-muted small mt-1">
<span className="spinner-border spinner-border-sm me-1" />
Buscando...
</div>
)}
{errorBusqueda && (
<div className="alert alert-danger py-2 mt-2" role="alert">
<i className="bi bi-exclamation-circle me-2"></i>
{errorBusqueda}
</div>
)}
</div>
)}
{/* Resto del formulario */}
{mostrarFormularioCompleto && data.cuestionario.secciones.map((seccion, i) => (
<div key={i}>
{seccion.preguntas.map((pregunta) => {
// No renderizar preguntas ya manejadas
if (
pregunta.pregunta.id_pregunta === preguntaComunidad?.id_pregunta ||
pregunta.pregunta.id_pregunta === preguntaCuenta?.id_pregunta
) {
{mostrarFormularioCompleto &&
data.cuestionario.secciones.map((seccion, i) => (
<div key={i}>
{seccion.preguntas.map((pregunta) => {
if (
pregunta.pregunta.id_pregunta === preguntaComunidad?.id_pregunta ||
pregunta.pregunta.id_pregunta === preguntaCuenta?.id_pregunta
) {
return null;
}
const valor = respuestas[pregunta.pregunta.id_pregunta] || '';
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Abierta (Respuesta corta)') {
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
{pregunta.pregunta.pregunta}
</label>
<SimpleInput
type="text"
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
placeholder="Ingrese una respuesta"
value={valor}
onChange={(e) =>
setRespuesta(pregunta.pregunta.id_pregunta.toString(), e.target.value)
}
/>
</div>
);
}
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Abierta (Parrafo)') {
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
{pregunta.pregunta.pregunta}
</label>
<SimpleInput
type="textarea"
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
placeholder="Ingrese una respuesta"
value={valor}
onChange={(e) =>
setRespuesta(pregunta.pregunta.id_pregunta.toString(), e.target.value)
}
/>
</div>
);
}
if (
pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Cerrada' ||
pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Multiple'
) {
const opciones: RadioOption<number>[] = pregunta.pregunta.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}));
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
{pregunta.pregunta.pregunta}
</label>
<RadioOptionGroup
name={`pregunta_${pregunta.pregunta.id_pregunta}`}
options={opciones}
selectedValue={valor ? Number(valor) : undefined}
onChange={(opt) =>
setRespuesta(pregunta.pregunta.id_pregunta.toString(), String(opt.value))
}
/>
</div>
);
}
return null;
}
const valor = respuestas[pregunta.pregunta.id_pregunta] || '';
// Pregunta abierta corta
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Abierta (Respuesta corta)') {
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
{pregunta.pregunta.pregunta}
</label>
<SimpleInput
type="text"
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
placeholder="Ingrese una respuesta"
value={valor}
onChange={(e) => actualizarRespuesta(
pregunta.pregunta.id_pregunta.toString(),
e.target.value
)}
/>
</div>
);
}
// Pregunta abierta párrafo
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Abierta (Parrafo)') {
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
{pregunta.pregunta.pregunta}
</label>
<SimpleInput
type="textarea"
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
placeholder="Ingrese una respuesta"
value={valor}
onChange={(e) => actualizarRespuesta(
pregunta.pregunta.id_pregunta.toString(),
e.target.value
)}
/>
</div>
);
}
// Pregunta cerrada
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Cerrada') {
const opciones: RadioOption<number>[] = pregunta.pregunta.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}));
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
{pregunta.pregunta.pregunta}
</label>
<RadioOptionGroup
name={`pregunta_${pregunta.pregunta.id_pregunta}`}
options={opciones}
selectedValue={valor ? Number(valor) : undefined}
onChange={(opt) => actualizarRespuesta(
pregunta.pregunta.id_pregunta.toString(),
String(opt.value)
)}
/>
</div>
);
}
return null;
})}
</div>
))}
})}
</div>
))}
{/* Botón de envío */}
{mostrarFormularioCompleto && (
@@ -456,16 +245,3 @@ export default function FormularioRegistro({
</div>
);
}
/*
curl -X 'POST' \
'https://venus.acatlan.unam.mx/registro_test/administrador' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"nombre_usuario": "staffmike",
"correo": "staffmike@ejemplo.com",
"password": "password",
"id_tipo_user": 2
}'
*/
+20 -123
View File
@@ -4,10 +4,11 @@ import { useGetApi } from '@/hooks/use-get-api';
import Image from 'next/image';
import React, { useState, useEffect } from 'react';
import FormularioRegistro from '../formulario/formulario-registro';
import FormularioExito from '../formulario/formulario-exito';
import { GetEventoWithCuestionario } from '@/types/evento';
import { formatearRangoFechas } from '@/utils/date-utils';
import { SubmitResponse } from '@/types/submit';
import Link from 'next/link';
import { FormularioProvider } from '@/context/formulario';
export default function FormularioPage({
id_evento,
@@ -64,121 +65,12 @@ export default function FormularioPage({
// Si hay una respuesta exitosa, mostrar la vista de éxito
if (response && response.success) {
return (
<div className="container my-5">
<div className="row justify-content-center">
<div className="col-12 col-lg-10 col-xl-8">
<div className="card shadow-sm">
<div className="card-body">
<div className="row g-4">
<div className="col-md-6">
<div className="h-100 d-flex flex-column justify-content-center">
<div className="text-center mb-4">
<div className="mb-3">
<div
className="d-inline-flex align-items-center justify-content-center bg-success rounded-circle"
style={{ width: '60px', height: '60px' }}
>
<i
className="bi bi-check-lg text-white"
style={{ fontSize: '2rem' }}
></i>
</div>
</div>
<h2 className="fw-bold text-success mb-2">
¡Felicidades!
</h2>
<p className="text-muted mb-0">
Has completado exitosamente el formulario
</p>
</div>
<div className="mb-3">
<div className="d-flex align-items-center mb-2">
<i
className="bi bi-calendar-event text-primary me-3"
style={{ fontSize: '1.3rem' }}
></i>
<h6 className="mb-0 text-primary">Evento</h6>
</div>
<p className="mb-0 fs-6 fw-semibold ms-5">
{data?.nombre_evento}
</p>
</div>
<div className="mb-3">
<div className="d-flex align-items-center mb-2">
<i
className="bi bi-file-text text-info me-3"
style={{ fontSize: '1.3rem' }}
></i>
<h6 className="mb-0 text-info">Formulario</h6>
</div>
<p className="mb-0 fs-6 fw-semibold ms-5">
{data?.cuestionario.nombre_form}
</p>
</div>
{response.message && (
<div className="mb-3">
<div className="alert alert-info" role="alert">
<div className="d-flex align-items-start">
<i className="bi bi-info-circle me-2 mt-1"></i>
<div>
<strong>Mensaje:</strong>
<p className="mb-0 mt-1">{response.message}</p>
</div>
</div>
</div>
</div>
)}
</div>
</div>
<div className="col-md-6">
<div className="h-100 d-flex flex-column justify-content-center align-items-center">
{qrImageUrl ? (
<div className="text-center">
<h5 className="mb-3 text-secondary">
<i className="bi bi-qr-code me-2"></i>
Código QR
</h5>
<div className="p-3 bg-white rounded shadow-sm">
<Image
src={qrImageUrl}
alt="Código QR"
width={250}
height={250}
className="rounded"
style={{ maxWidth: '100%', height: 'auto' }}
unoptimized
/>
</div>
</div>
) : (
<div className="text-center text-muted">
<i
className="bi bi-qr-code-scan"
style={{ fontSize: '4rem' }}
></i>
<p className="mt-3 mb-0">
No hay código QR disponible
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
<div className="text-center mt-4">
<Link href="/" className="btn btn-primary btn-lg me-3">
Ir al inicio
</Link>
</div>
</div>
</div>
</div>
<FormularioExito
response={response}
qrImageUrl={qrImageUrl}
nombreEvento={data?.nombre_evento}
nombreFormulario={data?.cuestionario.nombre_form}
/>
);
}
@@ -353,13 +245,18 @@ export default function FormularioPage({
<div className="col-lg-7">
<div className="ps-lg-4">
{data && (
<FormularioRegistro
evento={data.nombre_evento}
cuestionario={data?.cuestionario.nombre_form}
id_evento={data?.id_evento}
id_cuestionario={data?.cuestionario.id_cuestionario}
handleSubmitFormulario={onSubmitFormulario}
/>
<FormularioProvider
id_evento={data.id_evento}
id_cuestionario={data.cuestionario.id_cuestionario}
>
<FormularioRegistro
evento={data.nombre_evento}
cuestionario={data.cuestionario.nombre_form}
id_evento={data.id_evento}
id_cuestionario={data.cuestionario.id_cuestionario}
handleSubmitFormulario={onSubmitFormulario}
/>
</FormularioProvider>
)}
</div>
</div>
@@ -0,0 +1,330 @@
'use client';
import React, {
createContext,
useContext,
useReducer,
useEffect,
useRef,
useMemo,
useCallback,
ReactNode,
} from 'react';
import axiosInstance from '@/utils/api-config';
import { getAxiosError } from '@/utils/errors-utils';
import { GetCuestionario } from '@/types/evento';
import { UsuarioDataResponse } from '@/containers/formulario/formulario-registro';
import {
formularioReducer,
initialState,
FormularioState,
RespuestaFormulario,
} from './formulario-reducer';
// ── Longitudes requeridas por tipo de validación ─────────────────────────────
const LONGITUDES: Record<string, number> = {
cuenta_alumno: 9,
cuenta_trabajador: 6,
rfc: 10,
};
// ── Tipos del contexto ────────────────────────────────────────────────────────
interface FormularioContextType {
state: FormularioState;
// Datos derivados estables
preguntas: ReturnType<typeof getPreguntasFlat>;
preguntaComunidad: PreguntaFlat | undefined;
preguntaCuenta: PreguntaFlat | undefined;
esComunidadSi: boolean;
esExclusivo: boolean;
mostrarFormularioCompleto: boolean;
// Acciones
setRespuesta: (id: string, valor: string) => void;
submitFormulario: (id_cuestionario: number) => Promise<import('@/types/submit').SubmitResponse>;
}
// Helper para aplanar preguntas
type PreguntaFlat = GetCuestionario['cuestionario']['secciones'][0]['preguntas'][0]['pregunta'];
function getPreguntasFlat(data: GetCuestionario | null): PreguntaFlat[] {
if (!data) return [];
return data.cuestionario.secciones.flatMap((s) =>
s.preguntas.map((p) => p.pregunta)
);
}
// ── Context ───────────────────────────────────────────────────────────────────
const FormularioContext = createContext<FormularioContextType | undefined>(undefined);
interface FormularioProviderProps {
children: ReactNode;
id_evento: number;
id_cuestionario: number;
}
export function FormularioProvider({
children,
id_evento,
id_cuestionario,
}: FormularioProviderProps) {
const [state, dispatch] = useReducer(formularioReducer, initialState);
// Rastrea si ya se limpiaron los campos para el usuarioEncontrado actual
const yaLimpioRef = useRef(false);
// ── Carga del cuestionario ─────────────────────────────────────────────────
useEffect(() => {
dispatch({ type: 'FETCH_DATA_START' });
axiosInstance
.get<GetCuestionario>(`/cuestionario/${id_cuestionario}/formulario`)
.then((res) => dispatch({ type: 'FETCH_DATA_SUCCESS', payload: res.data }))
.catch((err) => {
const { message } = getAxiosError(err);
dispatch({ type: 'FETCH_DATA_ERROR', payload: message });
});
}, [id_cuestionario]);
// ── Derivados estables ─────────────────────────────────────────────────────
const preguntas = useMemo(() => getPreguntasFlat(state.data), [state.data]);
const preguntaComunidad = useMemo(
() =>
preguntas.find(
(p) =>
p.validacion === 'comunidad_alumno' ||
p.validacion === 'comunidad_trabajador'
),
[preguntas]
);
const preguntaCuenta = useMemo(
() =>
preguntas.find(
(p) =>
p.validacion === 'cuenta_alumno' ||
p.validacion === 'cuenta_trabajador' ||
p.validacion === 'rfc'
),
[preguntas]
);
const comunidadSeleccionada = preguntaComunidad
? state.respuestas[preguntaComunidad.id_pregunta]
: null;
const esComunidadSi = useMemo(
() =>
!!(
comunidadSeleccionada &&
preguntaComunidad?.opciones.find(
(op) =>
op.id_opcion === Number(comunidadSeleccionada) &&
op.opcion.opcion.toLowerCase().includes('si')
)
),
[comunidadSeleccionada, preguntaComunidad]
);
const esExclusivo = useMemo(
() => !preguntaComunidad && !!preguntaCuenta,
[preguntaComunidad, preguntaCuenta]
);
const mostrarFormularioCompleto =
(esExclusivo && state.busquedaCompletada) ||
(!preguntaComunidad && !esExclusivo) ||
(esComunidadSi && state.busquedaCompletada) ||
(!!(esComunidadSi === false) && !!comunidadSeleccionada);
// ── Búsqueda de cuenta ─────────────────────────────────────────────────────
useEffect(() => {
const debeIniciarBusqueda = preguntaCuenta && (esExclusivo || esComunidadSi);
if (!debeIniciarBusqueda) {
if (state.cuentaBuscada || state.busquedaCompletada || state.errorBusqueda) {
dispatch({ type: 'BUSQUEDA_RESET' });
}
return;
}
const cuenta = state.respuestas[preguntaCuenta.id_pregunta];
if (!cuenta || typeof cuenta !== 'string') {
if (state.cuentaBuscada || state.busquedaCompletada || state.errorBusqueda) {
dispatch({ type: 'BUSQUEDA_RESET' });
}
return;
}
const longitudRequerida = LONGITUDES[preguntaCuenta.validacion ?? ''];
if (!longitudRequerida || cuenta.length !== longitudRequerida) {
if (state.cuentaBuscada) dispatch({ type: 'BUSQUEDA_RESET' });
return;
}
// No re-buscar la misma cuenta
if (cuenta === state.cuentaBuscada) return;
let endpoint =
preguntaCuenta.validacion === 'cuenta_alumno'
? `/alumnos/${cuenta}`
: `/trabajadores/${cuenta}`;
if (esExclusivo) endpoint += `?id_evento=${id_evento}`;
dispatch({ type: 'BUSQUEDA_START', payload: cuenta });
axiosInstance
.get<UsuarioDataResponse>(endpoint)
.then((res) => {
const d = res.data;
if (d.nombre || d.apellidos || d.carrera || d.genero) {
dispatch({ type: 'BUSQUEDA_SUCCESS', payload: d });
} else {
dispatch({ type: 'BUSQUEDA_ERROR', payload: 'No se encontraron datos para este usuario' });
}
})
.catch((err) => {
const { message } = getAxiosError(err);
dispatch({ type: 'BUSQUEDA_ERROR', payload: message });
});
}, [
state.respuestas,
state.cuentaBuscada,
state.busquedaCompletada,
state.errorBusqueda,
preguntaCuenta,
esComunidadSi,
esExclusivo,
id_evento,
]);
// ── Autorelleno cuando llega usuarioEncontrado ─────────────────────────────
useEffect(() => {
if (!state.data) return;
if (!state.usuarioEncontrado) {
// Solo limpiar si aún no lo hicimos (evita loop infinito)
if (!yaLimpioRef.current) return;
yaLimpioRef.current = false;
const idsCampos = preguntas
.filter((p) =>
['nombre', 'apellidos', 'correo', 'carrera', 'genero'].includes(p.validacion ?? '')
)
.map((p) => p.id_pregunta);
if (idsCampos.length) {
dispatch({ type: 'LIMPIAR_CAMPOS_AUTORELLENO', payload: idsCampos });
}
return;
}
const u = state.usuarioEncontrado;
const bulk: RespuestaFormulario = {};
preguntas.forEach((pregunta) => {
const id = pregunta.id_pregunta;
switch (pregunta.validacion) {
case 'nombre':
bulk[id] = u.nombre ?? '';
break;
case 'apellidos':
bulk[id] = u.apellidos ?? '';
break;
case 'correo':
bulk[id] = u.cuenta ? `${u.cuenta}@pcpuma.acatlan.unam.mx` : '';
break;
case 'carrera':
bulk[id] = u.carrera ?? '';
break;
case 'genero': {
const texto = u.genero === 'M' ? 'Masculino' : 'Femenino';
const opcion = pregunta.opciones?.find(
(op) => op.opcion.opcion.toLowerCase() === texto.toLowerCase()
);
if (opcion) bulk[id] = String(opcion.id_opcion);
break;
}
}
});
if (Object.keys(bulk).length) {
yaLimpioRef.current = true;
dispatch({ type: 'SET_RESPUESTAS_BULK', payload: bulk });
}
}, [state.usuarioEncontrado, state.data, preguntas]);
// ── Acciones expuestas ─────────────────────────────────────────────────────
const setRespuesta = useCallback((id: string, valor: string) => {
dispatch({ type: 'SET_RESPUESTA', payload: { id, valor } });
}, []);
const submitFormulario = useCallback(
async (id_cuestionario: number) => {
dispatch({ type: 'SEND_START' });
try {
const preguntaCorreo = preguntas.find(
(p) => p.validacion === 'correo' || p.validacion === 'correo_institucional'
);
let correo = preguntaCorreo
? String(state.respuestas[preguntaCorreo.id_pregunta] ?? '')
: '';
if (!correo) {
const rx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
for (const v of Object.values(state.respuestas)) {
if (typeof v === 'string' && rx.test(v)) { correo = v; break; }
}
}
const res = await axiosInstance.post<import('@/types/submit').SubmitResponse>(
'/cuestionario-respondido/submit',
{
id_cuestionario,
correo,
fecha_envio: new Date().toISOString(),
respuestas: Object.entries(state.respuestas).map(([id, valor]) => ({
id_pregunta: Number(id),
valor: isNaN(Number(valor)) ? valor : Number(valor),
})),
}
);
return res.data;
} finally {
dispatch({ type: 'SEND_END' });
}
},
[state.respuestas, preguntas]
);
// ── Valor del contexto ─────────────────────────────────────────────────────
const value: FormularioContextType = {
state,
preguntas,
preguntaComunidad,
preguntaCuenta,
esComunidadSi,
esExclusivo,
mostrarFormularioCompleto,
setRespuesta,
submitFormulario,
};
return (
<FormularioContext.Provider value={value}>
{children}
</FormularioContext.Provider>
);
}
export function useFormulario() {
const ctx = useContext(FormularioContext);
if (!ctx) throw new Error('useFormulario debe usarse dentro de FormularioProvider');
return ctx;
}
@@ -0,0 +1,152 @@
import { GetCuestionario } from '@/types/evento';
import { UsuarioDataResponse } from '@/containers/formulario/formulario-registro';
export type RespuestaFormulario = Record<string, string | number>;
// ── Estado ──────────────────────────────────────────────────────────────────
export interface FormularioState {
// Datos del cuestionario cargado desde la API
data: GetCuestionario | null;
loadingData: boolean;
errorData: string | null;
// Respuestas del usuario
respuestas: RespuestaFormulario;
// Búsqueda de cuenta/rfc
cuentaBuscada: string;
busquedaCompletada: boolean;
loadingBusqueda: boolean;
errorBusqueda: string | null;
usuarioEncontrado: UsuarioDataResponse | null;
// Envío
isSending: boolean;
}
export const initialState: FormularioState = {
data: null,
loadingData: true,
errorData: null,
respuestas: {},
cuentaBuscada: '',
busquedaCompletada: false,
loadingBusqueda: false,
errorBusqueda: null,
usuarioEncontrado: null,
isSending: false,
};
// ── Acciones ─────────────────────────────────────────────────────────────────
export type FormularioAction =
// Carga del cuestionario
| { type: 'FETCH_DATA_START' }
| { type: 'FETCH_DATA_SUCCESS'; payload: GetCuestionario }
| { type: 'FETCH_DATA_ERROR'; payload: string }
// Respuestas
| { type: 'SET_RESPUESTA'; payload: { id: string; valor: string } }
| { type: 'SET_RESPUESTAS_BULK'; payload: RespuestaFormulario }
| { type: 'LIMPIAR_CAMPOS_AUTORELLENO'; payload: number[] } // ids de pregunta
// Búsqueda de cuenta
| { type: 'BUSQUEDA_START'; payload: string } // cuenta buscada
| { type: 'BUSQUEDA_SUCCESS'; payload: UsuarioDataResponse }
| { type: 'BUSQUEDA_ERROR'; payload: string }
| { type: 'BUSQUEDA_RESET' }
// Envío
| { type: 'SEND_START' }
| { type: 'SEND_END' };
// ── Reducer ──────────────────────────────────────────────────────────────────
export function formularioReducer(
state: FormularioState,
action: FormularioAction
): FormularioState {
switch (action.type) {
// Carga del cuestionario
case 'FETCH_DATA_START':
return { ...state, loadingData: true, errorData: null };
case 'FETCH_DATA_SUCCESS':
return { ...state, loadingData: false, data: action.payload };
case 'FETCH_DATA_ERROR':
return { ...state, loadingData: false, errorData: action.payload };
// Respuestas
case 'SET_RESPUESTA':
return {
...state,
respuestas: { ...state.respuestas, [action.payload.id]: action.payload.valor },
};
case 'SET_RESPUESTAS_BULK':
return {
...state,
respuestas: { ...state.respuestas, ...action.payload },
};
case 'LIMPIAR_CAMPOS_AUTORELLENO': {
const limpio = { ...state.respuestas };
action.payload.forEach((id) => delete limpio[id]);
return { ...state, respuestas: limpio };
}
// Búsqueda
case 'BUSQUEDA_START':
return {
...state,
loadingBusqueda: true,
errorBusqueda: null,
busquedaCompletada: false,
usuarioEncontrado: null,
cuentaBuscada: action.payload,
};
case 'BUSQUEDA_SUCCESS':
return {
...state,
loadingBusqueda: false,
busquedaCompletada: true,
errorBusqueda: null,
usuarioEncontrado: action.payload,
};
case 'BUSQUEDA_ERROR':
return {
...state,
loadingBusqueda: false,
busquedaCompletada: false,
errorBusqueda: action.payload,
usuarioEncontrado: null,
};
case 'BUSQUEDA_RESET':
return {
...state,
cuentaBuscada: '',
busquedaCompletada: false,
loadingBusqueda: false,
errorBusqueda: null,
usuarioEncontrado: null,
};
// Envío
case 'SEND_START':
return { ...state, isSending: true };
case 'SEND_END':
return { ...state, isSending: false };
default:
return state;
}
}
+2
View File
@@ -0,0 +1,2 @@
export { FormularioProvider, useFormulario } from './formulario-context';
export type { FormularioState, FormularioAction, RespuestaFormulario } from './formulario-reducer';
+119 -7
View File
@@ -1,7 +1,6 @@
import { FormularioCreacion } from '@/types/create-formulario';
interface Plantilla {
imagen: string;
nombre: string;
id: string;
datos: FormularioCreacion;
@@ -9,8 +8,7 @@ interface Plantilla {
export const plantillasDisponibles: Plantilla[] = [
{
imagen: '/plantilla_trabajadores.png',
nombre: 'Registro Comunidad Trabajador',
nombre: 'Registro General (con prellenado para Trabajadores)',
id: 'registro-comunidad-estudiantil',
datos: {
nombre_form: 'Registro',
@@ -79,8 +77,7 @@ export const plantillasDisponibles: Plantilla[] = [
},
},
{
imagen: '/plantilla_alumnos.png',
nombre: 'Registro Comunidad Alumnos',
nombre: 'Registro General (con prellenado para Alumnos)',
id: 'registro-comunidad-alumnos',
datos: {
nombre_form: 'Registro',
@@ -155,7 +152,6 @@ export const plantillasDisponibles: Plantilla[] = [
},
},
{
imagen: '/plantilla_general.png',
nombre: 'Registro General',
id: 'registro-general',
datos: {
@@ -209,9 +205,125 @@ export const plantillasDisponibles: Plantilla[] = [
],
},
},
{
nombre: 'Formulario Exclusivo Alumnos',
id: 'formulario-exclusivo-alumnos',
datos: {
nombre_form: 'Formulario Exclusivo Alumnos',
descripcion:
'Este formulario está destinado exclusivamente para alumnos. Por favor, completa todos los campos obligatorios.',
fecha_inicio: '2025-08-01T08:00:00',
fecha_fin: '2025-08-10T23:59:59',
id_tipo_cuestionario: 1,
secciones: [
{
titulo: 'Información Personal',
descripcion: 'Proporciona tus datos personales.',
preguntas: [
{
titulo: 'Número de cuenta',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'cuenta_alumno',
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'correo',
},
{
titulo: 'Nombre(s)',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'nombre',
},
{
titulo: 'Apellidos',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'apellidos',
},
{
titulo: 'Carrera',
tipo: 'Abierta (Respuesta corta)',
validacion: 'carrera',
obligatoria: true,
},
{
titulo: 'Género',
tipo: 'Cerrada',
obligatoria: true,
validacion: 'genero',
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
],
},
],
},
},
{
nombre: 'Formulario Exclusivo Profesores',
id: 'formulario-exclusivo-profesores',
datos: {
nombre_form: 'Formulario Exclusivo Profesores',
descripcion:
'Este formulario está destinado exclusivamente para profesores. Por favor, completa todos los campos obligatorios.',
fecha_inicio: '2025-08-01T08:00:00',
fecha_fin: '2025-08-10T23:59:59',
id_tipo_cuestionario: 2,
secciones: [
{
titulo: 'Información Personal',
descripcion: 'Proporciona tus datos personales.',
preguntas: [
{
titulo: 'RFC',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'rfc',
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'correo',
},
{
titulo: 'Nombre(s)',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'nombre',
},
{
titulo: 'Apellidos',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'apellidos',
},
{
titulo: 'Género',
tipo: 'Cerrada',
obligatoria: true,
validacion: 'genero',
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
],
},
],
},
},
];
/*
/*
¡Bienvenidas y bienvenidos a la Facultad!
Inicia esta nueva etapa con un recorrido especial por nuestras instalaciones, pensado para que conozcas tu nueva casa universitaria. Te acompañaremos por aulas, laboratorios, bibliotecas, áreas comunes y servicios que estarán a tu disposición durante tu formación. Además, podrás resolver dudas y conocer a quienes serán parte de tu camino académico.
¡No faltes, esta actividad marcará el inicio de grandes experiencias!
+1
View File
@@ -29,6 +29,7 @@ export interface FormularioCreacion {
id_tipo_cuestionario: number;
id_tipo_evento?: number;
cupo_maximo?: number; // Opcional, si no se requiere un cupo máximo
mensaje_correo?: string;
secciones: SeccionFormulario[];
}
+1
View File
@@ -15,6 +15,7 @@ export interface GetCuestionario {
fecha_fin: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
fecha_inicio: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
id_tipo_cuestionario: number;
mensaje_correo?: string | null;
}
export interface GetCuestionarioWithEvento extends GetCuestionario {