55 Commits

Author SHA1 Message Date
jalvarado 2b16d9b2f7 Merge pull request 'Develop' (#37) from develop into master
Despliegue Automatizado Universal / get-context (push) Successful in 1s
Despliegue Automatizado Universal / build (push) Failing after 32s
Despliegue Automatizado Universal / deploy (push) Has been skipped
Reviewed-on: #37
2026-07-28 21:17:31 +00:00
jalvarado-contentoh 9c86f5d223 refactor: remove unnecessary reverse on cuestionarios display
Despliegue Automatizado Universal / get-context (push) Successful in 0s
Despliegue Automatizado Universal / build (push) Failing after 36s
Despliegue Automatizado Universal / deploy (push) Has been skipped
2026-07-28 15:15:58 -06:00
stern eaa955a995 Add deploy.yaml
Despliegue Automatizado Universal / get-context (push) Successful in 1s
Despliegue Automatizado Universal / build (push) Failing after 38s
Despliegue Automatizado Universal / deploy (push) Has been skipped
2026-07-02 03:38:28 -06:00
jalvarado f0be2bd7fe Merge pull request 'develop' (#36) from develop into master
Reviewed-on: #36
2026-06-02 17:49:03 -06:00
miguel ad983c6ae8 eliminate log 2026-06-02 17:48:17 -06:00
miguel ac0671bb1a feat: reverse order of cuestionarios display and add console log for data 2026-06-02 17:47:25 -06:00
jalvarado 066b150df3 Merge pull request 'feat: add modal to display user contador and update attendance confirmation flow' (#35) from develop into master
Reviewed-on: #35
2026-05-14 19:07:27 -06:00
miguel 8c2a6ecb6f feat: add modal to display user contador and update attendance confirmation flow
Co-authored-by: Copilot <copilot@github.com>
2026-05-13 20:52:05 -06:00
jalvarado b31a2f36c9 Merge pull request 'feat: tecto horas agregado a carta' (#34) from develop into master
Reviewed-on: #34
2026-03-26 10:20:23 -06:00
miguel 2f50d4f2fb feat: tecto horas agregado a carta 2026-03-26 10:19:22 -06:00
jalvarado 56ce4c3942 Merge pull request 'feature: horas, added at time and acento in estaras' (#33) from develop into master
Reviewed-on: #33
2026-03-24 20:19:12 -06:00
miguel dcc474a065 feature: horas, added at time and acento in estaras 2026-03-24 20:18:18 -06:00
jalvarado 36b52ca898 Merge pull request 'develop' (#32) from develop into master
Reviewed-on: #32
2026-03-10 15:42:15 -06:00
miguel fe8ec5f591 feat: add email message field to cuestionario and update edit form 2026-03-10 12:59:01 -06:00
miguel d3e1441f87 feat: add bulk user upload functionality and form success page
- Implemented bulk upload for students and workers with file validation and error handling.
- Added Excel template download feature for user uploads.
- Created success page for form submissions with QR code display.
- Established context and reducer for form state management, including user search and response handling.
- Added mermaid diagrams for event registration documentation.
2026-03-08 19:18:23 -06:00
jalvarado d846ea6c01 Merge pull request 'develop' (#31) from develop into master
Reviewed-on: #31
2026-02-11 15:22:57 -06:00
miguel f7d7bcfae6 fix: version nextjs 2026-02-11 15:22:08 -06:00
santiago c0f4f31e18 Se quitaron algunas vulnerabilidades 2026-01-08 18:24:00 -06:00
jalvarado a4b8746cd8 Merge pull request 'develop' (#30) from develop into master
Reviewed-on: #30
2025-11-10 17:35:56 -06:00
miguel 3eba0c1348 Refactor navbar component for improved readability and consistency 2025-11-10 17:33:31 -06:00
santiago 5a88e93e04 Se agrego la validacion para que el staff no pueda crear formularios 2025-10-30 20:04:04 +01:00
santiago 479f9fae46 Merge branch 'develop' 2025-10-28 22:25:42 +01:00
santiago 6f89b993e1 change cupos to registro 2025-10-28 22:20:22 +01:00
santiago cac17284d8 Cambios sin modificar 2025-10-07 18:48:06 +02:00
miguel 9068cef62d Implementar gestión de usuarios con modales para agregar, editar y eliminar administradores 2025-10-05 13:15:55 -06:00
santiago b0f744ef14 Modificaciones 2025-10-03 14:45:35 -06:00
santiago 4ff66e81c7 Modificaciones 2025-10-02 16:11:31 -06:00
santiago 3123c9911a Modificaciones y correcion de errores 2025-10-02 16:08:57 -06:00
santiago fee9d92ed7 Modificaciones en las vistas de las cargas masivas 2025-10-02 16:03:28 -06:00
santiago 8f2ee27711 Se agregaron las funcionalidades de subir eventos e imagenes en carga masiva 2025-10-01 15:21:02 -06:00
santiago 509ff4255a Modificaciones en error al escanear codigo qr 2025-10-01 14:14:36 -06:00
santiago 4c300a8e20 Validacion en la asistencia del usuario 2025-09-29 12:13:38 -06:00
jalvarado be30c5a438 Merge pull request 'feat: update plantilla titles and options for improved clarity and inclusivity' (#29) from develop into master
Reviewed-on: #29
2025-09-10 10:51:12 -06:00
miguel 534ba64e0c feat: update plantilla titles and options for improved clarity and inclusivity 2025-09-10 10:34:11 -06:00
jalvarado c2b435d033 Merge pull request 'develop' (#28) from develop into master
Reviewed-on: #28
2025-09-10 10:18:56 -06:00
jorge miguel dcb66ce2c1 feat: implement formulario de registro with community and account validation, including dynamic response handling and submission logic 2025-09-09 19:24:35 -06:00
jalvarado dc161cf269 Merge pull request 'Refactor code structure for improved readability and maintainability' (#26) from events-division into develop
Reviewed-on: #26
2025-09-09 15:17:44 -06:00
jalvarado 978f6c8a69 Merge pull request 'events-division' (#25) from events-division into develop
Reviewed-on: #25
2025-08-19 10:29:17 -06:00
jalvarado e0a9a001fa Merge pull request 'fix: add recent events section to the administrator page' (#24) from develop into master
Reviewed-on: #24
2025-08-11 10:24:33 -06:00
jalvarado 904da12911 Merge pull request 'fix: update confirmarAsistencia function to use cuestionario ID instead of evento ID' (#23) from develop into master
Reviewed-on: #23
2025-08-08 09:30:53 -06:00
jalvarado 4ff69369b5 Merge pull request 'fix: update cuestionario types to include evento details' (#22) from develop into master
Reviewed-on: #22
2025-08-07 09:49:02 -06:00
jalvarado bd0703d07e Merge pull request 'send' (#21) from develop into master
Reviewed-on: #21
2025-06-30 14:22:22 -06:00
jalvarado 4d4cd7ec67 Merge pull request 'fix: simplify toast notification rendering function' (#20) from develop into master
Reviewed-on: #20
2025-06-30 13:25:53 -06:00
jalvarado 16dc610ba3 Merge pull request 'fix: enhance toast notifications for form submission feedback' (#19) from develop into master
Reviewed-on: #19
2025-06-30 13:23:57 -06:00
jalvarado eaea5cd86b Merge pull request 'develop' (#18) from develop into master
Reviewed-on: #18
2025-06-30 11:24:16 -06:00
jalvarado 480b1bc86f Merge pull request 'feat: update placeholder text for cuenta input based on validation type' (#17) from develop into master
Reviewed-on: #17
2025-06-30 11:15:25 -06:00
jalvarado 68a041682e Merge pull request 'Implement code changes to enhance functionality and improve performance' (#16) from develop into master
Reviewed-on: #16
2025-06-27 18:18:20 -06:00
jalvarado 0fb3a141ce Merge pull request 'refactor: improve email validation logic and enhance community response checks in FormularioRegistro' (#15) from develop into master
Reviewed-on: #15
2025-06-27 17:20:04 -06:00
jalvarado 2ac53a2a24 Merge pull request 'fix: correct URL structure in EventoCard component for cuestionario navigation' (#14) from develop into master
Reviewed-on: #14
2025-06-27 15:58:22 -06:00
jalvarado 9a49392f8a Merge pull request 'feat: include cupo_maximo in event update payload for edit formulario' (#13) from develop into master
Reviewed-on: #13
2025-06-25 11:38:11 -06:00
jalvarado 4425c2b3f7 Merge pull request 'develop' (#12) from develop into master
Reviewed-on: #12
2025-06-24 10:32:28 -06:00
jalvarado 3e5be626f7 Merge pull request 'Reporte respuestas' (#11) from develop into master
Reviewed-on: #11
2025-04-25 11:42:32 -06:00
jalvarado 12dcd999c7 Merge pull request 'develop' (#10) from develop into master
Reviewed-on: #10
2025-04-21 12:35:12 -06:00
jalvarado 9142c4c951 Merge pull request 'banner aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' (#9) from develop into master
Reviewed-on: #9
2025-04-09 18:40:21 -06:00
jalvarado ec23cef4a0 Merge pull request 'banner' (#8) from develop into master
Reviewed-on: #8
2025-04-09 17:59:06 -06:00
32 changed files with 3223 additions and 1555 deletions
+85
View File
@@ -0,0 +1,85 @@
name: Despliegue Automatizado Universal
on:
push:
branches:
- develop
- master
jobs:
get-context:
runs-on: host
outputs:
node_version: ${{ steps.setup.outputs.version }}
target_app: ${{ steps.setup.outputs.app }}
steps:
- name: Resolver Aplicación desde apps.conf
id: setup
run: |
# 1. Obtener el nombre del repositorio actual en Gitea (ej: front-Censo)
REPO_NAME="${{ github.event.repository.name }}"
# 2. Determinar si buscamos un entorno -dev (develop) o producción (master)
if [ "${{ github.ref_name }}" = "develop" ]; then
SUFFIX="-dev"
else
SUFFIX=""
fi
echo "Buscando qué sección de apps.conf tiene REPO=$REPO_NAME y termina en '$SUFFIX'..."
# 3. Buscar en /etc/apps.conf el bloque que contenga REPO=nombre_del_repo
TARGET_APP=$(awk -v repo="REPO=$REPO_NAME" -v suf="$SUFFIX" '
/^\[/ { gsub(/[\[\]]/, "", $0); current_box=$0; next }
$0 == repo {
if ((suf == "-dev" && current_box ~ /-dev$/) || (suf == "" && current_box !~ /-dev$/)) {
print current_box;
exit
}
}
' /etc/apps.conf)
if [ -z "$TARGET_APP" ]; then
echo "ERROR: No se encontró ninguna sección en /etc/apps.conf para el repositorio $REPO_NAME con el sufijo correcto."
exit 1
fi
# 4. Leer la versión de Node del bloque encontrado
VERSION=$(awk -v app="$TARGET_APP" '
$0=="["app"]" { found=1; next }
/^\[/ && found { exit }
found && /^NODE=/ { split($0, a, "="); print a[2]; exit }
' /etc/apps.conf)
MAJOR_VERSION=$(echo "$VERSION" | tr -d '[:space:]' | grep -oE '[0-9]+' | head -n1)
echo "-> ID de Aplicación encontrado: $TARGET_APP"
echo "-> Versión Mayor de Node resuelta: $MAJOR_VERSION"
echo "app=${TARGET_APP}" >> $GITHUB_OUTPUT
echo "version=${MAJOR_VERSION}" >> $GITHUB_OUTPUT
build:
needs: get-context
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configurar Node.js dinámicamente
uses: actions/setup-node@v4
with:
node-version: ${{ needs.get-context.outputs.node_version }}
- run: npm ci
- run: npm run build
deploy:
needs: [get-context, build]
runs-on: host
steps:
- name: Ejecutar Despliegue en Servidor Físico
run: |
TARGET_APP="${{ needs.get-context.outputs.target_app }}"
BRANCH_NAME="${{ github.ref_name }}"
sudo -u deploy /home/deploy/scripts/deploy-front.sh "$TARGET_APP" "$BRANCH_NAME"
+3 -3
View File
@@ -6,12 +6,12 @@ const nextConfig: NextConfig = {
{ {
protocol: 'http', protocol: 'http',
hostname: 'localhost', hostname: 'localhost',
port: '4200', port: '4200', //4200
}, },
{ {
protocol: 'https', protocol: 'https',
hostname: 'venus.acatlan.unam.mx' hostname: 'venus.acatlan.unam.mx',
} },
], ],
}, },
}; };
+399 -821
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -14,13 +14,15 @@
"@uiw/react-md-editor": "^4.0.7", "@uiw/react-md-editor": "^4.0.7",
"@yudiel/react-qr-scanner": "^2.2.1", "@yudiel/react-qr-scanner": "^2.2.1",
"axios": "^1.8.4", "axios": "^1.8.4",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.8",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"next": "15.3.3", "next": "^15.5.12",
"react": "^19.0.0", "react": "^19.0.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
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>
);
}
@@ -11,6 +11,10 @@ import { useParams } from 'next/navigation';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useEvento } from '@/context/evento'; import { useEvento } from '@/context/evento';
import dynamic from 'next/dynamic';
import Button from '@/components/button';
const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
type Params = { type Params = {
id_evento: string; id_evento: string;
@@ -27,6 +31,7 @@ export default function Page() {
const [loadingAsistencia, setLoadingAsistencia] = React.useState< const [loadingAsistencia, setLoadingAsistencia] = React.useState<
Record<number, boolean> Record<number, boolean>
>({}); >({});
const [contadorModal, setContadorModal] = React.useState<number | null>(null);
const { data: participantes, setData } = useGetApi<ParticipacionEvento[]>( const { data: participantes, setData } = useGetApi<ParticipacionEvento[]>(
`/participante-evento/evento/${params.id_cuestionario}` `/participante-evento/evento/${params.id_cuestionario}`
@@ -45,12 +50,15 @@ export default function Page() {
) => { ) => {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: true })); setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: true }));
try { try {
await axiosInstance.post( const response = await axiosInstance.post(
`/participante-evento/asistencia/${id_participante}/${id_evento}` `/participante-evento/asistencia/${id_participante}/${id_evento}`
); );
if (response.data?.contador != null) {
setContadorModal(response.data.contador);
}
toast.success('Asistencia confirmada'); toast.success('Asistencia confirmada');
// Actualiza lista
const { data } = await axiosInstance.get<ParticipacionEvento[]>( const { data } = await axiosInstance.get<ParticipacionEvento[]>(
`/participante-evento/evento/${id_evento}` `/participante-evento/evento/${id_evento}`
); );
@@ -188,6 +196,33 @@ export default function Page() {
</div> </div>
</> </>
)} )}
<Modal
isVisible={contadorModal !== null}
size="sm"
onClose={() => setContadorModal(null)}
closeButton
className={{ content: 'border-0 rounded-3', body: 'text-center' }}
>
<div className="modal-header bg-primary text-white border-0 rounded-top-3">
<h5 className="modal-title mb-0 fw-bold">
<i className="bi bi-hash me-2"></i>
Contador
</h5>
</div>
<div className="modal-body p-4">
<p className="display-4 fw-bold text-primary mb-1">{contadorModal}</p>
<p className="text-muted mb-4">
Este es el numero de estampillas a entregar al usuario
</p>
<Button
variant="primary"
onClick={() => setContadorModal(null)}
className="px-4 py-2 rounded-pill"
>
Cerrar
</Button>
</div>
</Modal>
</div> </div>
); );
} }
@@ -1,7 +1,6 @@
'use client'; 'use client';
import { plantillasDisponibles } from '@/data/plantillas'; import { plantillasDisponibles } from '@/data/plantillas';
import { FormularioCreacion } from '@/types/create-formulario'; import { FormularioCreacion } from '@/types/create-formulario';
import Image from 'next/image';
import React, { useState } from 'react'; import React, { useState } from 'react';
import CreateFormulario from '@/containers/create-formulario'; import CreateFormulario from '@/containers/create-formulario';
import FormularioCardPreview from '@/components/formulario/formulario-card-preview'; import FormularioCardPreview from '@/components/formulario/formulario-card-preview';
@@ -193,21 +192,18 @@ export default function Page() {
</span> </span>
</p> </p>
<div className="d-flex gap-4 flex-wrap"> <div className="d-flex gap-2 flex-wrap">
{plantillasDisponibles.map((plantilla) => ( {plantillasDisponibles.map((plantilla) => (
<div <div
className="mb-3 cursor-pointer" className="cursor-pointer"
key={plantilla.id} key={plantilla.id}
onClick={() => handleSeleccionarPlantilla(plantilla.id)} onClick={() => handleSeleccionarPlantilla(plantilla.id)}
> >
<div className="text-center"> <div
<Image className="d-flex align-items-center justify-content-center rounded shadow-sm border text-center fw-semibold bg-azul text-white"
src={plantilla.imagen} style={{ padding: '1rem' }}
width={200} >
height={200} {plantilla.nombre}
alt={`Plantilla de formulario ${plantilla.nombre}`}
className="img-fluid rounded shadow-sm"
/>
</div> </div>
</div> </div>
))} ))}
@@ -21,10 +21,46 @@ export default function Page() {
const params = useParams<Params>(); const params = useParams<Params>();
const { evento, loading, error, updateEvento, setEventoData } = useEvento(); const { evento, loading, error, updateEvento, setEventoData } = useEvento();
const tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
const [loadingStates, setLoadingStates] = useState<Record<number, boolean>>( const [loadingStates, setLoadingStates] = useState<Record<number, boolean>>(
{} {}
); );
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 = ( const handleChange = (
field: keyof CreateEventoType, field: keyof CreateEventoType,
value: string | Date value: string | Date
@@ -86,6 +122,61 @@ export default function Page() {
handleOnChange={handleEventoActualizado} 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' && (
<div className="p-4 border rounded bg-light"> <div className="p-4 border rounded bg-light">
<div className="d-flex justify-content-between align-items-center"> <div className="d-flex justify-content-between align-items-center">
<div> <div>
@@ -99,13 +190,16 @@ export default function Page() {
icon="plus" icon="plus"
variant="primary" variant="primary"
onClick={() => onClick={() =>
router.push(`/user/evento/${params.id_evento}/formularios/crear`) router.push(
`/user/evento/${params.id_evento}/formularios/crear`
)
} }
> >
Crear nuevo formulario Crear nuevo formulario
</Button> </Button>
</div> </div>
</div> </div>
)}
{evento?.cuestionarios && evento.cuestionarios.length > 0 && ( {evento?.cuestionarios && evento.cuestionarios.length > 0 && (
<div className="row mt-3"> <div className="row mt-3">
@@ -0,0 +1,133 @@
'use client'
import axiosInstance from "@/utils/api-config";
import React, { useState } from "react";
import { Button, Form, ProgressBar } from "react-bootstrap";
import { FaUpload } from "react-icons/fa";
export default function CargaMasiva() {
const [xlsx, setXlsx] = useState<File | null>(null);
const [progress, setProgress] = useState<number>(0);
const [loading, setLoading] = useState<boolean>(false);
const validarExt = (file: File | null) => {
if (!file) return;
const extPermitidas = /(.xlsx)$/i;
if (!extPermitidas.exec(file.name)) {
console.error("Asegúrate de ingresar un archivo .xlsx");
setXlsx(null);
} else {
setXlsx(file);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
validarExt(file);
};
const enviarCargaMasiva = async () => {
if (!xlsx) return;
const formData = new FormData();
formData.append("file", xlsx);
try {
setLoading(true);
setProgress(0);
const res = await axiosInstance.post(
"/cuestionario/carga-masiva",
formData,
{
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (event) => {
if (event.total) {
const porcentaje = Math.round(
(event.loaded * 100) / event.total
);
setProgress(porcentaje);
}
},
}
);
//Falta hacer validacion
console.log("Carga masiva exitosa", res.data);
setTimeout(() => {
setProgress(100);
setLoading(false);
}, 500);
} catch (error) {
console.error("Carga masiva fallida", error);
setLoading(false);
setProgress(0);
}
};
return (
<div className="container-fluid my-4">
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Carga Masiva de Eventos</h2>
<p className="mb-0 text-muted">
Sube tu archivo excel para poder crear los eventos.
</p>
</div>
</div>
</div>
<Form.Group className="mb-3">
<Form.Label>Archivo Excel</Form.Label>
<div
className="border p-4 text-center rounded"
style={{ cursor: "pointer" }}
onClick={() => document.getElementById("xlsxInput")?.click()}
>
<FaUpload size={40} className="mb-2" />
<p className="mb-1">
{xlsx?.name ||
"Arrastra aquí tu archivo o da click aquí para buscar"}
</p>
<p className="is-size-6">Tamaño máximo 20MB</p>
<p className="is-size-7">
Si al momento de elegir un archivo este no se selecciona,
haga click en cancelar en la ventana emergente e intente de
nuevo.
</p>
</div>
<input
id="xlsxInput"
type="file"
accept=".xlsx"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</Form.Group>
<div className="d-flex gap-2 mt-3">
<Button
variant="outline-primary"
disabled={!xlsx || loading}
onClick={enviarCargaMasiva}
>
{loading ? "Subiendo..." : "Enviar archivo"}
</Button>
</div>
{/* Barra de progreso */}
{loading && (
<div className="mt-3">
<ProgressBar
now={progress}
label={`${progress}%`}
animated
striped
variant="info"
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,161 @@
"use client";
import React, { useState } from "react";
import { Button, Image } from "react-bootstrap";
import { FaUpload } from "react-icons/fa";
import axiosInstance from "@/utils/api-config";
//Para poder subir las imagenes
interface UploadedImage {
originalName: string;
filename: string;
path: string;
}
export default function BannerUpload() {
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [uploaded, setUploaded] = useState<UploadedImage[]>([]);
const [progress, setProgress] = useState(0);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files));
}
};
const handleUpload = async () => {
if (files.length === 0) {
alert("Selecciona al menos un archivo");
return;
}
const formData = new FormData();
files.forEach((file) => {
formData.append("banners", file);
});
try {
setLoading(true);
setProgress(0);
const res = await axiosInstance.post("/cuestionario/banners/bulk", formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
setProgress(
Math.round((progressEvent.loaded * 100) / progressEvent.total)
);
}
},
});
//Falta hacer validacion
setUploaded(res.data);
alert("Banners subidos correctamente ✅");
setFiles([]);
} catch (error) {
console.error(error);
alert(error || "Error al subir archivos");
} finally {
setLoading(false);
}
};
return (
<div className="container-fluid my-4">
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Subir Banners</h2>
<p className="mb-0 text-muted">
Arrastra o selecciona imágenes para subir tus banners (jpg, png, webp).
</p>
</div>
</div>
</div>
{/* Área Drag & Drop */}
<div
className="border p-4 text-center rounded"
style={{ cursor: "pointer" }}
onClick={() => document.getElementById("bannerInput")?.click()}
>
<FaUpload size={40} className="mb-2 text-muted" />
<p className="mb-1">
Arrastra aquí tus imágenes o haz clic para buscar
</p>
<p className="is-size-6">Tamaño máximo 20MB</p>
</div>
<input
id="bannerInput"
type="file"
accept=".jpg,.jpeg,.png,.webp"
multiple
style={{ display: "none" }}
onChange={handleFileChange}
/>
{/* Previsualización */}
{files.length > 0 && (
<div className="d-flex flex-wrap gap-3 mt-4">
{files.map((file, i) => (
<div
key={i}
className="border rounded overflow-hidden shadow-sm"
style={{ width: "350px", height: "350px" }}
>
<Image
src={URL.createObjectURL(file)}
alt={file.name}
className="img-fluid h-75 w-100 object-fit-cover"
/>
<p className="small text-center bg-light p-2 m-0">{file.name}</p>
</div>
))}
</div>
)}
{/* Botón */}
<div className="d-flex gap-2 mt-3">
<Button
variant="outline-primary"
disabled={loading}
onClick={handleUpload}
>
{loading ? "Subiendo..." : "Subir Banners"}
</Button>
</div>
{/* Barra de progreso */}
{loading && (
<div className="progress mt-3" style={{ height: "8px" }}>
<div
className="progress-bar progress-bar-striped bg-primary"
role="progressbar"
style={{ width: `${progress}%` }}
></div>
</div>
)}
{/* Archivos subidos */}
{uploaded.length > 0 && (
<div className="mt-4">
<h5 className="mb-2 text-muted">Archivos subidos:</h5>
<ul className="list-group">
{uploaded.map((file, i) => (
<li key={i} className="list-group-item d-flex justify-content-between align-items-center">
<span>{file.originalName}</span>
<a href={`/${file.path}`} target="_blank" className="text-primary">
Ver
</a>
</li>
))}
</ul>
</div>
)}
</div>
);
}
+3 -1
View File
@@ -1,6 +1,8 @@
'use client'
import dynamic from 'next/dynamic';
import Footer from '@/components/layout/footer'; import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header'; import Header from '@/components/layout/header';
import Navbar from '@/components/navbar'; const Navbar = dynamic(() => import('@/components/navbar'), { ssr: false });
import React from 'react'; import React from 'react';
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
+84 -54
View File
@@ -10,61 +10,76 @@ import toast from 'react-hot-toast';
const Modal = dynamic(() => import('@/components/modal'), { ssr: false }); const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
export default function Page() { export default function Page() {
const [scannedData, setScannedData] = useState<{
id_participante: number;
id_cuestionario: number;
} | null>(null);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [enableScan, setEnableScan] = useState(true); const [enableScan, setEnableScan] = useState(true);
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [participante, setParticipante] = useState(''); const [participante, setParticipante] = useState('');
const [contador, setContador] = useState(0);
const [message, setMessage] = useState('');
const [token, setToken] = useState('');
const [tokenValid, setTokenValid] = useState(false);
const handleScan = async (rawValue: string) => { const handleScan = async (rawValue: string) => {
console.log('QR Escaneado:', rawValue); console.log('QR Escaneado:', rawValue);
if (!enableScan) return; if (!enableScan) return;
if (!rawValue) {
setStatusMessage('⚠️ El QR no contiene los campos requeridos');
return;
}
try { try {
const data = JSON.parse(rawValue); const response = await axiosInstance.post(
if (data.id_participante && data.id_cuestionario) { '/participante-evento/decode-token',
try { { token: rawValue }
const response = await axiosInstance.get(
`/participante-evento/${data.id_participante}/${data.id_cuestionario}`
); );
setParticipante(response.data.participante.correo); if (response.data) {
setScannedData({ // Token válido
id_participante: data.id_participante, setToken(rawValue);
id_cuestionario: data.id_cuestionario, setTokenValid(true);
});
setShowModal(true); setShowModal(true);
setEnableScan(false); setEnableScan(false);
} catch (err) { setContador(response.data.contador);
console.warn('Participante no registrado en el cuestionario:', err);
setStatusMessage(
'❌ El participante no está registrado en este evento.'
);
}
} else { } else {
setStatusMessage('⚠️ El QR no contiene los campos requeridos'); // Token caducado o cualquier otro error
setStatusMessage(`❌ Error el código qr ya caduco`);
setTokenValid(false);
setShowModal(true); // mostramos modal con el mensaje de error
setEnableScan(false);
} }
} catch (err) { } catch (err) {
console.error('QR malformado:', err); console.error('Error al decodificar el QR:', err);
setStatusMessage('❌ Error al leer el QR: formato inválido'); setStatusMessage('❌ Error al leer el QR');
setTokenValid(false);
setShowModal(true);
setEnableScan(false);
} }
}; };
const handleConfirm = async () => { const handleConfirm = async () => {
if (!scannedData) return; if (!tokenValid) return;
try { try {
const response = await axiosInstance.post( const response = await axiosInstance.post(
`/participante-evento/asistencia/${scannedData.id_participante}/${scannedData.id_cuestionario}` `/participante-evento/asistencia`,
{
token: token,
}
); );
//Validacion
if (response.data.success === false) {
console.error('El usuario ya se registro');
setMessage(response.data.message);
toast.error('❌ Error el usaurio ya se registro previamente.');
} else {
setParticipante(response.data.data.participante);
setMessage(response.data.message);
console.log('Asistencia registrada:', response.data); console.log('Asistencia registrada:', response.data);
toast.success('✅ Asistencia registrada correctamente'); toast.success('✅ Asistencia registrada correctamente');
}
} catch (err) { } catch (err) {
console.error('Error en la petición:', err); console.error('Error en la petición:', err);
setStatusMessage('❌ Error de red al registrar asistencia'); setStatusMessage('❌ Error de red al registrar asistencia');
@@ -72,7 +87,6 @@ export default function Page() {
setShowModal(false); setShowModal(false);
setEnableScan(true); setEnableScan(true);
setScannedData(null);
}; };
return ( return (
@@ -124,6 +138,26 @@ export default function Page() {
</div> </div>
</div> </div>
{participante && (
<div className="mb-4">
<div className="alert alert-success border-0 rounded-3 d-flex align-items-center">
<i className="bi bi-person-check-fill me-2"></i>
<span>
Ultimo participante registrado: {participante}
</span>
</div>
</div>
)}
{message && (
<div className="mb-4">
<div className="alert alert-info border-0 rounded-3 d-flex align-items-center">
<i className="bi bi-info-circle-fill me-2"></i>
<span>{message}</span>
</div>
</div>
)}
<Button <Button
onClick={() => setEnableScan(false)} onClick={() => setEnableScan(false)}
variant="outline-danger" variant="outline-danger"
@@ -195,7 +229,7 @@ export default function Page() {
onClose={() => { onClose={() => {
setShowModal(false); setShowModal(false);
setEnableScan(true); setEnableScan(true);
setScannedData(null); setTokenValid(false);
}} }}
closeButton closeButton
className={{ className={{
@@ -211,31 +245,12 @@ export default function Page() {
</div> </div>
<div className="modal-body p-4"> <div className="modal-body p-4">
{scannedData ? ( {tokenValid ? (
// Modal de confirmación de asistencia
<> <>
<div className="mb-4"> <p className="text-muted mb-3">
<div className="mb-3"> Contador del usuario: <strong>{contador}</strong>
<i </p>
className="bi bi-person-check-fill text-success"
style={{ fontSize: '3rem' }}
></i>
</div>
<h5 className="mb-3 fw-semibold">
Información del participante
</h5>
<div className="card bg-light border-0 rounded-3">
<div className="card-body py-3">
<div className="d-flex align-items-center justify-content-center">
<i className="bi bi-envelope-fill text-primary me-2"></i>
<strong className="me-2">Correo:</strong>
<span className="text-primary fw-medium">
{participante}
</span>
</div>
</div>
</div>
</div>
<div className="d-grid gap-2 d-md-flex justify-content-center"> <div className="d-grid gap-2 d-md-flex justify-content-center">
<Button <Button
variant="success" variant="success"
@@ -250,7 +265,8 @@ export default function Page() {
onClick={() => { onClick={() => {
setShowModal(false); setShowModal(false);
setEnableScan(true); setEnableScan(true);
setScannedData(null); setTokenValid(false);
setToken('');
}} }}
className="px-4 py-2 rounded-pill" className="px-4 py-2 rounded-pill"
> >
@@ -260,6 +276,7 @@ export default function Page() {
</div> </div>
</> </>
) : ( ) : (
// Modal de error
<div className="py-4"> <div className="py-4">
<div className="mb-3"> <div className="mb-3">
<i <i
@@ -267,7 +284,20 @@ export default function Page() {
style={{ fontSize: '2.5rem' }} style={{ fontSize: '2.5rem' }}
></i> ></i>
</div> </div>
<h6 className="text-danger mb-0">QR inválido o malformado</h6> <h6 className="text-danger mb-0">{statusMessage}</h6>
<div className="mt-3">
<Button
variant="secondary"
onClick={() => {
setShowModal(false);
setEnableScan(true);
setStatusMessage('');
}}
className="px-4 py-2 rounded-pill"
>
Cerrar
</Button>
</div>
</div> </div>
)} )}
</div> </div>
+313 -6
View File
@@ -1,12 +1,158 @@
'use client'; 'use client';
import Button from '@/components/button'; import Button from '@/components/button';
import Table, { Header } from '@/components/table'; import Table, { Header } from '@/components/table';
const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
import { useGetApi } from '@/hooks/use-get-api'; import { useGetApi } from '@/hooks/use-get-api';
import { Administrador } from '@/types/administradores'; import { Administrador } from '@/types/administradores';
import React from 'react'; import React, { useState } from 'react';
import axiosInstance from '@/utils/api-config';
import dynamic from 'next/dynamic';
export default function Page() { export default function Page() {
const { data } = useGetApi<Administrador[]>('/administrador'); const { data, setData } = useGetApi<Administrador[]>('/administrador');
// Estado del modal
const [isModalVisible, setIsModalVisible] = useState(false);
const [modalMode, setModalMode] = useState<'add' | 'edit'>('add');
const [selectedUser, setSelectedUser] = useState<Administrador | null>(null);
// Estado del modal de eliminación
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const [userToDelete, setUserToDelete] = useState<Administrador | null>(null);
// Estado del formulario
const [formData, setFormData] = useState({
nombre_usuario: '',
correo: '',
password: '',
id_tipo_user: '',
});
// Handlers del modal
const handleOpenAddModal = () => {
setModalMode('add');
setSelectedUser(null);
setFormData({
nombre_usuario: '',
correo: '',
password: '',
id_tipo_user: '',
});
setIsModalVisible(true);
};
const handleOpenEditModal = (user: Administrador) => {
setModalMode('edit');
setSelectedUser(user);
setFormData({
nombre_usuario: user.nombre_usuario,
correo: user.correo,
password: '',
id_tipo_user: user.tipoUser.id.toString(),
});
setIsModalVisible(true);
};
const handleCloseModal = () => {
setIsModalVisible(false);
setSelectedUser(null);
};
// Handlers del modal de eliminación
const handleOpenDeleteModal = (user: Administrador) => {
setUserToDelete(user);
setIsDeleteModalVisible(true);
};
const handleCloseDeleteModal = () => {
setIsDeleteModalVisible(false);
setUserToDelete(null);
};
const handleConfirmDelete = async () => {
// Aquí iría la lógica para eliminar el usuario
try {
if (userToDelete) {
// Lógica para eliminar el usuario
const response = await axiosInstance.delete(
`/administrador/${userToDelete.id_administrador}`
);
console.log('Usuario eliminado:', response.data);
// Actualizar la lista de usuarios en el estado
if (data) {
const updatedData = data.filter(
(user) => user.id_administrador !== userToDelete.id_administrador
);
setData(updatedData);
}
}
} catch (error) {
console.error('Error al eliminar el usuario:', error);
}
handleCloseDeleteModal();
};
// Handler para envío del formulario
const handleSubmitForm = async () => {
try {
if (modalMode === 'add') {
// Crear nuevo usuario
const payload = {
nombre_usuario: formData.nombre_usuario,
correo: formData.correo,
password: formData.password,
id_tipo_user: parseInt(formData.id_tipo_user),
};
const response = await axiosInstance.post('/administrador', payload);
console.log('Usuario creado:', response.data);
// Actualizar la lista de usuarios
if (data) {
setData([
...data,
{
correo: formData.correo,
id_administrador: response.data.id_administrador,
nombre_usuario: formData.nombre_usuario,
tipoUser: {
id: parseInt(formData.id_tipo_user),
tipo: formData.id_tipo_user === '1' ? 'Administrador' : 'Staff',
},
},
]);
}
} else if (modalMode === 'edit' && selectedUser) {
// Editar usuario existente
const payload = {
nombre_usuario: formData.nombre_usuario,
correo: formData.correo,
id_tipo_user: parseInt(formData.id_tipo_user),
};
const response = await axiosInstance.put(
`/administrador/${selectedUser.id_administrador}`,
payload
);
console.log('Usuario actualizado:', response.data);
// Actualizar la lista de usuarios
if (data) {
const updatedData = data.map((user) =>
user.id_administrador === selectedUser.id_administrador
? { ...user, ...response.data }
: user
);
setData(updatedData);
}
}
handleCloseModal();
} catch (error) {
console.error('Error al guardar el usuario:', error);
}
};
const headers: Header<Administrador>[] = [ const headers: Header<Administrador>[] = [
{ key: 'id_administrador', label: 'ID' }, { key: 'id_administrador', label: 'ID' },
@@ -19,12 +165,16 @@ export default function Page() {
}, },
{ {
key: 'tipoUser', key: 'tipoUser',
render: () => ( render: (_, row) => (
<div className="d-flex gap-2"> <div className="d-flex gap-2">
<Button className="w-100" disabled> <Button className="w-100" onClick={() => handleOpenEditModal(row)}>
Editar Editar
</Button> </Button>
<Button variant="danger" className="w-100" disabled> <Button
variant="danger"
className="w-100"
onClick={() => handleOpenDeleteModal(row)}
>
Eliminar Eliminar
</Button> </Button>
</div> </div>
@@ -37,9 +187,166 @@ export default function Page() {
<div> <div>
<div className="d-flex justify-content-between align-items-center my-3"> <div className="d-flex justify-content-between align-items-center my-3">
<h1>Administradores</h1> <h1>Administradores</h1>
<Button variant="primary">Agregar Administrador</Button> <Button variant="primary" onClick={handleOpenAddModal}>
Agregar Administrador
</Button>
</div> </div>
{data && <Table headers={headers} data={data} />} {data && <Table headers={headers} data={data} />}
<Modal
isVisible={isModalVisible}
onClose={handleCloseModal}
size="lg"
closeButton={true}
>
<div className="p-3">
<h3 className="mb-4">
{modalMode === 'add'
? 'Agregar Nuevo Administrador'
: 'Editar Administrador'}
</h3>
{modalMode === 'edit' && selectedUser && (
<div className="mb-3">
<p>
<strong>ID:</strong> {selectedUser.id_administrador}
</p>
<p>
<strong>Usuario actual:</strong> {selectedUser.nombre_usuario}
</p>
<p>
<strong>Correo actual:</strong> {selectedUser.correo}
</p>
<p>
<strong>Tipo:</strong> {selectedUser.tipoUser.tipo}
</p>
</div>
)}
<div className="mb-3">
<label htmlFor="nombre_usuario" className="form-label">
Nombre de Usuario
</label>
<input
type="text"
className="form-control"
id="nombre_usuario"
value={formData.nombre_usuario}
onChange={(e) =>
setFormData((prev) => ({
...prev,
nombre_usuario: e.target.value,
}))
}
placeholder="Ingrese el nombre de usuario"
/>
</div>
<div className="mb-3">
<label htmlFor="correo" className="form-label">
Correo Electrónico
</label>
<input
type="email"
className="form-control"
id="correo"
value={formData.correo}
onChange={(e) =>
setFormData((prev) => ({
...prev,
correo: e.target.value,
}))
}
placeholder="Ingrese el correo electrónico"
/>
</div>
{modalMode === 'add' && (
<div className="mb-3">
<label htmlFor="password" className="form-label">
Contraseña
</label>
<input
type="password"
className="form-control"
id="password"
value={formData.password}
onChange={(e) =>
setFormData((prev) => ({
...prev,
password: e.target.value,
}))
}
placeholder="Ingrese la contraseña"
/>
</div>
)}
<div className="mb-4">
<label htmlFor="tipoUser" className="form-label">
Tipo de Usuario
</label>
<select
className="form-select"
id="tipoUser"
value={formData.id_tipo_user}
onChange={(e) =>
setFormData((prev) => ({
...prev,
id_tipo_user: e.target.value,
}))
}
>
<option value="">Seleccione un tipo</option>
<option value="1">Administrador</option>
<option value="2">Staff</option>
</select>
</div>
<div className="d-flex gap-2 justify-content-end">
<Button variant="secondary" onClick={handleCloseModal}>
Cancelar
</Button>
<Button variant="primary" onClick={handleSubmitForm}>
{modalMode === 'add' ? 'Crear Administrador' : 'Guardar Cambios'}
</Button>
</div>
</div>
</Modal>
{/* Modal de confirmación de eliminación */}
<Modal
isVisible={isDeleteModalVisible}
onClose={handleCloseDeleteModal}
size="md"
closeButton={true}
>
<div className="p-4 text-center">
<div className="mb-4">
<i className="bi bi-exclamation-triangle text-warning display-1"></i>
</div>
<h4 className="mb-3">¿Está seguro de eliminar al usuario?</h4>
{userToDelete && (
<div className="mb-4">
<p className="fs-5 fw-bold text-danger">
{userToDelete.nombre_usuario}
</p>
<p className="text-muted">Esta acción no se puede deshacer</p>
</div>
)}
<div className="d-flex gap-3 justify-content-center">
<Button variant="secondary" onClick={handleCloseDeleteModal}>
Cancelar
</Button>
<Button variant="danger" onClick={handleConfirmDelete}>
, Eliminar
</Button>
</div>
</div>
</Modal>
</div> </div>
); );
} }
+4 -2
View File
@@ -34,13 +34,15 @@ export default function LoginForm() {
password: loginData.password, password: loginData.password,
}); });
console.log(response)
// Guardar el token en sessionStorage // Guardar el token en sessionStorage
if (response.data.token) { if (response.data.token) {
sessionStorage.setItem('token', response.data.token); sessionStorage.setItem('token', response.data.token);
// Opcional: guardar información adicional del usuario si viene en la respuesta // Opcional: guardar información adicional del usuario si viene en la respuesta
if (response.data.user) { if (response.data.tipo_usuario) {
sessionStorage.setItem('user', JSON.stringify(response.data.user)); sessionStorage.setItem('axxxd', JSON.stringify(response.data.tipo_usuario));
} }
// Redireccionar al dashboard // Redireccionar al dashboard
+24
View File
@@ -60,6 +60,30 @@ export default function Page() {
</div> </div>
)} )}
{/*
{!loading && data && data.length > 0 && (
<div className="row">
{data.flatMap((evento) =>
evento.cuestionarios
// Filtramos solo los cuestionarios con id_cuestionario >= 148
.filter((cuestionario) => !cuestionario.id_cuestionario > 10 && !cuestionario.id_cuestionario < 148)
// Luego mapeamos para renderizarlos
.map((cuestionario, index) => (
<div
className={`col-md-6 col-lg-4 my-3 fade-in-up-bounce delay-${(index % 5) + 1}`}
key={`${evento.id_evento}-${cuestionario.id_cuestionario}`}
>
<FormularioCardUser
evento={evento}
formulario={cuestionario}
/>
</div>
))
)}
</div>
)}
*/}
{!loading && data && data.length > 0 && ( {!loading && data && data.length > 0 && (
<div className="row"> <div className="row">
{data.flatMap((evento) => {data.flatMap((evento) =>
@@ -143,7 +143,7 @@ export default function FormularioCardUser({
<div className="row mb-3"> <div className="row mb-3">
{/* Columna de horario - solo visible si es el mismo día */} {/* Columna de horario - solo visible si es el mismo día */}
{esElMismoDia && ( {esElMismoDia && (
<div className="col-6"> <div className="col-7">
<div <div
className={`py-2 px-3 rounded ${ className={`py-2 px-3 rounded ${
sinCupos ? 'bg-primary-subtle' : 'bg-light' sinCupos ? 'bg-primary-subtle' : 'bg-light'
@@ -154,7 +154,7 @@ export default function FormularioCardUser({
<div> <div>
<small className="text-muted d-block">Horario</small> <small className="text-muted d-block">Horario</small>
<span className={`fw-semibold text-azul`}> <span className={`fw-semibold text-azul`}>
{horarioFormateado} {horarioFormateado} horas.
</span> </span>
</div> </div>
</div> </div>
@@ -162,7 +162,7 @@ export default function FormularioCardUser({
</div> </div>
)} )}
{/* Columna de cupos - siempre visible */} {/* Columna de cupos - siempre visible */}
<div className={esElMismoDia ? 'col-6' : 'col-12'}> <div className={esElMismoDia ? 'col-4' : 'col-12'}>
<div <div
className={`py-2 px-3 rounded ${ className={`py-2 px-3 rounded ${
sinCupos ? 'bg-primary-subtle' : 'bg-light' sinCupos ? 'bg-primary-subtle' : 'bg-light'
@@ -171,7 +171,7 @@ export default function FormularioCardUser({
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<i className={`bi bi-people-fill me-2 text-primary`}></i> <i className={`bi bi-people-fill me-2 text-primary`}></i>
<div> <div>
<small className="text-muted d-block">Cupos</small> <small className="text-muted d-block">Registro</small>
<span className={`fw-semibold text-primary`}> <span className={`fw-semibold text-primary`}>
{formulario.cupo_maximo !== null {formulario.cupo_maximo !== null
? `${formulario.cupos_usados} / ${formulario.cupo_maximo}` ? `${formulario.cupos_usados} / ${formulario.cupo_maximo}`
+61 -5
View File
@@ -3,18 +3,51 @@ import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
type NavChild = {
href: string; // siempre string
label: string;
};
type NavItem =
| { href: string; label: string } // items normales
| { label: string; children: NavChild[] }; // items con submenu
const Navbar: React.FC = () => { const Navbar: React.FC = () => {
const pathname = usePathname(); const pathname = usePathname();
const tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
const navItems = [ const navItemsAdmin: NavItem[] = [
{ href: '/', label: 'Inicio' }, { href: '/', label: 'Inicio' },
{ href: '/user/eventos', label: 'Eventos' }, { href: '/user/eventos', label: 'Eventos' },
{ href: '/user/qr', label: 'Lector de QRs' }, { href: '/user/qr', label: 'Lector de QRs' },
{ href: '/user/eventos/crear', label: 'Crear Evento' }, { href: '/user/eventos/crear', label: 'Crear Evento' },
{
label: 'Carga Masiva',
children: [
{ href: '/user/eventos/carga_masiva', label: 'Carga Masiva Eventos' },
{
href: '/user/eventos/carga_masiva_banner',
label: 'Carga Masiva Imagenes',
},
{
href: '/user/carga_masiva_usuarios',
label: 'Carga Masiva Usuarios',
},
],
},
{ href: '/user/usuarios', label: 'Usuarios' }, { href: '/user/usuarios', label: 'Usuarios' },
{ href: '/signout', label: 'Cerrar sesión' }, { href: '/login', label: 'Cerrar sesión' },
]; ];
const navItemsUser: NavItem[] = [
{ href: '/user/eventos', label: 'Eventos' },
{ href: '/user/qr', label: 'Lector de QRs' },
{ href: '/login', label: 'Cerrar sesión' },
];
const navItems =
tipo_usuario === 'administrador' ? navItemsAdmin : navItemsUser;
return ( return (
<nav className="navbar navbar-expand-lg navbar-dark bg-azul"> <nav className="navbar navbar-expand-lg navbar-dark bg-azul">
<div className="container"> <div className="container">
@@ -33,7 +66,7 @@ const Navbar: React.FC = () => {
id="offcanvasNavbar" id="offcanvasNavbar"
aria-labelledby="offcanvasNavbarLabel" aria-labelledby="offcanvasNavbarLabel"
> >
<div className="offcanvas-header"> <div className="offcanvas-header bg-azul text-white">
<h5 className="offcanvas-title" id="offcanvasNavbarLabel"> <h5 className="offcanvas-title" id="offcanvasNavbarLabel">
Menu Menu
</h5> </h5>
@@ -44,7 +77,7 @@ const Navbar: React.FC = () => {
aria-label="Close" aria-label="Close"
></button> ></button>
</div> </div>
<div className="offcanvas-body align-items-center justify-content-between"> <div className="offcanvas-body align-items-center justify-content-between bg-azul">
<h2 className="h5 mb-0 text-white"> <h2 className="h5 mb-0 text-white">
Sistema de Registro de Eventos Sistema de Registro de Eventos
</h2> </h2>
@@ -52,9 +85,10 @@ const Navbar: React.FC = () => {
{navItems.map((item, index) => ( {navItems.map((item, index) => (
<li <li
key={index} key={index}
className={`nav-item`} className="nav-item"
data-bs-dismiss="offcanvas" data-bs-dismiss="offcanvas"
> >
{'href' in item ? (
<Link <Link
className={`nav-link ${ className={`nav-link ${
pathname === item.href ? 'text-white' : '' pathname === item.href ? 'text-white' : ''
@@ -63,6 +97,28 @@ const Navbar: React.FC = () => {
> >
{item.label} {item.label}
</Link> </Link>
) : (
<div className="nav-item dropdown">
<a
className="nav-link dropdown-toggle"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{item.label}
</a>
<ul className="dropdown-menu">
{item.children.map((child: NavChild, idx: number) => (
<li key={idx}>
<Link className="dropdown-item" href={child.href}>
{child.label}
</Link>
</li>
))}
</ul>
</div>
)}
</li> </li>
))} ))}
</ul> </ul>
+7
View File
@@ -214,6 +214,13 @@ export default function FormularioEditor({
</div> </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="row">
<div className="col-md-6"> <div className="col-md-6">
<Select <Select
+12 -2
View File
@@ -31,6 +31,8 @@ export default function EditEvento({
handleChange, handleChange,
handleOnChange, handleOnChange,
}: EditEventoProps) { }: EditEventoProps) {
const tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [nuevoBanner, setNuevoBanner] = useState<File | null>(null); const [nuevoBanner, setNuevoBanner] = useState<File | null>(null);
@@ -102,6 +104,8 @@ export default function EditEvento({
<div className="p-4"> <div className="p-4">
{/* Banner */} {/* Banner */}
<div className="mb-4"> <div className="mb-4">
{tipo_usuario === 'administrador' && (
<>
<ImageUploader <ImageUploader
label="Banner del evento" label="Banner del evento"
defaultBanner={ defaultBanner={
@@ -129,6 +133,8 @@ export default function EditEvento({
Formatos soportados: PNG, JPG, JPEG. Formatos soportados: PNG, JPG, JPEG.
</strong> </strong>
</p> </p>
</>
)}
</div> </div>
{/* Detalles del evento */} {/* Detalles del evento */}
@@ -142,6 +148,8 @@ export default function EditEvento({
onChange={(e) => onChange={(e) =>
handleChange('nombre_evento', e.target.value) handleChange('nombre_evento', e.target.value)
} }
disabled={tipo_usuario !== 'administrador'}
/> />
<div className="mb-4"> <div className="mb-4">
@@ -150,6 +158,7 @@ export default function EditEvento({
<MDEditor <MDEditor
value={evento.descripcion_evento ?? ''} value={evento.descripcion_evento ?? ''}
onChange={(value) => { onChange={(value) => {
if (tipo_usuario !== 'administrador') return;
const texto = value || ''; const texto = value || '';
if (texto.length <= MAX_LENGTH) { if (texto.length <= MAX_LENGTH) {
handleChange('descripcion_evento', texto); handleChange('descripcion_evento', texto);
@@ -167,6 +176,7 @@ export default function EditEvento({
placeholder="Selecciona un tipo de evento" placeholder="Selecciona un tipo de evento"
onChange={(e) => handleChange('tipo_evento', e.target.value)} onChange={(e) => handleChange('tipo_evento', e.target.value)}
options={tipoEventos} options={tipoEventos}
disabled={tipo_usuario !== 'administrador'}
/> />
<div className="row"> <div className="row">
@@ -197,11 +207,11 @@ export default function EditEvento({
</div> </div>
{/* Botón de guardar */} {/* Botón de guardar */}
<div className="d-flex justify-content-end mt-4"> {tipo_usuario === "administrador" && <div className="d-flex justify-content-end mt-4">
<Button icon="save" disabled={loading} onClick={handleOnSave}> <Button icon="save" disabled={loading} onClick={handleOnSave}>
{loading ? 'Guardando...' : 'Guardar Cambios'} {loading ? 'Guardando...' : 'Guardar Cambios'}
</Button> </Button>
</div> </div>}
</div> </div>
</div> </div>
</div> </div>
+12
View File
@@ -23,6 +23,7 @@ export default function EditFormulario({
evento, evento,
handleChange, handleChange,
}: EditFormularioProps) { }: EditFormularioProps) {
const tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [nuevoBanner, setNuevoBanner] = useState<File | null>(null); const [nuevoBanner, setNuevoBanner] = useState<File | null>(null);
@@ -54,6 +55,7 @@ export default function EditFormulario({
fecha_inicio: cuestionario.fecha_inicio, fecha_inicio: cuestionario.fecha_inicio,
fecha_fin: cuestionario.fecha_fin, fecha_fin: cuestionario.fecha_fin,
cupo_maximo: Number(cuestionario.cupo_maximo) || null, cupo_maximo: Number(cuestionario.cupo_maximo) || null,
mensaje_correo: cuestionario.mensaje_correo || null,
} }
); );
@@ -146,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 <SimpleInput
label="Cupo máximo (Si no se requiere, dejar en blanco)" label="Cupo máximo (Si no se requiere, dejar en blanco)"
type="number" type="number"
@@ -213,11 +223,13 @@ export default function EditFormulario({
</div> </div>
{/* Botón de guardar */} {/* Botón de guardar */}
{tipo_usuario === "administrador" && (
<div className="d-flex justify-content-end mt-4"> <div className="d-flex justify-content-end mt-4">
<Button icon="save" disabled={loading} onClick={handleOnSave}> <Button icon="save" disabled={loading} onClick={handleOnSave}>
{loading ? 'Guardando...' : 'Guardar Cambios'} {loading ? 'Guardando...' : 'Guardar Cambios'}
</Button> </Button>
</div> </div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -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>
);
}
@@ -0,0 +1,591 @@
import React, { useState, useEffect } from 'react';
import { useGetApi } from '@/hooks/use-get-api';
import { GetCuestionario } from '@/types/evento';
import SimpleInput from '@/components/input';
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
import axiosInstance from '@/utils/api-config';
import PrefetchAbiertaCorta from './prefetch-abierta-corta';
import Button from '@/components/button';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { getAxiosError } from '@/utils/errors-utils';
import { SubmitResponse } from '@/types/submit';
export type UsuarioDataResponse = {
cuenta: string | null;
nombre: string | null;
apellidos: string | null;
carrera: string | null;
genero: string | null;
rfc?: string | null; // Solo para trabajadores
};
type UsuarioData = {
nombre: string; // alumno o trabajador
apellidos: string; // alumno o trabajador
correo: string; // alumno o trabajador (en los alumnos se usa el id_ncuenta en trabajadores ellos deben escribirlo)
genero: 'M' | 'F'; // alumno o trabajador
carrera: string; // solo para alumnos
rfc?: string; // solo para trabajadores se usa para buscar información de cuenta
};
const confirmativeOption = ['Sí', 'Si', 'Si, claro', 'Claro que sí'];
const negativeOption = ['No', 'No, gracias', 'No, de momento'];
type RespuestaFormulario = Record<string, string | number>; // id_pregunta: respuesta
export default function FormularioRegistro({
id_cuestionario,
handleSubmitFormulario,
}: {
evento: string;
cuestionario: string;
id_evento: number;
id_cuestionario: number;
handleSubmitFormulario: (data: SubmitResponse) => void;
}) {
// ------------------------
// Estados
// ------------------------
const [isComunidad, setIsComunidad] = useState<RadioOption<number>>();
const [respuestas, setRespuestas] = useState<RespuestaFormulario>({});
const [cuentaInfo, setCuentaInfo] = useState<UsuarioData | null>(null);
const [isSending, setIsSending] = useState(false);
const router = useRouter();
// ------------------------
// Carga del cuestionario
// ------------------------
const { data, error } = useGetApi<GetCuestionario>(
`/cuestionario/${id_cuestionario}/formulario`
);
// ------------------------
// Extracción de preguntas clave
// ------------------------
const preguntas = data?.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas.map((pregunta) => pregunta.pregunta)
);
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'
);
// Solución: Agregar estado para controlar si ya se hizo la búsqueda
const [cuentaBuscada, setCuentaBuscada] = useState<string>(''); // Nuevo estado
// ------------------------
// Efecto: buscar información de cuenta
// ------------------------
useEffect(() => {
const cuentaId = preguntaCuenta?.id_pregunta ?? '';
const cuenta = respuestas[cuentaId];
const esCuentaAlumno = preguntaCuenta?.validacion === 'cuenta_alumno';
const esCuentaTrabajador =
preguntaCuenta?.validacion === 'cuenta_trabajador' ||
preguntaCuenta?.validacion === 'rfc';
const puedeBuscar =
cuenta &&
typeof cuenta === 'string' &&
((esCuentaAlumno && cuenta.length === 9) ||
(esCuentaTrabajador && cuenta.length === 10));
const fetchCuentaInfo = async () => {
try {
const endpoint = esCuentaAlumno
? `/alumnos/${cuenta}`
: `/trabajadores/${cuenta}`;
const res = await axiosInstance.get<UsuarioDataResponse>(endpoint);
console.log('Datos de cuenta obtenidos:', res.data);
// Verificar si realmente 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 ? res.data.nombre : '',
apellidos: res.data.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 ? res.data.carrera : '',
});
} else {
// Si no hay datos válidos, establecer como null
setCuentaInfo(null);
}
// Marcar que ya se buscó esta cuenta
setCuentaBuscada(cuenta as string);
} catch (error) {
console.error('Error al obtener datos de cuenta:', error);
setCuentaInfo(null);
setCuentaBuscada(cuenta as string); // También marcar en caso de error
}
};
// Solo buscar si puede buscar Y no se ha buscado ya esta cuenta
if (puedeBuscar && cuentaBuscada !== cuenta) {
fetchCuentaInfo();
} else if (!puedeBuscar) {
setCuentaInfo(null);
setCuentaBuscada(''); // Reset cuando no se puede buscar
}
}, [
respuestas,
preguntaCuenta?.id_pregunta,
preguntaCuenta?.validacion,
cuentaBuscada,
]);
// También resetear cuentaBuscada cuando cambie el número de cuenta
useEffect(() => {
const cuentaId = preguntaCuenta?.id_pregunta ?? '';
const cuenta = respuestas[cuentaId];
// Si la cuenta cambió, resetear el estado de búsqueda
if (cuentaBuscada && cuenta !== cuentaBuscada) {
setCuentaBuscada('');
}
}, [respuestas, preguntaCuenta?.id_pregunta, cuentaBuscada]);
// ------------------------
// Efecto: precargar respuestas si hay cuentaInfo
// ------------------------
useEffect(() => {
if (!cuentaInfo) {
if (data?.cuestionario.secciones) {
const preguntas = data.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas.map((pregunta) => pregunta.pregunta)
);
const idsPrefetch = preguntas
.filter((pregunta) =>
[
'nombre',
'apellidos',
'correo',
'institucion',
'carrera',
'genero',
].includes(pregunta.validacion)
)
.map((pregunta) => pregunta.id_pregunta);
setRespuestas((prev) => {
const nuevas = { ...prev };
idsPrefetch.forEach((id) => {
delete nuevas[id];
});
return nuevas;
});
}
return;
}
const nuevasRespuestas: RespuestaFormulario = {};
if (data?.cuestionario.secciones) {
const preguntas = data.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas.map((pregunta) => pregunta.pregunta)
);
for (const pregunta of preguntas) {
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 'institucion':
nuevasRespuestas[id] = 'FES Acatlán';
break;
case 'carrera':
nuevasRespuestas[id] = cuentaInfo.carrera;
break;
case 'genero':
const generoTexto =
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
const opcion = pregunta.opciones?.find(
(op) =>
op.opcion.opcion.toLowerCase() === generoTexto.toLowerCase()
);
if (opcion) {
nuevasRespuestas[id] = String(opcion.id_opcion);
}
break;
}
}
setRespuestas((prev) => ({
...prev,
...nuevasRespuestas,
}));
}
}, [cuentaInfo, data?.cuestionario.secciones]);
// ------------------------
// Funciones auxiliares
// ------------------------
const actualizarRespuesta = (idPregunta: string, valor: string) => {
setRespuestas((prev) => ({ ...prev, [idPregunta]: valor }));
};
const handleOnSubmit = async () => {
setIsSending(true);
if (!data?.cuestionario.id_cuestionario) {
alert('No se ha cargado correctamente el formulario');
return;
}
const preguntasObligatorias =
data?.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas
.filter((pregunta) => pregunta.pregunta.obligatoria)
.map((pregunta) => pregunta.pregunta)
) || [];
const faltantes = preguntasObligatorias.filter(
(pregunta) =>
respuestas[pregunta.id_pregunta] === undefined ||
respuestas[pregunta.id_pregunta] === '' ||
respuestas[pregunta.id_pregunta] === null
);
const preguntaCorreo = preguntasObligatorias.find(
(pregunta) =>
pregunta.validacion === 'correo' ||
pregunta.validacion === 'correo_institucional'
);
if (faltantes.length > 0) {
toast.error('Por favor responde todas las preguntas obligatorias.');
setIsSending(false);
return;
}
// Buscar un posible correo en las respuestas si no hay cuentaInfo
let correo =
cuentaInfo?.correo === respuestas[preguntaCorreo?.id_pregunta ?? '']
? cuentaInfo?.correo
: respuestas[preguntaCorreo?.id_pregunta ?? ''];
if (!correo) {
// Buscar en las respuestas una que parezca 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(
() => (
<span
className="fs-4 pc-3 py-2"
style={{
maxWidth: '300px',
wordWrap: 'break-word',
whiteSpace: 'normal',
}}
>
{res.data.message.includes('ya ha respondido') ? (
<>
<i className="bi bi-exclamation-circle-fill text-warning me-2"></i>
Respuestas Enviadas Con Éxito
</>
) : (
<>
<i className="bi bi-check-circle-fill text-success me-2"></i>
{res.data.message || '¡Formulario enviado!'}
</>
)}
</span>
),
{
duration: 6000,
position: 'bottom-center',
style: {
maxWidth: '425px',
},
}
);
handleSubmitFormulario(res.data);
} catch (error) {
console.log(getAxiosError(error));
toast.error('Hubo un error al enviar el formulario', {
duration: 5000,
});
router.push('/');
}
};
// ------------------------
// Condición para mostrar el formulario
// ------------------------
const mostrarFormularioRestante =
!preguntaComunidad || // Si no hay pregunta de comunidad, mostrar el formulario
(isComunidad &&
confirmativeOption.some((opt) =>
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
) &&
!!cuentaInfo) ||
(isComunidad &&
negativeOption.some((opt) =>
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
));
if (error) {
console.error('Error al cargar el cuestionario:', error);
return <div>Error al cargar el cuestionario</div>;
}
return (
<div>
{preguntaComunidad && (
<div className="my-4">
<label
className={`form-label ${
preguntaComunidad.obligatoria ? 'required' : ''
}`}
>
{preguntaComunidad.pregunta}
</label>
<RadioOptionGroup
name="comunidad"
options={preguntaComunidad.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}))}
selectedValue={isComunidad?.value}
onChange={(opt) => {
console.log('Opción comunidad seleccionada:', opt);
setIsComunidad(opt);
actualizarRespuesta(
preguntaComunidad.id_pregunta.toString(),
String(opt.value)
);
}}
/>
</div>
)}
{isComunidad &&
confirmativeOption.some((opt) =>
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
) &&
preguntaCuenta && (
<div className="my-4">
<label
className={`form-label ${
preguntaCuenta.obligatoria ? 'required' : ''
}`}
>
{preguntaCuenta.pregunta}
</label>
<SimpleInput
type="text"
name="cuenta"
placeholder={
preguntaCuenta.validacion === 'cuenta_alumno'
? 'Ingrese su cuenta'
: preguntaCuenta?.validacion === 'rfc'
? 'Ingrese su RFC sin homoclave'
: preguntaCuenta?.validacion === 'cuenta_trabajador'
? 'Ingrese su numero de trabajador'
: 'Ingrese su ' + preguntaCuenta.pregunta
}
maxLength={10}
value={respuestas[preguntaCuenta.id_pregunta] || ''}
onChange={(e) =>
setRespuestas((prev) => ({
...prev,
[preguntaCuenta.id_pregunta]: e.target.value,
}))
}
/>
</div>
)}
{mostrarFormularioRestante &&
data?.cuestionario.secciones.map((seccion, i) => (
<div key={i}>
{seccion.preguntas.map((pregunta) => {
// Evitar renderizar las preguntas ya manejadas
if (
pregunta.pregunta.id_pregunta ===
preguntaComunidad?.id_pregunta ||
pregunta.pregunta.id_pregunta === preguntaCuenta?.id_pregunta
)
return null;
if (
pregunta.pregunta.tipo_pregunta.tipo_pregunta ===
'Abierta (Respuesta corta)'
) {
return (
<PrefetchAbiertaCorta
key={pregunta.pregunta.id_pregunta}
obligatorio={pregunta.pregunta.obligatoria}
idPregunta={pregunta.pregunta.id_pregunta}
enunciado={pregunta.pregunta.pregunta}
validacion={pregunta.pregunta.validacion}
respuestas={respuestas}
cuentaInfo={cuentaInfo}
isComunidad={isComunidad?.label}
onChange={actualizarRespuesta}
preguntaComunidad={preguntaComunidad?.validacion ?? ''}
/>
);
}
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={respuestas[pregunta.pregunta.id_pregunta] || ''}
onChange={(e) =>
setRespuestas((prev) => ({
...prev,
[pregunta.pregunta.id_pregunta]: e.target.value,
}))
}
/>
</div>
);
}
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,
}));
let respuestaActual =
respuestas[`${pregunta.pregunta.id_pregunta}`];
if (
(!preguntaComunidad ||
(confirmativeOption.includes(isComunidad?.label || '') &&
cuentaInfo)) &&
respuestaActual === undefined // solo si no ha respondido
) {
if (pregunta.pregunta.validacion === 'genero' && cuentaInfo) {
const generoTexto =
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
const opcionGenero = pregunta.pregunta.opciones.find(
(op) =>
op.opcion.opcion.toLowerCase() ===
generoTexto.toLowerCase()
);
if (opcionGenero) {
respuestaActual = String(opcionGenero.id_opcion);
}
}
}
return (
<div key={pregunta.pregunta.id_pregunta}>
<label
className={`form-label ${
pregunta.pregunta.obligatoria ? 'required' : ''
}`}
>
{pregunta.pregunta.pregunta}
</label>
<RadioOptionGroup
name={`pregunta_${pregunta.pregunta.id_pregunta}`}
options={opciones}
selectedValue={
respuestaActual ? Number(respuestaActual) : undefined
}
onChange={(opt) => {
actualizarRespuesta(
`${pregunta.pregunta.id_pregunta}`,
String(opt.value)
);
}}
/>
</div>
);
}
return (
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label
className={`form-label ${
pregunta.pregunta.obligatoria ? 'required' : ''
}`}
>
{pregunta.pregunta.pregunta}
</label>
</div>
);
})}
</div>
))}
<Button
className="btn btn-primary"
onClick={handleOnSubmit}
disabled={!mostrarFormularioRestante || isSending}
>
{isSending ? 'Enviando...' : 'Enviar Formulario'}
</Button>
</div>
);
}
+125 -439
View File
@@ -1,15 +1,10 @@
import React, { useState, useEffect } from 'react'; import React from 'react';
import { useGetApi } from '@/hooks/use-get-api'; import { SubmitResponse } from '@/types/submit';
import { GetCuestionario } from '@/types/evento';
import SimpleInput from '@/components/input'; import SimpleInput from '@/components/input';
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group'; import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
import axiosInstance from '@/utils/api-config';
import PrefetchAbiertaCorta from './prefetch-abierta-corta';
import Button from '@/components/button'; import Button from '@/components/button';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation'; import { useFormulario } from '@/context/formulario';
import { getAxiosError } from '@/utils/errors-utils';
import { SubmitResponse } from '@/types/submit';
export type UsuarioDataResponse = { export type UsuarioDataResponse = {
cuenta: string | null; cuenta: string | null;
@@ -17,25 +12,10 @@ export type UsuarioDataResponse = {
apellidos: string | null; apellidos: string | null;
carrera: string | null; carrera: string | null;
genero: string | null; genero: string | null;
rfc?: string | null; // Solo para trabajadores rfc?: string | null;
}; };
type UsuarioData = {
nombre: string; // alumno o trabajador
apellidos: string; // alumno o trabajador
correo: string; // alumno o trabajador (en los alumnos se usa el id_ncuenta en trabajadores ellos deben escribirlo)
genero: 'M' | 'F'; // alumno o trabajador
carrera: string; // solo para alumnos
rfc?: string; // solo para trabajadores se usa para buscar información de cuenta
};
const confirmativeOption = ['Sí', 'Si', 'Si, claro', 'Claro que sí'];
const negativeOption = ['No', 'No, gracias', 'No, de momento'];
type RespuestaFormulario = Record<string, string | number>; // id_pregunta: respuesta
export default function FormularioRegistro({ export default function FormularioRegistro({
id_cuestionario,
handleSubmitFormulario, handleSubmitFormulario,
}: { }: {
evento: string; evento: string;
@@ -44,324 +24,70 @@ export default function FormularioRegistro({
id_cuestionario: number; id_cuestionario: number;
handleSubmitFormulario: (data: SubmitResponse) => void; handleSubmitFormulario: (data: SubmitResponse) => void;
}) { }) {
// ------------------------ const {
// Estados state,
// ------------------------ preguntaComunidad,
const [isComunidad, setIsComunidad] = useState<RadioOption<number>>(); preguntaCuenta,
const [respuestas, setRespuestas] = useState<RespuestaFormulario>({}); esComunidadSi,
const [cuentaInfo, setCuentaInfo] = useState<UsuarioData | null>(null); esExclusivo,
const [isSending, setIsSending] = useState(false); mostrarFormularioCompleto,
const router = useRouter(); setRespuesta,
submitFormulario,
} = useFormulario();
// ------------------------ const { data, loadingData, errorData, respuestas, isSending, errorBusqueda, loadingBusqueda } = state;
// Carga del cuestionario
// ------------------------
const { data, error } = useGetApi<GetCuestionario>(
`/cuestionario/${id_cuestionario}/formulario`
);
// ------------------------
// Extracción de preguntas clave
// ------------------------
const preguntas = data?.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas.map((pregunta) => pregunta.pregunta)
);
const preguntaComunidad = preguntas?.find( const comunidadSeleccionada = preguntaComunidad
(pregunta) => ? respuestas[preguntaComunidad.id_pregunta]
pregunta.validacion === 'comunidad_alumno' || : null;
pregunta.validacion === 'comunidad_trabajador'
);
const preguntaCuenta = preguntas?.find(
(pregunta) =>
pregunta.validacion === 'cuenta_alumno' ||
pregunta.validacion === 'cuenta_trabajador' ||
pregunta.validacion === 'rfc'
);
// ------------------------
// Efecto: buscar información de cuenta
// ------------------------
useEffect(() => {
const cuentaId = preguntaCuenta?.id_pregunta ?? '';
const cuenta = respuestas[cuentaId];
const esCuentaAlumno = preguntaCuenta?.validacion === 'cuenta_alumno';
const esCuentaTrabajador =
preguntaCuenta?.validacion === 'cuenta_trabajador' ||
preguntaCuenta?.validacion === 'rfc';
const puedeBuscar =
cuenta &&
typeof cuenta === 'string' &&
((esCuentaAlumno && cuenta.length === 9) ||
(esCuentaTrabajador && cuenta.length === 10));
const fetchCuentaInfo = async () => {
try {
const endpoint = esCuentaAlumno
? `/alumnos/${cuenta}`
: `/trabajadores/${cuenta}`;
const res = await axiosInstance.get<UsuarioDataResponse>(endpoint);
console.log('Datos de cuenta obtenidos:', res.data);
setCuentaInfo({
nombre: res.data.nombre ? res.data.nombre : '',
apellidos: res.data.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 ? res.data.carrera : '',
});
// Usar datos fake para pruebas
/* setCuentaInfo({
nombre: 'Juan',
apellidos: 'Pérez',
correo: 'juan.perez@pcpuma.acatlan.unam.mx',
genero: 'M',
carrera: 'Ingeniería en Computación',
}); */
} catch (error) {
console.error('Error al obtener datos de cuenta:', error);
setCuentaInfo(null);
}
};
if (puedeBuscar) {
fetchCuentaInfo();
} else {
setCuentaInfo(null);
}
}, [respuestas, preguntaCuenta?.id_pregunta, preguntaCuenta?.validacion]);
// ------------------------
// Efecto: precargar respuestas si hay cuentaInfo
// ------------------------
useEffect(() => {
if (!cuentaInfo) {
if (data?.cuestionario.secciones) {
const preguntas = data.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas.map((pregunta) => pregunta.pregunta)
);
const idsPrefetch = preguntas
.filter((pregunta) =>
[
'nombre',
'apellidos',
'correo',
'institucion',
'carrera',
'genero',
].includes(pregunta.validacion)
)
.map((pregunta) => pregunta.id_pregunta);
setRespuestas((prev) => {
const nuevas = { ...prev };
idsPrefetch.forEach((id) => {
delete nuevas[id];
});
return nuevas;
});
}
return;
}
const nuevasRespuestas: RespuestaFormulario = {};
if (data?.cuestionario.secciones) {
const preguntas = data.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas.map((pregunta) => pregunta.pregunta)
);
for (const pregunta of preguntas) {
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 'institucion':
nuevasRespuestas[id] = 'FES Acatlán';
break;
case 'carrera':
nuevasRespuestas[id] = cuentaInfo.carrera;
break;
case 'genero':
const generoTexto =
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
const opcion = pregunta.opciones?.find(
(op) =>
op.opcion.opcion.toLowerCase() === generoTexto.toLowerCase()
);
if (opcion) {
nuevasRespuestas[id] = String(opcion.id_opcion);
}
break;
}
}
setRespuestas((prev) => ({
...prev,
...nuevasRespuestas,
}));
}
}, [cuentaInfo, data?.cuestionario.secciones]);
// ------------------------
// Funciones auxiliares
// ------------------------
const actualizarRespuesta = (idPregunta: string, valor: string) => {
setRespuestas((prev) => ({ ...prev, [idPregunta]: valor }));
};
const handleOnSubmit = async () => { const handleOnSubmit = async () => {
setIsSending(true);
if (!data?.cuestionario.id_cuestionario) { if (!data?.cuestionario.id_cuestionario) {
alert('No se ha cargado correctamente el formulario'); toast.error('No se ha cargado correctamente el formulario');
return; return;
} }
const preguntasObligatorias = const preguntasObligatorias = data.cuestionario.secciones.flatMap((seccion) =>
data?.cuestionario.secciones.flatMap((seccion) =>
seccion.preguntas seccion.preguntas
.filter((pregunta) => pregunta.pregunta.obligatoria) .filter((p) => p.pregunta.obligatoria)
.map((pregunta) => pregunta.pregunta) .map((p) => p.pregunta)
) || [];
const faltantes = preguntasObligatorias.filter(
(pregunta) =>
respuestas[pregunta.id_pregunta] === undefined ||
respuestas[pregunta.id_pregunta] === '' ||
respuestas[pregunta.id_pregunta] === null
); );
const preguntaCorreo = preguntasObligatorias.find( const faltantes = preguntasObligatorias.filter(
(pregunta) => (p) =>
pregunta.validacion === 'correo' || respuestas[p.id_pregunta] === undefined ||
pregunta.validacion === 'correo_institucional' respuestas[p.id_pregunta] === '' ||
respuestas[p.id_pregunta] === null
); );
if (faltantes.length > 0) { if (faltantes.length > 0) {
toast.error('Por favor responde todas las preguntas obligatorias.'); toast.error('Por favor responde todas las preguntas obligatorias.');
setIsSending(false);
return; return;
} }
// Buscar un posible correo en las respuestas si no hay cuentaInfo
let correo =
cuentaInfo?.correo === respuestas[preguntaCorreo?.id_pregunta ?? '']
? cuentaInfo?.correo
: respuestas[preguntaCorreo?.id_pregunta ?? ''];
if (!correo) {
// Buscar en las respuestas una que parezca 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 { try {
const res = await axiosInstance.post<SubmitResponse>( const res = await submitFormulario(data.cuestionario.id_cuestionario);
'/cuestionario-respondido/submit', toast.success(res.message || '¡Formulario enviado exitosamente!');
payload handleSubmitFormulario(res);
); } catch {
toast( toast.error('Hubo un error al enviar el formulario');
() => (
<span
className="fs-4 pc-3 py-2"
style={{
maxWidth: '300px',
wordWrap: 'break-word',
whiteSpace: 'normal',
}}
>
{res.data.message.includes('ya ha respondido') ? (
<>
<i className="bi bi-exclamation-circle-fill text-warning me-2"></i>
Respuestas Enviadas Con Éxito
</>
) : (
<>
<i className="bi bi-check-circle-fill text-success me-2"></i>
{res.data.message || '¡Formulario enviado!'}
</>
)}
</span>
),
{
duration: 6000,
position: 'bottom-center',
style: {
maxWidth: '425px',
},
}
);
handleSubmitFormulario(res.data);
} catch (error) {
console.log(getAxiosError(error));
toast.error('Hubo un error al enviar el formulario', {
duration: 5000,
});
router.push('/');
} }
}; };
// ------------------------ if (errorData) {
// Condición para mostrar el formulario
// ------------------------
const mostrarFormularioRestante =
!preguntaComunidad || // Si no hay pregunta de comunidad, mostrar el formulario
(isComunidad &&
confirmativeOption.some((opt) =>
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
) &&
!!cuentaInfo) ||
(isComunidad &&
negativeOption.some((opt) =>
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
));
if (error) {
console.error('Error al cargar el cuestionario:', error);
return <div>Error al cargar el cuestionario</div>; return <div>Error al cargar el cuestionario</div>;
} }
if (loadingData || !data) {
return <div>Cargando...</div>;
}
return ( return (
<div> <div>
{/* Pregunta de comunidad */}
{preguntaComunidad && ( {preguntaComunidad && (
<div className="my-4"> <div className="my-4">
<label <label className={`form-label ${preguntaComunidad.obligatoria ? 'required' : ''}`}>
className={`form-label ${
preguntaComunidad.obligatoria ? 'required' : ''
}`}
>
{preguntaComunidad.pregunta} {preguntaComunidad.pregunta}
</label> </label>
<RadioOptionGroup <RadioOptionGroup
@@ -370,29 +96,18 @@ export default function FormularioRegistro({
label: op.opcion.opcion, label: op.opcion.opcion,
value: op.id_opcion, value: op.id_opcion,
}))} }))}
selectedValue={isComunidad?.value} selectedValue={comunidadSeleccionada ? Number(comunidadSeleccionada) : undefined}
onChange={(opt) => { onChange={(opt) =>
console.log('Opción comunidad seleccionada:', opt); setRespuesta(preguntaComunidad.id_pregunta.toString(), String(opt.value))
setIsComunidad(opt); }
actualizarRespuesta(
preguntaComunidad.id_pregunta.toString(),
String(opt.value)
);
}}
/> />
</div> </div>
)} )}
{isComunidad &&
confirmativeOption.some((opt) => {/* Pregunta de cuenta (si respondió "Sí" a comunidad, o en cuestionario exclusivo) */}
isComunidad.label?.toLowerCase().includes(opt.toLowerCase()) {(esComunidadSi || esExclusivo) && preguntaCuenta && (
) &&
preguntaCuenta && (
<div className="my-4"> <div className="my-4">
<label <label className={`form-label ${preguntaCuenta.obligatoria ? 'required' : ''}`}>
className={`form-label ${
preguntaCuenta.obligatoria ? 'required' : ''
}`}
>
{preguntaCuenta.pregunta} {preguntaCuenta.pregunta}
</label> </label>
<SimpleInput <SimpleInput
@@ -400,162 +115,133 @@ export default function FormularioRegistro({
name="cuenta" name="cuenta"
placeholder={ placeholder={
preguntaCuenta.validacion === 'cuenta_alumno' preguntaCuenta.validacion === 'cuenta_alumno'
? 'Ingrese su cuenta' ? 'Ingrese su número de cuenta (9 dígitos)'
: preguntaCuenta?.validacion === 'rfc' : preguntaCuenta.validacion === 'cuenta_trabajador'
? 'Ingrese su RFC sin homoclave' ? 'Ingrese su número de trabajador (6 dígitos)'
: preguntaCuenta?.validacion === 'cuenta_trabajador' : preguntaCuenta.validacion === 'rfc'
? 'Ingrese su numero de trabajador' ? 'Ingrese su RFC sin homoclave (10 caracteres)'
: 'Ingrese su ' + preguntaCuenta.pregunta : `Ingrese su ${preguntaCuenta.pregunta}`
}
maxLength={
preguntaCuenta.validacion === 'rfc'
? 10
: preguntaCuenta.validacion === 'cuenta_alumno'
? 9
: 6
} }
maxLength={10}
value={respuestas[preguntaCuenta.id_pregunta] || ''} value={respuestas[preguntaCuenta.id_pregunta] || ''}
onChange={(e) => onChange={(e) =>
setRespuestas((prev) => ({ setRespuesta(preguntaCuenta.id_pregunta.toString(), e.target.value)
...prev, }
[preguntaCuenta.id_pregunta]: 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) => {
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> </div>
)}
{mostrarFormularioRestante &&
data?.cuestionario.secciones.map((seccion, i) => (
<div key={i}>
{seccion.preguntas.map((pregunta) => {
// Evitar renderizar las preguntas ya manejadas
if (
pregunta.pregunta.id_pregunta ===
preguntaComunidad?.id_pregunta ||
pregunta.pregunta.id_pregunta === preguntaCuenta?.id_pregunta
)
return null;
if (
pregunta.pregunta.tipo_pregunta.tipo_pregunta ===
'Abierta (Respuesta corta)'
) {
return (
<PrefetchAbiertaCorta
key={pregunta.pregunta.id_pregunta}
obligatorio={pregunta.pregunta.obligatoria}
idPregunta={pregunta.pregunta.id_pregunta}
enunciado={pregunta.pregunta.pregunta}
validacion={pregunta.pregunta.validacion}
respuestas={respuestas}
cuentaInfo={cuentaInfo}
isComunidad={isComunidad?.label}
onChange={actualizarRespuesta}
preguntaComunidad={preguntaComunidad?.validacion ?? ''}
/>
); );
} }
if ( if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Abierta (Parrafo)') {
pregunta.pregunta.tipo_pregunta.tipo_pregunta ===
'Abierta (Parrafo)'
) {
return ( return (
<div key={pregunta.pregunta.id_pregunta} className="my-4"> <div key={pregunta.pregunta.id_pregunta} className="my-4">
<label <label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
className={`form-label ${
pregunta.pregunta.obligatoria ? 'required' : ''
}`}
>
{pregunta.pregunta.pregunta} {pregunta.pregunta.pregunta}
</label> </label>
<SimpleInput <SimpleInput
type="textarea" type="textarea"
name={`respuesta_${pregunta.pregunta.id_pregunta}`} name={`respuesta_${pregunta.pregunta.id_pregunta}`}
placeholder="Ingrese una respuesta" placeholder="Ingrese una respuesta"
value={respuestas[pregunta.pregunta.id_pregunta] || ''} value={valor}
onChange={(e) => onChange={(e) =>
setRespuestas((prev) => ({ setRespuesta(pregunta.pregunta.id_pregunta.toString(), e.target.value)
...prev,
[pregunta.pregunta.id_pregunta]: e.target.value,
}))
} }
/> />
</div> </div>
); );
} }
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Cerrada') { if (
const opciones: RadioOption<number>[] = pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Cerrada' ||
pregunta.pregunta.opciones.map((op) => ({ pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Multiple'
) {
const opciones: RadioOption<number>[] = pregunta.pregunta.opciones.map((op) => ({
label: op.opcion.opcion, label: op.opcion.opcion,
value: op.id_opcion, value: op.id_opcion,
})); }));
let respuestaActual =
respuestas[`${pregunta.pregunta.id_pregunta}`];
if (
(!preguntaComunidad ||
(confirmativeOption.includes(isComunidad?.label || '') &&
cuentaInfo)) &&
respuestaActual === undefined // solo si no ha respondido
) {
if (pregunta.pregunta.validacion === 'genero' && cuentaInfo) {
const generoTexto =
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
const opcionGenero = pregunta.pregunta.opciones.find(
(op) =>
op.opcion.opcion.toLowerCase() ===
generoTexto.toLowerCase()
);
if (opcionGenero) {
respuestaActual = String(opcionGenero.id_opcion);
}
}
}
return ( return (
<div key={pregunta.pregunta.id_pregunta}> <div key={pregunta.pregunta.id_pregunta} className="my-4">
<label <label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
className={`form-label ${
pregunta.pregunta.obligatoria ? 'required' : ''
}`}
>
{pregunta.pregunta.pregunta} {pregunta.pregunta.pregunta}
</label> </label>
<RadioOptionGroup <RadioOptionGroup
name={`pregunta_${pregunta.pregunta.id_pregunta}`} name={`pregunta_${pregunta.pregunta.id_pregunta}`}
options={opciones} options={opciones}
selectedValue={ selectedValue={valor ? Number(valor) : undefined}
respuestaActual ? Number(respuestaActual) : undefined onChange={(opt) =>
setRespuesta(pregunta.pregunta.id_pregunta.toString(), String(opt.value))
} }
onChange={(opt) => {
actualizarRespuesta(
`${pregunta.pregunta.id_pregunta}`,
String(opt.value)
);
}}
/> />
</div> </div>
); );
} }
return ( return null;
<div key={pregunta.pregunta.id_pregunta} className="my-4">
<label
className={`form-label ${
pregunta.pregunta.obligatoria ? 'required' : ''
}`}
>
{pregunta.pregunta.pregunta}
</label>
</div>
);
})} })}
</div> </div>
))} ))}
{/* Botón de envío */}
{mostrarFormularioCompleto && (
<Button <Button
className="btn btn-primary" className="btn btn-primary"
onClick={handleOnSubmit} onClick={handleOnSubmit}
disabled={!mostrarFormularioRestante || isSending} disabled={isSending}
> >
{isSending ? 'Enviando...' : 'Enviar Formulario'} {isSending ? 'Enviando...' : 'Enviar Formulario'}
</Button> </Button>
)}
</div> </div>
); );
} }
+19 -120
View File
@@ -4,10 +4,11 @@ import { useGetApi } from '@/hooks/use-get-api';
import Image from 'next/image'; import Image from 'next/image';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import FormularioRegistro from '../formulario/formulario-registro'; import FormularioRegistro from '../formulario/formulario-registro';
import FormularioExito from '../formulario/formulario-exito';
import { GetEventoWithCuestionario } from '@/types/evento'; import { GetEventoWithCuestionario } from '@/types/evento';
import { formatearRangoFechas } from '@/utils/date-utils'; import { formatearRangoFechas } from '@/utils/date-utils';
import { SubmitResponse } from '@/types/submit'; import { SubmitResponse } from '@/types/submit';
import Link from 'next/link'; import { FormularioProvider } from '@/context/formulario';
export default function FormularioPage({ export default function FormularioPage({
id_evento, id_evento,
@@ -64,121 +65,12 @@ export default function FormularioPage({
// Si hay una respuesta exitosa, mostrar la vista de éxito // Si hay una respuesta exitosa, mostrar la vista de éxito
if (response && response.success) { if (response && response.success) {
return ( return (
<div className="container my-5"> <FormularioExito
<div className="row justify-content-center"> response={response}
<div className="col-12 col-lg-10 col-xl-8"> qrImageUrl={qrImageUrl}
<div className="card shadow-sm"> nombreEvento={data?.nombre_evento}
<div className="card-body"> nombreFormulario={data?.cuestionario.nombre_form}
<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>
); );
} }
@@ -295,7 +187,8 @@ export default function FormularioPage({
className="bi bi-calendar-date me-1 text-primary" className="bi bi-calendar-date me-1 text-primary"
aria-hidden="true" aria-hidden="true"
></i> ></i>
{formatearRangoFechas(data.fecha_inicio, data.fecha_fin)} {formatearRangoFechas(data.fecha_inicio, data.fecha_fin)}{' '}
horas.
</p> </p>
)} )}
</div> </div>
@@ -340,7 +233,8 @@ export default function FormularioPage({
{formatearRangoFechas( {formatearRangoFechas(
data.cuestionario.fecha_inicio, data.cuestionario.fecha_inicio,
data.cuestionario.fecha_fin data.cuestionario.fecha_fin
)} )}{' '}
horas.
</p> </p>
)} )}
</div> </div>
@@ -353,13 +247,18 @@ export default function FormularioPage({
<div className="col-lg-7"> <div className="col-lg-7">
<div className="ps-lg-4"> <div className="ps-lg-4">
{data && ( {data && (
<FormularioProvider
id_evento={data.id_evento}
id_cuestionario={data.cuestionario.id_cuestionario}
>
<FormularioRegistro <FormularioRegistro
evento={data.nombre_evento} evento={data.nombre_evento}
cuestionario={data?.cuestionario.nombre_form} cuestionario={data.cuestionario.nombre_form}
id_evento={data?.id_evento} id_evento={data.id_evento}
id_cuestionario={data?.cuestionario.id_cuestionario} id_cuestionario={data.cuestionario.id_cuestionario}
handleSubmitFormulario={onSubmitFormulario} handleSubmitFormulario={onSubmitFormulario}
/> />
</FormularioProvider>
)} )}
</div> </div>
</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';
+127 -11
View File
@@ -1,7 +1,6 @@
import { FormularioCreacion } from '@/types/create-formulario'; import { FormularioCreacion } from '@/types/create-formulario';
interface Plantilla { interface Plantilla {
imagen: string;
nombre: string; nombre: string;
id: string; id: string;
datos: FormularioCreacion; datos: FormularioCreacion;
@@ -9,13 +8,12 @@ interface Plantilla {
export const plantillasDisponibles: Plantilla[] = [ export const plantillasDisponibles: Plantilla[] = [
{ {
imagen: '/plantilla_trabajadores.png', nombre: 'Registro General (con prellenado para Trabajadores)',
nombre: 'Registro Comunidad Trabajador',
id: 'registro-comunidad-estudiantil', id: 'registro-comunidad-estudiantil',
datos: { datos: {
nombre_form: 'Registro', nombre_form: 'Registro',
descripcion: descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.', 'Con este formulario estarás reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
fecha_inicio: '2025-08-01T08:00:00', fecha_inicio: '2025-08-01T08:00:00',
fecha_fin: '2025-08-10T23:59:59', fecha_fin: '2025-08-10T23:59:59',
id_tipo_cuestionario: 2, id_tipo_cuestionario: 2,
@@ -79,13 +77,12 @@ export const plantillasDisponibles: Plantilla[] = [
}, },
}, },
{ {
imagen: '/plantilla_alumnos.png', nombre: 'Registro General (con prellenado para Alumnos)',
nombre: 'Registro Comunidad Alumnos',
id: 'registro-comunidad-alumnos', id: 'registro-comunidad-alumnos',
datos: { datos: {
nombre_form: 'Registro', nombre_form: 'Registro',
descripcion: descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.', 'Con este formulario estarás reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
fecha_inicio: '2025-08-01T08:00:00', fecha_inicio: '2025-08-01T08:00:00',
fecha_fin: '2025-08-10T23:59:59', fecha_fin: '2025-08-10T23:59:59',
id_tipo_cuestionario: 1, id_tipo_cuestionario: 1,
@@ -155,13 +152,12 @@ export const plantillasDisponibles: Plantilla[] = [
}, },
}, },
{ {
imagen: '/plantilla_general.png',
nombre: 'Registro General', nombre: 'Registro General',
id: 'registro-general', id: 'registro-general',
datos: { datos: {
nombre_form: 'Registro Para Asistencia General', nombre_form: 'Registro Para Asistencia General',
descripcion: descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.', 'Con este formulario estarás reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
fecha_inicio: '2025-08-01T08:00:00', fecha_inicio: '2025-08-01T08:00:00',
fecha_fin: '2025-08-10T23:59:59', fecha_fin: '2025-08-10T23:59:59',
id_tipo_cuestionario: 1, id_tipo_cuestionario: 1,
@@ -172,7 +168,7 @@ export const plantillasDisponibles: Plantilla[] = [
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.', 'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [ preguntas: [
{ {
titulo: 'Numero de cuenta', titulo: 'Número de cuenta / trabajador',
tipo: 'Abierta (Respuesta corta)', tipo: 'Abierta (Respuesta corta)',
obligatoria: false, obligatoria: false,
}, },
@@ -198,7 +194,127 @@ export const plantillasDisponibles: Plantilla[] = [
titulo: 'Tipo de Asistente', titulo: 'Tipo de Asistente',
tipo: 'Cerrada', tipo: 'Cerrada',
obligatoria: true, obligatoria: true,
opciones: [{ valor: 'Alumno' }, { valor: 'Profesor' }], opciones: [
{ valor: 'Alumno' },
{ valor: 'Profesor' },
{ valor: 'Trabajador' },
],
},
],
},
],
},
},
{
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' },
],
}, },
], ],
}, },
+1
View File
@@ -29,6 +29,7 @@ export interface FormularioCreacion {
id_tipo_cuestionario: number; id_tipo_cuestionario: number;
id_tipo_evento?: number; id_tipo_evento?: number;
cupo_maximo?: number; // Opcional, si no se requiere un cupo máximo cupo_maximo?: number; // Opcional, si no se requiere un cupo máximo
mensaje_correo?: string;
secciones: SeccionFormulario[]; secciones: SeccionFormulario[];
} }
+1
View File
@@ -15,6 +15,7 @@ export interface GetCuestionario {
fecha_fin: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss' fecha_fin: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
fecha_inicio: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss' fecha_inicio: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
id_tipo_cuestionario: number; id_tipo_cuestionario: number;
mensaje_correo?: string | null;
} }
export interface GetCuestionarioWithEvento extends GetCuestionario { export interface GetCuestionarioWithEvento extends GetCuestionario {