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

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2025-06-24 10:32:28 -06:00
54 changed files with 5341 additions and 1757 deletions
+14 -2
View File
@@ -1,7 +1,19 @@
import type { NextConfig } from "next";
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '4200',
},
{
protocol: 'https',
hostname: 'venus.acatlan.unam.mx'
}
],
},
};
export default nextConfig;
+2365 -144
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -11,15 +11,17 @@
"lint": "next lint"
},
"dependencies": {
"@uiw/react-md-editor": "^4.0.7",
"@yudiel/react-qr-scanner": "^2.2.1",
"axios": "^1.8.4",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"js-cookie": "^3.0.5",
"next": "15.2.4",
"next": "15.3.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.5.2",
"react-markdown": "^10.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,161 @@
'use client';
import React, { useState } from 'react';
import { CreateEventoType } from '@/types/create-evento';
import { FormularioCreacion } from '@/types/create-formulario';
import CreateEvento from '@/containers/create-evento';
import { plantillasDisponibles } from '@/data/plantillas';
import Image from 'next/image';
import CreateFormulario from '@/containers/create-formulario';
import Button from '@/components/button';
import axiosInstance from '@/utils/api-config';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { getAxiosError } from '@/utils/errors-utils';
export default function Page() {
const [eventoBanner, setEventoBanner] = useState<File | null>(null);
const [evento, setEvento] = useState<CreateEventoType>({
tipo_evento: '',
nombre_evento: '',
descripcion_evento: '',
fecha_inicio: new Date(),
fecha_fin: new Date(),
});
const router = useRouter();
const [datosFormulario, setDatosFormulario] =
useState<FormularioCreacion | null>(null);
const handleChange = (
field: keyof CreateEventoType,
value: string | Date
) => {
setEvento((prev) => ({
...prev,
[field]: value,
}));
};
const handleBannerChange = (file: File | null) => {
setEventoBanner(file);
};
const handleSeleccionarPlantilla = (plantillaId: string) => {
const seleccionada = plantillasDisponibles.find(
(p) => p.id === plantillaId
);
if (seleccionada) {
setDatosFormulario(seleccionada.datos);
console.log('Plantilla seleccionada:', seleccionada);
}
};
const handleCrearEventoYFormulario = async () => {
try {
const createEventoWithCuestionario = await axiosInstance.post('/cuestionario/withEvento', {
evento: evento,
cuestionario: datosFormulario,
});
if (createEventoWithCuestionario) {
const formData = new FormData();
if (eventoBanner) {
formData.append('banner', eventoBanner);
const bannercreated = await axiosInstance.post(
`/evento/${createEventoWithCuestionario.data.id_evento}/banner`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
console.log('Banner creado:', bannercreated.data);
}
}
console.log('Evento y formulario creados:', createEventoWithCuestionario.data);
toast.success('Evento y formulario creados exitosamente');
router.push(`/administrador`);
} catch (error) {
const msg = getAxiosError(error);
toast.error(
`Error al crear el evento o formulario: ${msg.message || 'Error desconocido'}`
);
console.error('Error al crear evento y formulario:', error);
}
};
return (
<div className="container">
<div className="my-4">
<h1>Crear evento y formulario de registro</h1>
<p>
En esta pagina podras crear un evento y un primer formulario de
registro, posteriormente podras crear mas formularios relacionados a
este evento.
</p>
</div>
<CreateEvento
evento={evento}
handleChange={handleChange}
handleBannerChange={handleBannerChange}
/>
{!datosFormulario && (
<div className="border p-3 rounded my-4">
<h2 className="h5 fw-bold mb-2">
Selecciona una plantilla para el formulario:
</h2>
<p className="text-secondary small lh-sm">
Elige una de las plantillas disponibles para crear tu formulario de
registro.
<br />
Estas plantillas están diseñadas para garantizar la correcta
integración con el sistema, permitiendo que los datos se obtengan y
se prellenan automáticamente de forma precisa.
<br />
<span className="text-danger fw-semibold">
Para asegurar el funcionamiento óptimo del prellenado, modifica la
estructura solo si es necesario.
</span>
</p>
<div className="d-flex gap-4 flex-wrap">
{plantillasDisponibles.map((plantilla) => (
<div
className="mb-3 cursor-pointer"
key={plantilla.id}
onClick={() => handleSeleccionarPlantilla(plantilla.id)}
>
<div className="text-center">
<Image
src={plantilla.imagen}
width={200}
height={200}
alt={`Plantilla de formulario ${plantilla.nombre}`}
className="img-fluid rounded shadow-sm"
/>
</div>
</div>
))}
</div>
</div>
)}
{datosFormulario && (
<CreateFormulario
formulario={datosFormulario}
onChange={(nuevo) => setDatosFormulario({ ...nuevo })}
/>
)}
<Button className="mb-4" onClick={handleCrearEventoYFormulario}>
Crear Evento y Formulario
</Button>
</div>
);
}
@@ -1,586 +0,0 @@
'use client';
import React, { useState } from 'react';
import Pagination from '@/components/pagination';
import Image from 'next/image';
import axiosInstance from '@/utils/api-config';
import Select from '@/components/select';
import Input from '@/components/input';
import Button from '@/components/button';
import FloatingInput from '@/components/floating-input';
import { plantillasDisponibles } from '@/data/plantillas';
import {
FormularioCreacion,
PreguntaFormulario,
SeccionFormulario,
} from '@/types/crear-formulario';
import toast from 'react-hot-toast';
export default function AdminFormPage() {
const [nombreFormulario, setNombreFormulario] = useState('');
const [descripcion, setDescripcion] = useState('');
const [secciones, setSecciones] = useState<SeccionFormulario[]>([]);
const [paginaActual, setPaginaActual] = useState(1);
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
const [tipoCuestionario, setTipoCuestionario] = useState<number | null>(null);
const [fechaInicio, setFechaInicio] = useState<string | null>(null);
const [fechaFin, setFechaFin] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const swapItems = <T,>(arr: T[], index1: number, index2: number): T[] => {
const result = [...arr];
[result[index1], result[index2]] = [result[index2], result[index1]];
return result;
};
const moverSeccion = (from: number, to: number) => {
if (to >= 0 && to < secciones.length) {
setSecciones((prev) => swapItems(prev, from, to));
setPaginaActual(to + 1);
}
};
const moverPregunta = (seccionIndex: number, from: number, to: number) => {
const nuevas = [...secciones];
const preguntas = nuevas[seccionIndex].preguntas;
if (to >= 0 && to < preguntas.length) {
nuevas[seccionIndex].preguntas = swapItems(preguntas, from, to);
setSecciones(nuevas);
}
};
const agregarSeccion = () => {
setSecciones((prev) => [
...prev,
{ titulo: '', descripcion: '', preguntas: [] },
]);
setPaginaActual(secciones.length + 1);
};
const eliminarSeccion = (index: number) => {
const nuevas = secciones.filter((_, i) => i !== index);
setSecciones(nuevas);
setPaginaActual((prev) =>
Math.max(1, prev > nuevas.length ? nuevas.length : prev)
);
};
const actualizarSeccion = <K extends keyof SeccionFormulario>(
index: number,
campo: K,
valor: SeccionFormulario[K]
) => {
setSecciones((prev) => {
const nuevas = [...prev];
nuevas[index][campo] = valor;
return nuevas;
});
};
const agregarPregunta = (seccionIndex: number) => {
const nuevas = [...secciones];
nuevas[seccionIndex].preguntas.push({
titulo: '',
tipo: 'Abierta',
opciones: [],
obligatoria: false,
limite: 250,
validacion: undefined,
});
setSecciones(nuevas);
};
const eliminarPregunta = (seccionIndex: number, preguntaIndex: number) => {
const nuevas = [...secciones];
nuevas[seccionIndex].preguntas.splice(preguntaIndex, 1);
setSecciones(nuevas);
};
const actualizarPregunta = (
seccionIndex: number,
preguntaIndex: number,
campo: keyof PreguntaFormulario,
valor: PreguntaFormulario[keyof PreguntaFormulario]
) => {
setSecciones((prev) => {
const nuevas = [...prev];
const pregunta = nuevas[seccionIndex].preguntas[preguntaIndex];
nuevas[seccionIndex].preguntas[preguntaIndex] = {
...pregunta,
[campo]: valor,
};
return nuevas;
});
};
const agregarOpcion = (seccionIndex: number, preguntaIndex: number) => {
const nuevas = [...secciones];
if (nuevas[seccionIndex]?.preguntas[preguntaIndex]?.opciones) {
nuevas[seccionIndex].preguntas[preguntaIndex].opciones!.push({
valor: '',
});
}
setSecciones(nuevas);
};
const eliminarOpcion = (
seccionIndex: number,
preguntaIndex: number,
opcionIndex: number
) => {
const nuevas = [...secciones];
nuevas[seccionIndex]?.preguntas[preguntaIndex]?.opciones?.splice(
opcionIndex,
1
);
setSecciones(nuevas);
};
const actualizarOpcion = (
seccionIndex: number,
preguntaIndex: number,
opcionIndex: number,
valor: string
) => {
const nuevas = [...secciones];
if (
nuevas[seccionIndex]?.preguntas[preguntaIndex]?.opciones &&
nuevas[seccionIndex].preguntas[preguntaIndex].opciones![opcionIndex]
) {
nuevas[seccionIndex].preguntas[preguntaIndex].opciones![
opcionIndex
].valor = valor;
}
setSecciones(nuevas);
};
const guardarFormulario = async () => {
if (!fechaInicio || !fechaFin || !tipoCuestionario) {
alert('Todos los campos obligatorios deben estar llenos');
return;
}
const formatearFecha = (fecha: string, tipo: 'inicio' | 'fin'): string => {
const sufijo = tipo === 'inicio' ? 'T00:00:01' : 'T23:59:59';
return `${fecha}${sufijo}`;
};
const formData: FormularioCreacion = {
nombre_form: nombreFormulario,
descripcion,
fecha_inicio: formatearFecha(fechaInicio, 'inicio'),
fecha_fin: formatearFecha(fechaFin, 'fin'),
id_tipo_cuestionario: tipoCuestionario,
evento: nombreFormulario, // temporalmente igual al nombre del form
secciones,
};
console.log('Formulario a guardar:', formData);
setLoading(true);
await toast
.promise(axiosInstance.post('/cuestionario', formData), {
loading: 'Guardando formulario...',
success: 'Formulario guardado exitosamente!',
error: 'Error al guardar el formulario.',
})
.then((res) => {
console.log('Formulario guardado:', res.data);
})
.catch((error) => {
console.error('Error al guardar el formulario:', error);
})
.finally(() => {
setLoading(false);
});
};
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
if (file) {
const reader = new FileReader();
reader.onload = () => setBannerPreview(reader.result as string);
reader.readAsDataURL(file);
} else {
setBannerPreview(null);
}
};
const seccionActual = secciones[paginaActual - 1];
return (
<>
<h2 className="my-4">Crear nuevo formulario</h2>
<div className="row">
{secciones.length === 0 &&
plantillasDisponibles.map((plantilla, index) => (
<div className="col-md-4 mb-3" key={plantilla.id}>
<h3>Plantillas</h3>
<div
className="cursor-pointer"
onClick={() => {
const {
nombre_form,
descripcion,
fecha_inicio,
fecha_fin,
id_tipo_cuestionario,
secciones,
} = plantilla.datos;
setNombreFormulario(nombre_form);
setDescripcion(descripcion);
setFechaInicio(fecha_inicio.slice(0, 10));
setFechaFin(fecha_fin.slice(0, 10));
setTipoCuestionario(id_tipo_cuestionario);
setSecciones(
secciones.map((seccion) => ({
...seccion,
preguntas: seccion.preguntas.map((pregunta) => ({
...pregunta,
tipo: pregunta.tipo as
| 'Abierta'
| 'Cerrada'
| 'Multiple',
})),
}))
);
setPaginaActual(1);
}}
>
<Image
src={plantilla.imagen}
width={250}
height={250}
alt={`Plantilla de formulario ${index + 1}`}
className="img-fluid rounded shadow-sm"
/>
</div>
</div>
))}
</div>
<div className="mb-4">
<label className="form-label">Banner del formulario</label>
<input
type="file"
accept="image/*"
className="form-control mb-2"
onChange={handleBannerChange}
/>
{bannerPreview && (
<div className="text-center">
<Image
src={bannerPreview}
width={1000}
height={300}
alt="Ejemplo de banner"
className="rounded-4 shadow-sm"
style={{
objectFit: 'cover',
objectPosition: 'center',
}}
/>
</div>
)}
</div>
<Select
label="Tipo de cuestionario"
options={[
{ label: 'Seleccione un tipo', value: '' },
{ label: 'Tipo 1', value: 1 },
{ label: 'Tipo 2', value: 2 },
{ label: 'Tipo 3', value: 3 },
]}
value={tipoCuestionario?.toString() || ''}
onChange={(e) => setTipoCuestionario(Number(e.target.value))}
/>
<Input
label="Nombre del formulario"
value={nombreFormulario}
onChange={(e) => setNombreFormulario(e.target.value)}
placeholder="Ingrese el nombre del formulario"
/>
<div className="mb-3">
<label className="form-label">Descripción</label>
<textarea
className="form-control bg-white"
rows={3}
value={descripcion}
onChange={(e) => setDescripcion(e.target.value)}
/>
</div>
<div className="row alert alert-info">
<p className="mb-0 h2">Fechas de inicio y fin del formulario</p>
<div className="col-md-6">
<Input
label="Fecha de inicio"
type="date"
value={fechaInicio || ''}
onChange={(e) => setFechaInicio(e.target.value)}
/>
</div>
<div className="col-md-6">
<Input
label="Fecha de fin"
type="date"
value={fechaFin || ''}
onChange={(e) => setFechaFin(e.target.value)}
/>
</div>
</div>
<div className="mb-4">
<Button outline onClick={agregarSeccion} icon="plus">
Agregar Sección
</Button>
{seccionActual && (
<div className="border rounded p-3 mb-4">
<div className="d-flex justify-content-between align-items-center">
<h5>Sección {paginaActual}</h5>
<div className="d-flex gap-2">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() =>
moverSeccion(paginaActual - 1, paginaActual - 2)
}
disabled={paginaActual === 1}
>
</button>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => moverSeccion(paginaActual - 1, paginaActual)}
disabled={paginaActual === secciones.length}
>
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => eliminarSeccion(paginaActual - 1)}
>
Eliminar sección
</button>
</div>
</div>
<FloatingInput
label="Título de la sección"
className={{ container: 'mb-2' }}
value={seccionActual.titulo}
onChange={(e) =>
actualizarSeccion(paginaActual - 1, 'titulo', e.target.value)
}
placeholder="Título de la sección"
/>
<textarea
className="form-control mb-3"
placeholder="Descripción de la sección"
value={seccionActual.descripcion}
onChange={(e) =>
actualizarSeccion(
paginaActual - 1,
'descripcion',
e.target.value
)
}
/>
{seccionActual.preguntas.map((pregunta, pIdx) => (
<div key={pIdx} className="border rounded p-2 mb-3">
<div className="d-flex justify-content-between align-items-center">
<strong>Pregunta {pIdx + 1}</strong>
<div className="d-flex gap-2">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() =>
moverPregunta(paginaActual - 1, pIdx, pIdx - 1)
}
disabled={pIdx === 0}
>
</button>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() =>
moverPregunta(paginaActual - 1, pIdx, pIdx + 1)
}
disabled={pIdx === seccionActual.preguntas.length - 1}
>
</button>
<button
className="btn btn-sm btn-outline-danger"
onClick={() => eliminarPregunta(paginaActual - 1, pIdx)}
>
Eliminar
</button>
</div>
</div>
<Input
label="Texto de la pregunta"
className={{ container: 'my-2' }}
value={pregunta.titulo}
onChange={(e) =>
actualizarPregunta(
paginaActual - 1,
pIdx,
'titulo',
e.target.value
)
}
placeholder="Texto de la pregunta"
/>
<Select
label="Tipo de pregunta"
options={[
{ label: 'Abierta', value: 'Abierta' },
{ label: 'Cerrada', value: 'Cerrada' },
{ label: 'Opción múltiple', value: 'Multiple' },
]}
value={pregunta.tipo}
onChange={(e) =>
actualizarPregunta(
paginaActual - 1,
pIdx,
'tipo',
e.target.value as PreguntaFormulario['tipo']
)
}
/>
{pregunta.tipo === 'Abierta' && (
<div className="row">
<div className="col-md-6">
<Input
label="Límite de caracteres"
type="number"
value={pregunta.limite?.toString() || ''}
onChange={(e) =>
actualizarPregunta(
paginaActual - 1,
pIdx,
'limite',
parseInt(e.target.value, 10)
)
}
placeholder="Máximo de caracteres permitidos"
/>
</div>
<div className="col-md-6">
<Select
label="Validación"
value={pregunta.validacion || ''}
options={[
{ label: 'Sin validación', value: '' },
{ label: 'Correo', value: 'correo' },
{ label: 'Número de cuenta', value: 'cuenta_alumno' },
{ label: 'Nombre/Texto', value: 'nombre' },
]}
onChange={(e) =>
actualizarPregunta(
paginaActual - 1,
pIdx,
'validacion',
e.target.value as PreguntaFormulario['validacion']
)
}
/>
</div>
</div>
)}
<div className="form-check form-switch mb-3">
<input
className="form-check-input"
type="checkbox"
id={`obligatoria-${pIdx}`}
checked={pregunta.obligatoria}
onChange={(e) =>
actualizarPregunta(
paginaActual - 1,
pIdx,
'obligatoria',
e.target.checked
)
}
/>
<label
className="form-check-label"
htmlFor={`obligatoria-${pIdx}`}
>
¿Es obligatoria?
</label>
</div>
{(pregunta.tipo === 'Cerrada' ||
pregunta.tipo === 'Multiple') && (
<>
<button
className="btn btn-sm btn-outline-secondary mb-2"
onClick={() => agregarOpcion(paginaActual - 1, pIdx)}
>
+ Agregar opción
</button>
{(pregunta.opciones ?? []).map((op, oIdx) => (
<div key={oIdx} className="input-group mb-2">
<input
type="text"
className="form-control bg-white"
placeholder={`Opción ${oIdx + 1}`}
value={op.valor}
onChange={(e) =>
actualizarOpcion(
paginaActual - 1,
pIdx,
oIdx,
e.target.value
)
}
/>
<button
className="btn btn-outline-danger"
onClick={() =>
eliminarOpcion(paginaActual - 1, pIdx, oIdx)
}
>
x
</button>
</div>
))}
</>
)}
</div>
))}
<button
className="btn btn-sm btn-outline-primary"
onClick={() => agregarPregunta(paginaActual - 1)}
>
+ Agregar pregunta
</button>
</div>
)}
{secciones.length > 1 && (
<div className="d-flex justify-content-center mb-4">
<Pagination
currentPage={paginaActual}
totalPages={secciones.length}
onPageChange={setPaginaActual}
/>
</div>
)}
</div>
<Button onClick={guardarFormulario} color="success" disabled={loading}>
Guardar formulario
</Button>
</>
);
}
@@ -0,0 +1,189 @@
'use client';
import SimpleInput from '@/components/input';
import Table, { Header } from '@/components/table';
import EditFormulario from '@/containers/edit-formulario';
import { useGetApi } from '@/hooks/use-get-api';
import { GetCuestionario } from '@/types/cuestionario';
import { ParticipacionEvento } from '@/types/participante-evento';
import axiosInstance from '@/utils/api-config';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import React, { useEffect } from 'react';
import toast from 'react-hot-toast';
type Params = {
id_evento: string;
id_cuestionario: string;
};
export default function Page() {
const params = useParams<Params>();
const [cuestionario, setCuestionario] =
React.useState<GetCuestionario | null>(null);
const [busquedaCorreo, setBusquedaCorreo] = React.useState('');
const [loadingAsistencia, setLoadingAsistencia] = React.useState<
Record<number, boolean>
>({});
const { data: participantes, setData } = useGetApi<ParticipacionEvento[]>(
`/participante-evento/evento/${params.id_cuestionario}`
);
const { data } = useGetApi<GetCuestionario>(
`/cuestionario/${params.id_cuestionario}`
);
useEffect(() => {
if (data) setCuestionario(data);
}, [data]);
const confirmarAsistencia = async (
id_participante: number,
id_evento: number
) => {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: true }));
try {
await axiosInstance.post(
`/participante-evento/asistencia/${id_participante}/${id_evento}`
);
toast.success('Asistencia confirmada');
// Actualiza lista
const { data } = await axiosInstance.get<ParticipacionEvento[]>(
`/participante-evento/evento/${id_evento}`
);
setData(data);
} catch (error) {
toast.error('Error al confirmar asistencia');
console.error(error);
} finally {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: false }));
}
};
const headers: Header<ParticipacionEvento>[] = [
{
key: 'id_participante',
label: '#',
render: (_, row) => row.participante.id_participante,
},
{
key: 'participante',
label: 'Correo',
render: (_, row) => row.participante.correo,
},
{
key: 'fecha_registro',
label: 'Fecha registro',
render: (_, row) =>
row.fecha_registro
? new Date(row.fecha_registro).toLocaleString()
: 'Sin registro',
},
{
key: 'estatus',
label: 'Asistencia',
render: (_, row) => {
const isLoading = loadingAsistencia[row.id_participante];
return row.fecha_asistencia ? (
<span className="text-success">
<i className="bi bi-check-circle-fill me-2"></i>
Asistió
</span>
) : (
<button
className="btn btn-sm btn-outline-success"
onClick={() =>
confirmarAsistencia(row.id_participante, row.id_cuestionario)
}
disabled={isLoading}
>
{isLoading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Confirmando...
</>
) : (
'Confirmar asistencia'
)}
</button>
);
},
},
];
const handleChange = (field: keyof GetCuestionario, value: string | Date) => {
setCuestionario((prev) =>
prev
? {
...prev,
[field]: value,
}
: null
);
};
const handleCuestionarioActualizado = (
eventoActualizado: GetCuestionario
) => {
setCuestionario(eventoActualizado);
};
const participantesFiltrados =
participantes &&
participantes.filter((p) =>
p.participante.correo.toLowerCase().includes(busquedaCorreo.toLowerCase())
);
return (
<div className="my-4">
<Link
href={`/administrador/evento/${params.id_evento}`}
className="btn btn-link mb-3"
>
<i className="bi bi-arrow-left me-2"></i>
Volver
</Link>
<h2 className="mb-4">Formulario</h2>
{cuestionario && (
<EditFormulario
cuestionario={cuestionario}
handleChange={handleChange}
handleOnChange={handleCuestionarioActualizado}
/>
)}
<h3 className="mt-5">Participantes</h3>
{participantes?.length === 0 && (
<p className="text-muted">No hay participantes registrados aún.</p>
)}
{participantes && participantes.length > 0 && (
<>
<SimpleInput
label="Buscar por correo"
placeholder="ejemplo@correo.com"
value={busquedaCorreo}
onChange={(e) => setBusquedaCorreo(e.target.value)}
/>
<div className="mt-4">
<Table
headers={headers}
data={participantesFiltrados ?? []}
rowKey={(row) => row.id_participante}
/>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,162 @@
'use client';
import EditEvento from '@/containers/edit-evento';
import { useGetApi } from '@/hooks/use-get-api';
import {
CuestionarioWithCupo,
GetEventoWithCuestionarios,
} from '@/types/evento';
import { useParams } from 'next/navigation';
import React, { useState, useEffect } from 'react';
import { CreateEventoType } from '@/types/create-evento';
import axiosInstance from '@/utils/api-config';
import toast from 'react-hot-toast';
import Table, { Header } from '@/components/table';
import Button from '@/components/button';
import { downloadFile } from '@/utils/downloas-utils';
import Link from 'next/link';
type Params = {
id_evento: string;
};
export default function Page() {
const params = useParams<Params>();
const { data } = useGetApi<GetEventoWithCuestionarios>(
`/evento/${params.id_evento}/cuestionarios`
);
const [evento, setEvento] = useState<GetEventoWithCuestionarios | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (data) setEvento(data);
}, [data]);
const handleChange = (
field: keyof CreateEventoType,
value: string | Date
) => {
setEvento((prev) =>
prev
? {
...prev,
[field]: value,
}
: null
);
};
const handleEventoActualizado = (
eventoActualizado: GetEventoWithCuestionarios
) => {
setEvento(eventoActualizado);
};
const headers: Header<CuestionarioWithCupo>[] = [
{
key: 'nombre_form',
label: 'Nombre del cuestionario',
},
{
key: 'cupo_maximo',
label: 'Cupos',
render: (_, row) => {
if (row.cupo_maximo === null) {
return 'Sin límite';
}
return `${row.cupos_usados ?? 0} / ${row.cupo_maximo}`;
},
},
{
key: 'banner',
label: 'Ver / Editar',
render: (_, row) => (
<div className="d-flex gap-2">
<Link
className="btn btn-primary"
href={`/administrador/evento/${params.id_evento}/${row.id_cuestionario}`}
>
Ver/Editar formulario
</Link>
</div>
),
},
{
key: 'banner',
label: 'Descargar respuestas',
render: (_, row) => (
<Button
variant="success"
icon="download"
onClick={() => handleDownload(row.id_cuestionario, row.nombre_form)}
disabled={loading}
>
{loading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Descargando...
</>
) : (
'Descargar respuestas'
)}
</Button>
),
},
];
if (!evento) return <p>Cargando...</p>;
const handleDownload = async (id_cuestionario: number, nombre: string) => {
setLoading(true);
try {
const res = await axiosInstance.get(
`/cuestionario-respondido/reporte-respuestas/${id_cuestionario}`,
{
responseType: 'blob',
}
);
downloadFile(res.data, `${nombre}`, 'csv');
} catch (error) {
console.error('Error al descargar el archivo:', error);
toast.error('Error al descargar el archivo');
} finally {
setLoading(false);
}
};
return (
<div className="my-4">
<Link
href={`/administrador`}
className="btn btn-link mb-3"
>
<i className="bi bi-arrow-left me-2"></i>
Volver
</Link>
<h2 className="mb-4">Evento</h2>
<EditEvento
evento={evento}
handleChange={handleChange}
handleOnChange={handleEventoActualizado}
/>
{data?.cuestionarios && data.cuestionarios.length > 0 && (
<>
<h3 className="mt-5">Cuestionarios del evento</h3>
<div className="mt-2">
<Table headers={headers} data={data.cuestionarios ?? []} />
</div>
</>
)}
</div>
);
}
@@ -1,25 +0,0 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import React from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
const params = useParams<{ id_formulario: string }>();
return (
<div>
<nav className='d-flex gap-2 my-4'>
<Link href={`/administrador/formulario/${params.id_formulario}`} className='text-decoration-none'>
<div className='box'>Registros</div>
</Link>
{/* <Link
href={'/administrador/crear-formulario'}
className='text-decoration-none'
>
<div className='box'>Editar formulario</div>
</Link> */}
</nav>
{children}
</div>
);
}
@@ -1,226 +0,0 @@
'use client';
import Button from '@/components/button';
import Input from '@/components/input';
import Table, { Header } from '@/components/table';
import { ParticipacionEvento } from '@/types/participante-evento';
import axiosInstance from '@/utils/api-config';
import { downloadFile } from '@/utils/downloas-utils';
import { useParams } from 'next/navigation';
import React, { useEffect } from 'react';
import toast from 'react-hot-toast';
export default function Page() {
const params = useParams<{ id_formulario: string }>();
const id_formulario = Number(params?.id_formulario);
const [participantes, setParticipantes] = React.useState<
ParticipacionEvento[]
>([]);
const [loadingAsistencia, setLoadingAsistencia] = React.useState<
Record<number, boolean>
>({});
const [busquedaCorreo, setBusquedaCorreo] = React.useState('');
const [loading, setLoading] = React.useState(false);
useEffect(() => {
if (!id_formulario) return;
const fetchParticipantes = async () => {
try {
const { data } = await axiosInstance.get<ParticipacionEvento[]>(
`/participante-evento/evento/${id_formulario}`
);
setParticipantes(data);
} catch (error) {
console.error('Error al obtener participantes:', error);
}
};
fetchParticipantes();
}, [id_formulario]);
const confirmarAsistencia = async (
id_participante: number,
id_evento: number
) => {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: true }));
try {
await axiosInstance.post(
`/participante-evento/asistencia/${id_participante}/${id_evento}`
);
toast.success('Asistencia confirmada');
// Actualiza lista
const { data } = await axiosInstance.get<ParticipacionEvento[]>(
`/participante-evento/evento/${id_evento}`
);
setParticipantes(data);
} catch (error) {
toast.error('Error al confirmar asistencia');
console.error(error);
} finally {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: false }));
}
};
const headers: Header<ParticipacionEvento>[] = [
{
key: 'id_participante',
label: '#',
render: (_, row) => row.participante.id_participante,
},
{
key: 'participante',
label: 'Correo',
render: (_, row) => row.participante.correo,
},
{
key: 'fecha_inscripcion',
label: 'Fecha inscripción',
render: (val) => new Date(val as string).toLocaleString(),
},
{
key: 'estatus',
label: 'Asistencia',
render: (_, row) => {
const isLoading = loadingAsistencia[row.id_participante];
return row.fecha_asistencia ? (
<span className="text-success">
<i className="bi bi-check-circle-fill me-2"></i>
Asistió
</span>
) : (
<button
className="btn btn-sm btn-outline-success"
onClick={() =>
confirmarAsistencia(row.id_participante, row.id_evento)
}
disabled={isLoading}
>
{isLoading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Confirmando...
</>
) : (
'Confirmar asistencia'
)}
</button>
);
},
},
];
const participantesFiltrados = participantes.filter((p) =>
p.participante.correo.toLowerCase().includes(busquedaCorreo.toLowerCase())
);
/* const handleDownload = () => {
if (participantesFiltrados.length === 0) {
toast.error('No hay participantes para exportar');
return;
}
const rows = participantesFiltrados.map((item) => ({
ID_Participante: item.id_participante,
ID_Evento: item.id_evento,
Correo: item.participante.correo,
Fecha_Inscripcion: new Date(item.fecha_inscripcion).toLocaleString(),
Estatus: item.fecha_asistencia ? 'Asistio' : 'No asistio',
Fecha_Asistencia: item.fecha_asistencia
? new Date(item.fecha_asistencia).toLocaleString()
: '',
}));
const worksheet = XLSX.utils.json_to_sheet(rows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Participantes');
const excelBuffer = XLSX.write(workbook, {
bookType: 'xlsx',
type: 'array',
});
const blob = new Blob([excelBuffer], {
type: 'application/octet-stream',
});
downloadFile(blob, `participantes_formulario_${id_formulario}`, 'xlsx');
}; */
const handleDownload = async () => {
setLoading(true);
if (participantesFiltrados.length === 0) {
toast.error('No hay participantes para exportar');
return;
}
try {
const res = await axiosInstance.get(
`/cuestionario-respondido/reporte-respuestas/${id_formulario}`,
{
responseType: 'blob',
}
);
downloadFile(
res.data,
`reporte_respuestas_formulario_${id_formulario}`,
'csv'
);
} catch (error) {
console.error('Error al descargar el archivo:', error);
toast.error('Error al descargar el archivo');
} finally {
setLoading(false);
}
};
return (
<div>
<h2 className="mb-4">Participantes del formulario #{id_formulario}</h2>
<Button
icon="download"
onClick={handleDownload}
className="my-2"
disabled={loading}
>
{loading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Descargando...
</>
) : (
'Descargar respuestas'
)}
</Button>
{participantes.length > 0 && (
<>
<Input
label="Buscar por correo"
placeholder="ejemplo@correo.com"
value={busquedaCorreo}
onChange={(e) => setBusquedaCorreo(e.target.value)}
/>
<div className="mt-4">
<Table
headers={headers}
data={participantesFiltrados}
rowKey={(row) => row.id_participante}
/>
</div>
</>
)}
</div>
);
}
+3 -17
View File
@@ -1,26 +1,12 @@
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
import Link from 'next/link';
import React from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className='d-flex flex-column min-vh-100'>
<Header />
<main className='container flex-grow-1'>
<nav className='d-flex gap-2 my-4'>
<Link href={'/administrador/'} className='text-decoration-none'>
<div className='box'>Formularios</div>
</Link>
<Link
href={'/administrador/crear-formulario'}
className='text-decoration-none'
>
<div className='box'>Crear cuestionario</div>
</Link>
</nav>
{children}
</main>
<div className="d-flex flex-column min-vh-100">
<Header small/>
<main className="container flex-grow-1">{children}</main>
<Footer />
</div>
);
+34 -58
View File
@@ -1,71 +1,47 @@
'use client';
import FormularioCard from '@/components/formulario-card';
import { GetCuestionario } from '@/types/cuestionario';
import React, { useEffect, useState } from 'react';
import Button from '@/components/button';
import EventoCard from '@/components/evento-card';
import { useGetApi } from '@/hooks/use-get-api';
import { GetEventoWithCuestionarios } from '@/types/evento';
import Link from 'next/link';
import React from 'react';
export default function Page() {
const [eventos, setEventos] = useState<GetCuestionario[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function getEventos() {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/cuestionario`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!res.ok) {
throw new Error(`Error ${res.status}: ${res.statusText}`);
}
const data: GetCuestionario[] = await res.json();
setEventos(data);
} catch (err) {
console.error('Error en getEventos:', err);
setError('Error al cargar los eventos');
} finally {
setLoading(false);
}
}
getEventos();
}, []);
const { loading, data, error } = useGetApi<GetEventoWithCuestionarios[]>(
'/evento/activos/cuestionarios'
);
if (loading) return <div>Cargando formularios...</div>;
if (error) return <div>{error}</div>;
if (error) return <div className="alert alert-danger">{error.message}</div>;
return (
<>
<div className="cards-1 mt-5">
<div className="container">
<div className="row">
{eventos.map((cuestionario, index) => {
const fadeClass = `delay-${(index % 5) + 1}`;
return (
<div
className={`col-md-4 my-4 fade-in-up-bounce ${fadeClass}`}
key={cuestionario.id_cuestionario}
>
<FormularioCard
cuestionario={cuestionario}
link={`/administrador/formulario/${cuestionario.id_cuestionario}`}
button_message="Registrarse"
/>
</div>
);
})}
</div>
</div>
<div className="d-flex justify-content-between align-items-center my-4">
<h1>Eventos</h1>
<Link href="/administrador/crear-evento">
<Button>Crear Evento</Button>
</Link>
</div>
{loading && <div>Cargando...</div>}
{data && (
<div className="row">
{data.map((item, key) => {
const fadeClass = `delay-${(key % 5) + 1}`;
return (
<div
className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`}
key={key}
>
<EventoCard
evento={item}
user='administrador'
/>
</div>
);
})}
</div>
)}
</>
);
}
@@ -0,0 +1,54 @@
'use client';
import SimpleInput from '@/components/input';
import axiosInstance from '@/utils/api-config';
import React, { useState } from 'react';
export default function Page() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [response, setResponse] = useState<{
insertados: number;
omitidos: number;
} | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setSelectedFile(e.target.files[0]);
}
};
const handleSubmit = async () => {
if (!selectedFile) return;
const formData = new FormData();
formData.append('file', selectedFile);
try {
const res = await axiosInstance.post('/trabajadores/cargar', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
setResponse(res.data);
} catch (error) {
console.error('Error al subir el archivo:', error);
}
};
return (
<div>
<h2>Subir Excel de Profesores</h2>
<SimpleInput type="file" onChange={handleFileChange} />
<button onClick={handleSubmit} disabled={!selectedFile}>
Enviar
</button>
{response && (
<div>
<h3>Resultado de la carga:</h3>
<p>Insertados: {response.insertados}</p>
<p>Omitidos: {response.omitidos}</p>
</div>
)}
</div>
);
}
+2 -2
View File
@@ -22,8 +22,8 @@ export default function Page() {
render: (_, row) => row.participante.correo,
},
{
key: 'fecha_inscripcion',
label: 'Fecha inscripción',
key: 'fecha_registro',
label: 'Fecha registro',
render: (val) => new Date(val as string).toLocaleString(),
},
{
+6 -6
View File
@@ -12,7 +12,7 @@ const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
export default function Page() {
const [scannedData, setScannedData] = useState<{
id_participante: number;
id_evento: number;
id_cuestionario: number;
} | null>(null);
const [showModal, setShowModal] = useState(false);
@@ -27,21 +27,21 @@ export default function Page() {
try {
const data = JSON.parse(rawValue);
if (data.id_participante && data.id_evento) {
if (data.id_participante && data.id_cuestionario) {
try {
const response = await axiosInstance.get(
`/participante-evento/${data.id_participante}/${data.id_evento}`
`/participante-evento/${data.id_participante}/${data.id_cuestionario}`
);
setParticipante(response.data.participante.correo);
setScannedData({
id_participante: data.id_participante,
id_evento: data.id_evento,
id_cuestionario: data.id_cuestionario,
});
setShowModal(true);
setEnableScan(false);
} catch (err) {
console.warn('Participante no registrado en el evento:', err);
console.warn('Participante no registrado en el cuestionario:', err);
setStatusMessage(
'❌ El participante no está registrado en este evento.'
);
@@ -60,7 +60,7 @@ export default function Page() {
try {
const response = await axiosInstance.post(
`/participante-evento/asistencia/${scannedData.id_participante}/${scannedData.id_evento}`
`/participante-evento/asistencia/${scannedData.id_participante}/${scannedData.id_cuestionario}`
);
console.log('Asistencia registrada:', response.data);
@@ -0,0 +1,48 @@
import FormularioPage from '@/containers/pages/formulario-page';
import { GetEventoWithCuestionario } from '@/types/evento';
import { Metadata } from 'next';
import React from 'react';
type Params = {
nombre_evento: string;
id_evento: string;
id_cuestionario: string;
};
type Props = {
params: Promise<Params>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id_evento, id_cuestionario } = await params;
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/evento/${id_evento}/cuestionario/${id_cuestionario}`
);
const data: GetEventoWithCuestionario = await response.json();
return {
title: data?.nombre_evento || 'Evento',
description: data?.descripcion_evento || 'Descripción del evento',
openGraph: {
title: data?.nombre_evento || 'Evento',
description: data?.descripcion_evento || 'Descripción del evento',
images: [
{
url: `${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`,
width: 800,
height: 400,
alt: data?.nombre_evento || 'Evento',
},
],
},
};
}
export default async function Page({ params }: Props) {
const { id_evento, id_cuestionario } = await params;
return (
<FormularioPage id_evento={id_evento} id_cuestionario={id_cuestionario} />
);
}
+29 -77
View File
@@ -1,89 +1,41 @@
'use client';
import ClientCarousel from '@/client-components/client-carousel';
import FormularioCard from '@/components/formulario-card';
import { GetCuestionario } from '@/types/cuestionario';
import { useEffect, useState } from 'react';
import EventoCard from '@/components/evento-card';
import { useGetApi } from '@/hooks/use-get-api';
import { GetEventoWithCuestionarios } from '@/types/evento';
import React from 'react';
export default function Page() {
const [eventos, setEventos] = useState<GetCuestionario[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function getEventos() {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/cuestionario`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!res.ok) {
throw new Error(`Error ${res.status}: ${res.statusText}`);
}
const data: GetCuestionario[] = await res.json();
setEventos(data);
} catch (err) {
console.error('Error en getEventos:', err);
setError('No se pudieron cargar los cuestionarios.');
} finally {
setLoading(false);
}
}
getEventos();
}, []);
function slugify(text: string): string {
return text
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
if (loading)
return <div className="text-center">Cargando cuestionarios...</div>;
if (error) return <div>{error}</div>;
const { loading, data } = useGetApi<GetEventoWithCuestionarios[]>(
'/evento/activos/cuestionarios'
);
return (
<>
<div>
<div className="mx-auto mb-5 mt-3 fade-in-down-bounce">
<ClientCarousel />
<ClientCarousel
images={data?.map((item) => ({
src: item.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${item.banner}`
: '/default-banner.jpg',
alt: item.nombre_evento,
}))}
/>
</div>
<div className="cards-1 mt-5">
<div className="container">
<div className="row">
{eventos.map((cuestionario, index) => {
const slug = slugify(cuestionario.nombre_form);
const url = `/registro/${slug}-${cuestionario.id_cuestionario}`;
const fadeClass = `delay-${(index % 5) + 1}`;
return (
<div
className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`}
key={cuestionario.id_cuestionario}
>
<FormularioCard
cuestionario={cuestionario}
link={url}
button_message="Registrarse"
/>
</div>
);
})}
</div>
{loading && <div>Cargando...</div>}
{data && (
<div className="row">
{data.map((item, key) => {
const fadeClass = `delay-${(key % 5) + 1}`;
return (
<div className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`} key={key}>
<EventoCard evento={item} />
</div>
);
})}
</div>
</div>
</>
)}
</div>
);
}
-80
View File
@@ -1,80 +0,0 @@
'use client';
import Button from '@/components/button';
import Formulario from '@/containers/formulario';
import { CuestionarioResponse } from '@/types/responder-formulario';
import axiosInstance from '@/utils/api-config';
import Image from 'next/image';
import { useParams, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
export default function Page() {
const router = useRouter();
const params = useParams<{ slug: string }>();
const slug = params?.slug;
const id_formulario = slug?.split('-').pop(); // Toma el último fragmento como ID
const [cuestionario, setCuestionario] = useState<CuestionarioResponse | null>(
null
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const { data } = await axiosInstance.get<CuestionarioResponse>(
`/cuestionario/${id_formulario}/formulario`
);
if (data) {
console.log(data);
setCuestionario(data);
} else {
setError('Error fetching data');
}
} catch (error) {
console.error('Error fetching data:', error);
setError('Error de conexión');
} finally {
setLoading(false);
}
};
if (id_formulario) fetchData();
}, [id_formulario]);
if (loading) return <p>Cargando...</p>;
if (error) return <p className="text-danger">{error}</p>;
if (!cuestionario) return <p>No hay datos</p>;
return (
<div className="container py-4">
<Button icon="arrow-left" onClick={() => router.back()}>
Volver
</Button>
<div className="text-center mb-4 mt-2">
<Image
src={'/feriasex.png'}
width={1000}
height={300}
alt="Ejemplo de banner"
className="rounded-4 shadow-sm img-fluid"
style={{
objectFit: 'cover',
objectPosition: 'center',
height: 300,
}}
/>
</div>
<div className="px-lg-5 mx-lg-5">
<h2>{cuestionario.cuestionario.nombre_form}</h2>
<p>{cuestionario.cuestionario.descripcion}</p>
<Formulario
id_formulario={cuestionario.cuestionario.id_cuestionario}
secciones={cuestionario.cuestionario.secciones}
/>
</div>
</div>
);
}
+3 -2
View File
@@ -1,10 +1,11 @@
'use client';
import { CarouselProps } from '@/components/carousel';
import dynamic from 'next/dynamic';
import React from 'react';
const Carousel = dynamic(() => import('@/components/carousel'), {
ssr: false,
});
export default function ClientCarousel() {
return <Carousel />;
export default function ClientCarousel(props: CarouselProps) {
return <Carousel {...props} />;
}
+73
View File
@@ -0,0 +1,73 @@
'use client';
import Image from 'next/image';
import React, { useRef, useState } from 'react';
import Button from './button';
interface BannerUploaderProps {
label?: string;
onChange?: (file: File | null) => void;
defaultBanner?: string;
}
export default function ImageUploader({
label = 'Subir imagen',
onChange,
defaultBanner,
}: BannerUploaderProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(defaultBanner || null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] || null;
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(file);
} else {
setPreview(null);
}
onChange?.(file);
};
const handleRemoveImage = () => {
setPreview(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onChange?.(null);
};
return (
<div className="mb-4">
{label && <label className="block mb-2 font-semibold">{label}</label>}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="form-control mb-2"
/>
{preview && (
<div className="d-flex flex-column align-items-center">
<Image
src={preview}
alt="Previsualización"
className="img-fluid rounded"
width={600}
height={300}
/>
<Button
variant="danger"
onClick={handleRemoveImage}
className="mt-3"
>
Eliminar imagen
</Button>
</div>
)}
</div>
);
}
+12 -14
View File
@@ -1,7 +1,7 @@
import Image from 'next/image';
import React from 'react';
interface CarouselProps {
export interface CarouselProps {
id?: string;
images?: {
src: string;
@@ -9,17 +9,9 @@ interface CarouselProps {
}[];
}
export default function Carousel({
id = 'carouselExample',
images = [
{
src: '/feriasex.png',
alt: 'Imagen de ejemplo',
},
],
}: CarouselProps) {
export default function Carousel({ id="carousel-banners-eventos", images }: CarouselProps) {
if (!images || images.length === 0) {
return <div>No hay imágenes para mostrar</div>;
return <div></div>;
}
return (
@@ -31,11 +23,17 @@ export default function Carousel({
className={`carousel-item ${index === 0 ? 'active' : ''}`}
>
<Image
src={img.src}
width={1600}
height={535}
src={img.src ? img.src : '/default-banner.png'}
width={1200}
height={400}
alt="Ejemplo de banner"
className="d-block w-100 rounded-3 img-fluid"
style={{
height: '400px',
objectFit: 'cover',
objectPosition: 'center',
maxHeight: '400px',
}}
/>
</div>
))}
+106
View File
@@ -0,0 +1,106 @@
'use client';
import { GetEventoWithCuestionarios } from '@/types/evento';
import Image from 'next/image';
import Link from 'next/link';
import React, { useState } from 'react';
import MarkdownRenderer from './markdown-render';
export default function EventoCard({
evento,
user = 'public',
}: {
evento: GetEventoWithCuestionarios;
user?: 'public' | 'administrador' | 'staff';
}) {
const [verMas, setVerMas] = useState(false);
const descripcion = evento.descripcion_evento ?? '';
const limite = 100;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
return (
<div className="card card-blog d-flex flex-column justify-content-between">
<div>
<div className="card-image scale-hover-1">
<Link
href={
user === 'administrador' || user === 'staff'
? `/${user}/evento/${evento.id_evento}`
: `/evento/${evento.nombre_evento.split(' ').join('_')}/${
evento.id_evento
}`
}
>
<Image
width={400}
height={300}
className="img-fluid"
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`}
alt="Banner formulario"
/>
<div className="card-caption">{evento.nombre_evento}</div>
</Link>
<div className="ripple-cont"></div>
</div>
<div className="card-body">
<div className="card-description mb-0">
{evento.cuestionarios[0]?.cupo_maximo === null ? (
<span className="badge bg-success mb-2">Sin límite</span>
) : (
<span className="badge bg-primary mb-2">
{evento.cuestionarios[0]?.cupos_disponibles} cupos disponibles
</span>
)}
<MarkdownRenderer markdown={descripcionRecortada} />
{descripcion.length > limite && (
<button
onClick={() => setVerMas(!verMas)}
className="btn btn-link btn-sm p-0 ms-1 align-baseline"
style={{ fontSize: '0.875rem' }}
>
{verMas ? 'Ver menos' : 'Ver más'}
</button>
)}
</div>
{evento.cuestionarios.length > 0 &&
evento.cuestionarios.map((cuestionario) => (
<div key={cuestionario.id_cuestionario} className="">
{user === 'administrador' || user === 'staff' ? (
<Link
href={`/${user}/evento/${evento.id_evento}`}
className="btn mt-2 w-100 btn-outline-primary"
>
Ver evento
</Link>
) : cuestionario.cupos_disponibles === 0 &&
cuestionario.cupo_maximo !== null ? (
<button
className="btn mt-2 w-100 btn-outline-secondary"
disabled
>
Cupo lleno
</button>
) : (
<Link
href={`/evento/${evento.nombre_evento
.split(' ')
.join('_')}/${evento.id_evento}/${
cuestionario.id_cuestionario
}`}
className="btn mt-2 w-100 btn-outline-primary"
>
Regístrate
</Link>
)}
</div>
))}
</div>
</div>
</div>
);
}
+17 -55
View File
@@ -1,80 +1,42 @@
// @components/input.tsx
import React, { useState } from 'react';
import React from 'react';
export interface InputProps
export interface SimpleInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'className'> {
/** Etiqueta visible asociada al campo de entrada */
label?: string;
/** Clases CSS personalizables para distintos elementos */
className?: {
/** Clases para el contenedor del input */
container?: string;
/** Clases para la etiqueta */
label?: string;
/** Clases para el input */
input?: string;
};
}
/**
* Componente de entrada (`Input`) reutilizable en React.
*
* Este componente renderiza un campo de entrada (`<input>`) con una etiqueta opcional y
* funcionalidad para mostrar u ocultar contraseñas cuando el tipo es `password`.
* También permite personalizar clases CSS y es accesible mediante `aria-label` y `htmlFor`.
*
* @component
* @param {Object} props - Propiedades del componente.
* @param {string} [props.label] - Etiqueta visible asociada al campo de entrada.
* @param {Object} [props.className] - Clases CSS personalizables para distintos elementos.
* @param {string} [props.className.container] - Clases para el contenedor del input.
* @param {string} [props.className.label] - Clases para la etiqueta del input.
* @param {string} [props.className.input] - Clases para el input en sí.
* @param {string} [props.type] - Tipo del input (`text`, `password`, `email`, etc.).
* @param {string} [props.name] - Nombre del input (útil para formularios).
* @param {string} [props.id] - ID personalizado para el input (para accesibilidad).
* @param {string} [props.value] - Valor del input (para uso controlado).
* @param {function} [props.onChange] - Manejador del cambio de valor del input.
* @param {React.InputHTMLAttributes<HTMLInputElement>} [rest] - Atributos adicionales compatibles con `<input>`.
* Componente de entrada (`Input`) sin lógica de mostrar/ocultar contraseña.
*/
export default function Input(props: InputProps) {
const { className, label, type, name, id, ...rest } = props;
const [showPassword, setShowPassword] = useState(false);
export default function SimpleInput(props: SimpleInputProps) {
const { label, name, id, className, ...rest } = props;
const isPassword = type === 'password';
const inputType = isPassword && showPassword ? 'text' : type;
const inputId =
id || name || (label ? `input-${label.replace(/\s+/g, '-')}` : undefined);
const togglePasswordVisibility = () => setShowPassword(!showPassword);
return (
<div className={className?.container || 'mb-3'}>
{label && inputId && (
<label
htmlFor={inputId}
className={`form-label ${className?.label || ''}`}
>
<label htmlFor={inputId} className={className?.label || 'form-label'}>
{label}
</label>
)}
<div className="input-group">
<input
id={inputId}
name={name}
type={inputType}
className={`form-control ${className?.input || ''}`}
{...rest}
/>
{isPassword && (
<button
type="button"
className="btn btn-light border"
onClick={togglePasswordVisibility}
aria-label={
showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'
}
title={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
>
<i className={`bi ${showPassword ? 'bi-eye-slash' : 'bi-eye'}`}></i>
</button>
)}
</div>
<input
id={inputId}
name={name}
className={className?.input || 'form-control'}
{...rest}
/>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
type MarkdownRendererProps = {
markdown: string;
};
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ markdown }) => (
<ReactMarkdown
components={{
p: ({ ...props }) => <p style={{ margin: 0 }} {...props} />,
}}
>
{markdown}
</ReactMarkdown>
);
export default MarkdownRenderer;
+70
View File
@@ -0,0 +1,70 @@
import React, { useState } from 'react';
export interface PasswordInputProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'type' | 'className'
> {
/**
* Texto de la etiqueta asociada al input (opcional).
*/
label?: string;
/**
* Clases CSS personalizables para distintos elementos.
*/
className?: {
/** Clases para el contenedor del componente */
container?: string;
/** Clases para la etiqueta */
label?: string;
/** Clases para el campo input */
input?: string;
/** Clases para el botón de toggle */
button?: string;
};
}
/**
* Componente especializado para inputs de contraseña con botón de mostrar/ocultar.
*/
export default function PasswordInput(props: PasswordInputProps) {
const { label, name, id, className, ...rest } = props;
const [visible, setVisible] = useState(false);
// Determina el tipo de input según el estado de visibilidad
const inputType = visible ? 'text' : 'password';
const inputId =
id ||
name ||
(label ? `password-${label.replace(/\s+/g, '-')}` : undefined);
const toggleVisibility = () => setVisible((v) => !v);
return (
<div className={className?.container || 'mb-3'}>
{label && inputId && (
<label htmlFor={inputId} className={className?.label || 'form-label'}>
{label}
</label>
)}
<div className="input-group">
<input
id={inputId}
name={name}
type={inputType}
className={className?.input || 'form-control'}
{...rest}
/>
<button
type="button"
className={className?.button || 'btn btn-light border'}
onClick={toggleVisibility}
aria-label={visible ? 'Ocultar contraseña' : 'Mostrar contraseña'}
title={visible ? 'Ocultar contraseña' : 'Mostrar contraseña'}
>
<i className={`bi ${visible ? 'bi-eye-slash' : 'bi-eye'}`} />
</button>
</div>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
'use client';
import ImageUploader from '@/components/banner-uploader';
import Input from '@/components/input';
import Select from '@/components/select';
import { CreateEventoType } from '@/types/create-evento';
import { formatDateLocal } from '@/utils/date-utils';
import { commands } from '@uiw/react-md-editor';
import dynamic from 'next/dynamic';
import React from 'react';
// Carga dinámica para evitar problemas con SSR
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
const MAX_LENGTH = 500;
interface CreateEventoProps {
evento: CreateEventoType;
handleChange: (field: keyof CreateEventoType, value: string | Date) => void;
handleBannerChange: (file: File | null) => void;
}
export default function CreateEvento({
evento,
handleChange,
handleBannerChange,
}: CreateEventoProps) {
return (
<div className="row border p-3 rounded my-4">
<h2>Información del Evento</h2>
<p>
Completa la siguiente información para describir los detalles generales
del evento.
</p>
<ImageUploader label="Banner del evento" onChange={handleBannerChange} />
<Input
label="Nombre del evento"
required
value={evento.nombre_evento}
onChange={(e) => handleChange('nombre_evento', e.target.value)}
/>
<div className="mb-4">
<label className="form-label">Descripción del evento</label>
<div data-color-mode="light">
<MDEditor
value={evento.descripcion_evento}
onChange={(value) => {
const texto = value || '';
if (texto.length <= MAX_LENGTH) {
handleChange('descripcion_evento', texto);
}
}}
height={300}
commands={[commands.bold, commands.italic, commands.hr]}
/>
</div>
</div>
<Select
label="Tipo de evento"
value={evento.tipo_evento}
placeholder="Selecciona un tipo de evento"
onChange={(e) => handleChange('tipo_evento', e.target.value)}
options={[
{ label: 'Conferencia', value: 'conferencia' },
{ label: 'Taller', value: 'taller' },
{ label: 'Seminario', value: 'seminario' },
{ label: 'Curso', value: 'curso' },
{ label: 'Webinar', value: 'webinar' },
{ label: 'Reunión', value: 'reunion' },
{ label: 'Feria', value: 'feria' },
{ label: 'Concierto', value: 'concierto' },
{ label: 'Exposición', value: 'exposicion' },
{ label: 'Deportivo', value: 'deportivo' },
{ label: 'Cultural', value: 'cultural' },
{ label: 'Otro', value: 'otro' },
]}
/>
<div className="col-md-6">
<Input
label="Fecha de inicio"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(evento.fecha_inicio)}
onChange={(e) =>
handleChange('fecha_inicio', new Date(e.target.value))
}
/>
</div>
<div className="col-md-6">
<Input
label="Fecha de fin"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(evento.fecha_fin)}
onChange={(e) => handleChange('fecha_fin', new Date(e.target.value))}
/>
</div>
</div>
);
}
+378
View File
@@ -0,0 +1,378 @@
import React from 'react';
import {
FormularioCreacion,
TipoPregunta,
TiposValidacion,
} from '@/types/create-formulario';
import SimpleInput from '@/components/input';
import Select from '@/components/select';
import Button from '@/components/button';
interface Props {
formulario: FormularioCreacion;
onChange: (formularioActualizado: FormularioCreacion) => void;
}
const tipoPreguntaOpciones: TipoPregunta[] = [
'Abierta (Respuesta corta)',
'Abierta (Parrafo)',
'Cerrada',
'Multiple',
];
const tiposValidacion: TiposValidacion[] = [
'correo',
'correo_institucional',
'telefono',
'nombre',
'apellidos',
'genero',
'entero',
'decimal',
'comunidad_alumno',
'cuenta_alumno',
'comunidad_trabajador',
'cuenta_trabajador',
'rfc',
];
type CampoPreguntaSimple =
| 'titulo'
| 'tipo'
| 'validacion'
| 'obligatoria'
| 'limite';
type CampoPreguntaOpciones = 'opciones';
const tiposCuestionario = [
{ label: 'Registro', value: 1 },
{ label: 'Evaluación', value: 2 },
];
export default function FormularioEditor({ formulario, onChange }: Props) {
function actualizarCampo<K extends keyof FormularioCreacion>(
campo: K,
valor: FormularioCreacion[K]
): void {
const actualizado = { ...formulario, [campo]: valor };
onChange(actualizado);
}
function handleUpdatePregunta(
seccionIdx: number,
preguntaIdx: number,
campo: CampoPreguntaSimple | CampoPreguntaOpciones,
valor:
| string
| boolean
| TipoPregunta
| TiposValidacion
| number
| { valor: string }[]
| undefined
) {
const actualizado = { ...formulario };
const pregunta = actualizado.secciones[seccionIdx].preguntas[preguntaIdx];
if (campo === 'opciones') {
pregunta.opciones = valor as { valor: string }[];
} else if (campo === 'titulo' && typeof valor === 'string') {
pregunta.titulo = valor;
} else if (campo === 'tipo' && typeof valor === 'string') {
pregunta.tipo = valor as TipoPregunta;
} else if (campo === 'validacion' || valor === undefined) {
pregunta.validacion = valor as TiposValidacion | undefined;
} else if (campo === 'obligatoria' && typeof valor === 'boolean') {
pregunta.obligatoria = valor;
} else if (campo === 'limite' && typeof valor === 'number') {
pregunta.limite = valor;
}
onChange(actualizado);
}
const handleUpdateSeccion = (
index: number,
campo: 'titulo' | 'descripcion',
valor: string
) => {
const actualizado = { ...formulario };
actualizado.secciones = actualizado.secciones.map((seccion, idx) =>
idx === index ? { ...seccion, [campo]: valor } : seccion
);
onChange(actualizado);
};
const agregarSeccion = () => {
const nuevaSeccion = {
titulo: '',
descripcion: '',
preguntas: [],
};
onChange({
...formulario,
secciones: [...formulario.secciones, nuevaSeccion],
});
};
const eliminarSeccion = (index: number) => {
const nuevas = [...formulario.secciones];
nuevas.splice(index, 1);
onChange({ ...formulario, secciones: nuevas });
};
const agregarPregunta = (seccionIdx: number) => {
const nuevaPregunta = {
titulo: '',
tipo: 'Abierta (Respuesta corta)' as TipoPregunta,
obligatoria: false,
opciones: [],
};
const actualizado = { ...formulario };
actualizado.secciones[seccionIdx].preguntas.push(nuevaPregunta);
onChange(actualizado);
};
const eliminarPregunta = (seccionIdx: number, preguntaIdx: number) => {
const actualizado = { ...formulario };
actualizado.secciones[seccionIdx].preguntas.splice(preguntaIdx, 1);
onChange(actualizado);
};
return (
<div className="row border p-3 rounded my-4">
<h2 className="mb-3">Editar Formulario</h2>
<div className="col-md-8">
<SimpleInput
label="Nombre del Formulario"
value={formulario.nombre_form}
onChange={(e) => actualizarCampo('nombre_form', e.target.value)}
/>
</div>
<div className="col-md-4">
<SimpleInput
label="Cupo máximo (Si no se requiere, dejar en blanco)"
type="number"
required
value={formulario.cupo_maximo}
onChange={(e) =>
actualizarCampo('cupo_maximo', Number(e.target.value))
}
/>
</div>
<SimpleInput
label="Descripción del Formulario"
value={formulario.descripcion}
onChange={(e) => actualizarCampo('descripcion', e.target.value)}
/>
<div className="row">
<div className="col-md-6">
<SimpleInput
label="Fecha de Inicio"
type="date"
value={formulario.fecha_inicio.slice(0, 16)}
onChange={(e) => actualizarCampo('fecha_inicio', e.target.value)}
/>
</div>
<div className="col-md-6">
<SimpleInput
label="Fecha de Fin"
type="date"
value={formulario.fecha_fin.slice(0, 16)}
onChange={(e) => actualizarCampo('fecha_fin', e.target.value)}
/>
</div>
</div>
<Select
label="Tipo de Cuestionario"
value={formulario.id_tipo_cuestionario}
onChange={(e) =>
actualizarCampo('id_tipo_cuestionario', Number(e.target.value))
}
options={tiposCuestionario}
placeholder="Selecciona un tipo"
/>
<hr className="my-4" />
{/* Secciones */}
{formulario.secciones.map((seccion, seccionIdx) => (
<div key={seccionIdx} className="mb-4 p-3 border rounded bg-light">
<div className="d-flex justify-content-between align-items-center mb-2">
<h4 className="mb-0">Sección {seccionIdx + 1}</h4>
<Button
variant="danger"
icon="trash"
size="sm"
outline
onClick={() => eliminarSeccion(seccionIdx)}
>
Eliminar sección
</Button>
</div>
<SimpleInput
label="Título de la sección"
value={seccion.titulo}
onChange={(e) =>
handleUpdateSeccion(seccionIdx, 'titulo', e.target.value)
}
/>
<SimpleInput
label="Descripción de la sección"
value={seccion.descripcion}
onChange={(e) =>
handleUpdateSeccion(seccionIdx, 'descripcion', e.target.value)
}
/>
{/* Preguntas */}
{seccion.preguntas.map((pregunta, preguntaIdx) => (
<div key={preguntaIdx} className="mb-3 p-3 border rounded bg-white">
<div className="d-flex justify-content-between align-items-center mb-2">
<h6 className="mb-0">Pregunta {preguntaIdx + 1}</h6>
<Button
variant="danger"
icon="trash"
size="sm"
outline
onClick={() => eliminarPregunta(seccionIdx, preguntaIdx)}
>
Eliminar
</Button>
</div>
<SimpleInput
label="Título de la pregunta"
value={pregunta.titulo}
onChange={(e) =>
handleUpdatePregunta(
seccionIdx,
preguntaIdx,
'titulo',
e.target.value
)
}
/>
<div className="row">
<div className="col-md-6">
<Select
label="Tipo de pregunta"
value={pregunta.tipo}
onChange={(e) =>
handleUpdatePregunta(
seccionIdx,
preguntaIdx,
'tipo',
e.target.value
)
}
options={tipoPreguntaOpciones.map((tipo) => ({
label: tipo,
value: tipo,
}))}
/>
</div>
<div className="col-md-6">
<Select
label="Validación"
value={pregunta.validacion || ''}
onChange={(e) =>
handleUpdatePregunta(
seccionIdx,
preguntaIdx,
'validacion',
e.target.value || undefined
)
}
options={[
{ label: 'Ninguna', value: '' },
...tiposValidacion.map((v) => ({
label: v,
value: v,
})),
]}
/>
</div>
</div>
<div className="form-check mb-2">
<input
className="form-check-input"
type="checkbox"
checked={pregunta.obligatoria}
id={`obligatoria-${seccionIdx}-${preguntaIdx}`}
onChange={(e) =>
handleUpdatePregunta(
seccionIdx,
preguntaIdx,
'obligatoria',
e.target.checked
)
}
/>
<label
className="form-check-label"
htmlFor={`obligatoria-${seccionIdx}-${preguntaIdx}`}
>
¿Es obligatoria?
</label>
</div>
{(pregunta.tipo === 'Cerrada' ||
pregunta.tipo === 'Multiple') && (
<div className="mb-2">
<label className="form-label">Opciones</label>
{(pregunta.opciones || []).map((op, opIdx) => (
<SimpleInput
key={opIdx}
value={op.valor}
onChange={(e) => {
const nuevasOpciones = [...(pregunta.opciones || [])];
nuevasOpciones[opIdx].valor = e.target.value;
handleUpdatePregunta(
seccionIdx,
preguntaIdx,
'opciones',
nuevasOpciones
);
}}
placeholder={`Opción ${opIdx + 1}`}
/>
))}
</div>
)}
</div>
))}
<Button
outline
className="mt-2"
size="sm"
icon="plus"
onClick={() => agregarPregunta(seccionIdx)}
>
Agregar pregunta
</Button>
</div>
))}
<div className="text-center mt-4">
<Button
className="btn btn-success"
onClick={agregarSeccion}
icon="plus"
>
Agregar sección
</Button>
</div>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
'use client';
import ImageUploader from '@/components/banner-uploader';
import Button from '@/components/button';
import Input from '@/components/input';
import Select from '@/components/select';
import { CreateEventoType } from '@/types/create-evento';
import { GetEventoWithCuestionarios } from '@/types/evento';
import axiosInstance from '@/utils/api-config';
import { formatDateLocal } from '@/utils/date-utils';
import { getAxiosError } from '@/utils/errors-utils';
import { commands } from '@uiw/react-md-editor';
import dynamic from 'next/dynamic';
import React, { useState } from 'react';
import toast from 'react-hot-toast';
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
interface EditEventoProps {
evento: GetEventoWithCuestionarios;
handleChange: (field: keyof CreateEventoType, value: string | Date) => void;
handleOnChange: (eventoActualizado: GetEventoWithCuestionarios) => void;
}
export default function EditEvento({
evento,
handleChange,
handleOnChange,
}: EditEventoProps) {
const [loading, setLoading] = useState(false);
const [nuevoBanner, setNuevoBanner] = useState<File | null>(null);
const handleOnSave = async () => {
setLoading(true);
try {
// 1. Subir nuevo banner si fue cambiado
if (nuevoBanner) {
const formData = new FormData();
formData.append('banner', nuevoBanner);
await axiosInstance.post(
`/evento/${evento.id_evento}/banner`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
}
// 2. Actualizar el evento (sin incluir 'banner')
const res = await axiosInstance.patch(`/evento/${evento.id_evento}`, {
nombre_evento: evento.nombre_evento,
descripcion_evento: evento.descripcion_evento,
tipo_evento: evento.tipo_evento,
fecha_inicio: evento.fecha_inicio,
fecha_fin: evento.fecha_fin,
});
toast.success('Evento actualizado correctamente');
handleOnChange(res.data); // Actualiza en el padre
} catch (error) {
const msg = getAxiosError(error);
toast.error(`Error al actualizar el evento: ${msg.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="row border p-3 rounded my-4">
<h2>Editar Información del Evento</h2>
<p>Modifica los datos del evento que desees actualizar.</p>
<ImageUploader
label="Banner del evento"
defaultBanner={
evento.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
: undefined
}
onChange={(file) => {
setNuevoBanner(file);
}}
/>
<Input
label="Nombre del evento"
required
value={evento.nombre_evento}
onChange={(e) => handleChange('nombre_evento', e.target.value)}
/>
<div className="mb-4">
<label className="form-label">Descripción del evento</label>
<div data-color-mode="light">
<MDEditor
value={evento.descripcion_evento ?? ''}
onChange={(value) => {
const texto = value || '';
if (texto.length <= 500) {
handleChange('descripcion_evento', texto);
}
}}
height={250}
commands={[commands.bold, commands.italic, commands.hr]}
/>
</div>
</div>
<Select
label="Tipo de evento"
value={evento.tipo_evento}
placeholder="Selecciona un tipo de evento"
onChange={(e) => handleChange('tipo_evento', e.target.value)}
options={[
{ label: 'Conferencia', value: 'conferencia' },
{ label: 'Taller', value: 'taller' },
{ label: 'Seminario', value: 'seminario' },
{ label: 'Curso', value: 'curso' },
{ label: 'Webinar', value: 'webinar' },
{ label: 'Reunión', value: 'reunion' },
{ label: 'Feria', value: 'feria' },
{ label: 'Concierto', value: 'concierto' },
{ label: 'Exposición', value: 'exposicion' },
{ label: 'Deportivo', value: 'deportivo' },
{ label: 'Cultural', value: 'cultural' },
{ label: 'Otro', value: 'otro' },
]}
/>
<div className="col-md-6">
<Input
label="Fecha de inicio"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(new Date(evento.fecha_inicio))}
onChange={(e) =>
handleChange('fecha_inicio', new Date(e.target.value))
}
/>
</div>
<div className="col-md-6">
<Input
label="Fecha de fin"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(new Date(evento.fecha_fin))}
onChange={(e) => handleChange('fecha_fin', new Date(e.target.value))}
/>
</div>
<div>
<Button icon="save" disabled={loading} onClick={handleOnSave}>
{loading ? 'Guardando...' : 'Guardar Cambios'}
</Button>
</div>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
import ImageUploader from '@/components/banner-uploader';
import Button from '@/components/button';
import SimpleInput from '@/components/input';
import { GetCuestionario } from '@/types/cuestionario';
import axiosInstance from '@/utils/api-config';
import { formatDateLocal } from '@/utils/date-utils';
import { getAxiosError } from '@/utils/errors-utils';
import MDEditor, { commands } from '@uiw/react-md-editor';
import React, { useState } from 'react';
import toast from 'react-hot-toast';
interface EditFormularioProps {
cuestionario: GetCuestionario;
handleChange: (field: keyof GetCuestionario, value: string | Date) => void;
handleOnChange: (eventoActualizado: GetCuestionario) => void;
}
export default function EditFormulario({
cuestionario,
handleChange,
}: EditFormularioProps) {
const [loading, setLoading] = useState(false);
const [nuevoBanner, setNuevoBanner] = useState<File | null>(null);
const handleOnSave = async () => {
setLoading(true);
try {
// 1. Subir nuevo banner si fue cambiado
if (nuevoBanner) {
const formData = new FormData();
formData.append('banner', nuevoBanner);
await axiosInstance.post(
`/cuestionario/${cuestionario.id_cuestionario}/banner`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
}
// 2. Actualizar el evento (sin incluir 'banner')
const res = await axiosInstance.patch(
`/cuestionario/${cuestionario.id_cuestionario}`,
{
nombre_form: cuestionario.nombre_form,
descripcion: cuestionario.descripcion,
fecha_inicio: cuestionario.fecha_inicio,
fecha_fin: cuestionario.fecha_fin,
}
);
console.log('Evento actualizado:', res.data);
toast.success('Evento actualizado correctamente');
} catch (error) {
const msg = getAxiosError(error);
toast.error(`Error al actualizar el evento: ${msg.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="row border p-3 rounded my-4">
<h2>Editar Información del formulario</h2>
<p>Modifica los datos del evento que desees actualizar.</p>
<ImageUploader
label="Banner del formulario"
defaultBanner={
cuestionario.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`
: undefined
}
onChange={(file) => {
setNuevoBanner(file);
}}
/>
<div className="col-md-8">
<SimpleInput
label="Nombre del formulario"
required
value={cuestionario.nombre_form}
onChange={(e) => handleChange('nombre_form', e.target.value)}
/>
</div>
<div className="col-md-4">
<SimpleInput
label="Cupo máximo (Si no se requiere, dejar en blanco)"
type="number"
value={cuestionario.cupo_maximo ?? ''}
onChange={(e) =>
handleChange('cupo_maximo', e.target.value ? e.target.value : '')
}
/>
</div>
<div className="mb-4">
<label className="form-label">Descripción del evento</label>
<div data-color-mode="light">
<MDEditor
value={cuestionario.descripcion ?? ''}
onChange={(value) => {
const texto = value || '';
if (texto.length <= 500) {
handleChange(
'descripcion_evento' as keyof GetCuestionario,
texto
);
}
}}
height={250}
commands={[commands.bold, commands.italic, commands.hr]}
/>
</div>
</div>
<div className="col-md-6">
<SimpleInput
label="Fecha de inicio"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(new Date(cuestionario.fecha_inicio))}
onChange={(e) =>
handleChange('fecha_inicio', new Date(e.target.value))
}
/>
</div>
<div className="col-md-6">
<SimpleInput
label="Fecha de fin"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(new Date(cuestionario.fecha_fin))}
onChange={(e) => handleChange('fecha_fin', new Date(e.target.value))}
/>
</div>
<div>
<Button icon="save" disabled={loading} onClick={handleOnSave}>
{loading ? 'Guardando...' : 'Guardar Cambios'}
</Button>
</div>
</div>
);
}
-407
View File
@@ -1,407 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import { CuestionarioSeccion } from '@/types/responder-formulario';
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
import Input from '@/components/input';
import Button from '@/components/button';
import { validarRespuesta } from '@/utils/validador';
import toast from 'react-hot-toast';
import axiosInstance from '@/utils/api-config';
import { useRouter } from 'next/navigation';
import { getAxiosError } from '@/utils/errors-utils';
interface DatosAlumno {
id_ncuenta: number;
nombre: string;
apellidos: string;
carrera: string;
genero: 'M' | 'F';
}
export default function Formulario({
id_formulario,
secciones,
}: {
id_formulario: number;
secciones: CuestionarioSeccion[];
}) {
const router = useRouter();
const [esDeFES, setEsDeFES] = useState<boolean | null>(null);
const [cuenta, setCuenta] = useState('');
const [datosAuto, setDatosAuto] = useState<null | {
nombre: string;
apellidos: string;
correo: string;
genero: 'M' | 'F';
}>(null);
const [respuestas, setRespuestas] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false); // New state for submission
const actualizarRespuesta = (idPregunta: string, valor: string) => {
setRespuestas((prev) => ({ ...prev, [idPregunta]: valor }));
};
useEffect(() => {
if (esDeFES && cuenta.length === 9) {
(async () => {
try {
setDatosAuto(null);
const { data } = await axiosInstance.get<DatosAlumno>(
`/alumnos/${cuenta}`
);
console.log('Datos del alumno:', data);
if (data) {
setDatosAuto({
nombre: data.nombre.toString(),
apellidos: data.apellidos.toString(),
correo: data.id_ncuenta + '@pcpuma.acatlan.unam.mx',
genero: data.genero,
});
}
} catch (err) {
const msg = getAxiosError(err);
toast.error(msg.message);
setDatosAuto(null);
}
})();
}
}, [cuenta, esDeFES]);
useEffect(() => {
if (esDeFES && datosAuto) {
const nuevasRespuestas: Record<string, string> = {};
secciones.forEach((seccion) => {
seccion.preguntas.forEach(({ pregunta }) => {
const id = pregunta.id_pregunta;
if (pregunta.validacion === 'nombre') {
nuevasRespuestas[`pregunta_${id}`] = pregunta.pregunta
.toLowerCase()
.includes('apellido')
? datosAuto.apellidos.toString()
: datosAuto.nombre.toString();
}
if (pregunta.validacion === 'correo') {
nuevasRespuestas[`pregunta_${id}`] = datosAuto.correo;
}
if (pregunta.validacion === 'cuenta_alumno') {
nuevasRespuestas[`pregunta_${id}`] = cuenta;
}
if (
pregunta.pregunta
.toLowerCase()
.includes('institución de procedencia')
) {
nuevasRespuestas[`pregunta_${id}`] = 'FES Acatlán';
}
});
});
setRespuestas((prev) => ({
...prev,
...nuevasRespuestas,
}));
}
}, [datosAuto, esDeFES, cuenta, secciones]);
// Encuentra preguntas por texto o validación
const todasPreguntas = secciones.flatMap((s) =>
s.preguntas.map((p) => p.pregunta)
);
const preguntaFES = todasPreguntas.find(
(p) => p.pregunta === '¿Eres parte de la comunidad de la FES Acatlán?'
);
const preguntaCuenta = todasPreguntas.find(
(p) => p.validacion === 'cuenta_alumno'
);
const preguntasRestantes = todasPreguntas.filter(
(p) =>
![preguntaFES?.id_pregunta, preguntaCuenta?.id_pregunta].includes(
p.id_pregunta
)
);
const respuestaFES = respuestas[`pregunta_${preguntaFES?.id_pregunta}`];
const formatearRespuestas = async () => {
if (!validarRespuestasObligatorias()) return;
const respuestasArray = Object.entries(respuestas).flatMap(
([key, valor]) => {
const idNumerico = Number(key.replace('pregunta_', ''));
const pregunta = secciones
.flatMap((s) => s.preguntas)
.find((p) => p.pregunta.id_pregunta === idNumerico);
const tipo = pregunta?.pregunta.tipo_pregunta.tipo_pregunta;
// Si es múltiple, convertir cada valor individual si corresponde
if (Array.isArray(valor)) {
return valor.map((v) => ({
id_pregunta: idNumerico,
valor: tipo === 'Cerrada' || tipo === 'Multiple' ? Number(v) : v,
}));
}
return [
{
id_pregunta: idNumerico,
valor: tipo === 'Cerrada' ? Number(valor) : valor,
},
];
}
);
const correoEntry = respuestasArray.find(
(r) =>
typeof r.valor === 'string' &&
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r.valor)
);
const resultado = {
id_formulario: Number(id_formulario),
correo: correoEntry?.valor || 'correo@no-encontrado.com',
respuestas: respuestasArray,
fecha_envio: new Date().toISOString(),
};
console.log('Resultado a enviar:', resultado);
setIsSubmitting(true);
toast
.promise(
axiosInstance.post('/cuestionario-respondido/submit', resultado),
{
loading: 'Enviando formulario...',
success: 'Formulario enviado con éxito!',
error: 'Error al enviar el formulario.',
}
)
.then(() => router.push('/'))
.finally(() => setIsSubmitting(false));
};
const validarRespuestasObligatorias = (): boolean => {
const faltantes: number[] = [];
const errores: { id: number; mensaje: string }[] = [];
console.log(secciones);
console.log('Validando respuestas:', respuestas);
secciones.forEach((seccion) => {
seccion.preguntas.forEach(({ pregunta }) => {
const id = pregunta.id_pregunta;
const valor = respuestas[`pregunta_${id}`];
const tipo = pregunta.tipo_pregunta.tipo_pregunta;
const validacion = pregunta.validacion;
console.log('Validando pregunta:', pregunta.pregunta);
console.table({
id,
valor,
tipo,
validacion,
});
const respondida =
(tipo === 'Abierta' &&
typeof valor === 'string' &&
valor.trim() !== '') ||
(tipo === 'Cerrada' && valor !== undefined) ||
(tipo === 'Multiple' && Array.isArray(valor) && valor.length > 0);
if (!respondida && pregunta.obligatoria) {
faltantes.push(id);
}
if (tipo === 'Abierta' && validacion && typeof valor === 'string') {
const resultado = validarRespuesta(valor, validacion);
if (!resultado.valido) {
errores.push({ id, mensaje: resultado.mensaje });
}
}
});
});
if (faltantes.length > 0) {
console.log('Faltan preguntas obligatorias:', faltantes);
toast.error('Responde todas las preguntas obligatorias antes de enviar.');
return false;
}
if (errores.length > 0) {
toast.error(
`Corrige las respuestas con error de formato:\n${errores
.map((e) => `${e.mensaje}`)
.join('\n')}`
);
return false;
}
return true;
};
return (
<form className="container py-4">
{/* Pregunta inicial */}
{preguntaFES && (
<div className="mb-4">
<label className="form-label">{preguntaFES.pregunta}</label>
<RadioOptionGroup
name="comunidad_fes"
options={preguntaFES.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}))}
selectedValue={respuestaFES ? Number(respuestaFES) : undefined}
onChange={(opt) => {
const id = opt.value;
const value = opt?.label ?? '';
setEsDeFES(value === 'Si'); // Se actualiza esDeFES con el texto
setCuenta('');
setDatosAuto(null);
actualizarRespuesta(
`pregunta_${preguntaFES.id_pregunta}`,
String(id)
);
}}
/>
</div>
)}
{/* Si dijo que sí, pedir número de cuenta */}
{esDeFES && preguntaCuenta && (
<Input
key={preguntaCuenta.id_pregunta}
label={preguntaCuenta.pregunta}
name={`pregunta_${preguntaCuenta.id_pregunta}`}
value={cuenta}
maxLength={10}
onChange={(e) => {
const value = e.target.value;
setCuenta(value);
actualizarRespuesta(
`pregunta_${preguntaCuenta.id_pregunta}`,
value
); // ✅ Guarda la respuesta
}}
/>
)}
{/* Si ya tiene datos o dijo que no es de la FES, mostrar el resto */}
{(esDeFES === false || (esDeFES && datosAuto)) &&
preguntasRestantes.map((p) => {
if (p.tipo_pregunta.tipo_pregunta === 'Abierta') {
let defaultValue = '';
let disabled = false;
if (esDeFES && datosAuto) {
if (p.validacion === 'nombre') {
defaultValue = p.pregunta.toLowerCase().includes('apellido')
? datosAuto.apellidos
: datosAuto.nombre;
disabled = true;
}
if (p.validacion === 'correo') {
defaultValue = datosAuto.correo;
disabled = true;
}
if (p.validacion === 'cuenta_alumno') {
defaultValue = cuenta;
disabled = true;
}
if (
p.pregunta.toLowerCase().includes('institución de procedencia')
) {
defaultValue = 'FES Acatlán';
disabled = true;
}
}
return (
<React.Fragment key={p.id_pregunta}>
<Input
label={p.pregunta}
name={`pregunta_${p.id_pregunta}`}
defaultValue={defaultValue}
disabled={disabled}
onChange={(e) =>
actualizarRespuesta(
`pregunta_${p.id_pregunta}`,
e.target.value
)
}
/>
{p.validacion === 'correo' && (
<div className="alert alert-info">
Este es el correo donde enviaremos la validación de registro{' '}
</div>
)}
</React.Fragment>
);
}
if (p.tipo_pregunta.tipo_pregunta === 'Cerrada') {
const opciones: RadioOption<number>[] = p.opciones.map((op) => ({
label: op.opcion.opcion,
value: op.id_opcion,
}));
let respuestaActual = respuestas[`pregunta_${p.id_pregunta}`];
// Si es de FES y hay datos automáticos, intentar preseleccionar género
if (
esDeFES &&
datosAuto &&
!respuestaActual &&
p.pregunta.toLowerCase().includes('género')
) {
const generoTexto =
datosAuto.genero === 'M' ? 'Masculino' : 'Femenino';
const opcionGenero = p.opciones.find(
(op) =>
op.opcion.opcion.toLowerCase() === generoTexto.toLowerCase()
);
if (opcionGenero) {
respuestaActual = String(opcionGenero.id_opcion);
actualizarRespuesta(
`pregunta_${p.id_pregunta}`,
String(respuestaActual)
);
}
}
return (
<div key={p.id_pregunta}>
<label className="form-label">{p.pregunta}</label>
<RadioOptionGroup
name={`pregunta_${p.id_pregunta}`}
options={opciones}
selectedValue={
respuestaActual ? Number(respuestaActual) : undefined
}
onChange={(opt) =>
actualizarRespuesta(
`pregunta_${p.id_pregunta}`,
String(opt.value)
)
}
/>
</div>
);
}
return null;
})}
{/* Botón de enviar */}
<Button onClick={formatearRespuestas} disabled={isSubmitting}>
{isSubmitting ? 'Enviando...' : 'Enviar'}
</Button>
</form>
);
}
@@ -0,0 +1,506 @@
import React, { useState, useEffect } from 'react';
import MarkdownRenderer from '@/components/markdown-render';
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';
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,
}: {
id_cuestionario: number;
}) {
// ------------------------
// 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'
);
// ------------------------
// Efecto: buscar información de cuenta
// ------------------------
useEffect(() => {
const cuenta = respuestas[preguntaCuenta?.id_pregunta ?? ''];
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 ?? '']]);
// ------------------------
// Efecto: precargar respuestas si hay cuentaInfo
// ------------------------
useEffect(() => {
if (!cuentaInfo) {
if (preguntas) {
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 (preguntas) {
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]);
// ------------------------
// Funciones auxiliares
// ------------------------
const actualizarRespuesta = (idPregunta: string, valor: string) => {
setRespuestas((prev) => ({ ...prev, [idPregunta]: valor }));
};
const handleOnSubmit = async () => {
console.log('Enviando respuestas:', respuestas);
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.');
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(
'/cuestionario-respondido/submit',
payload
);
toast.success(res.data.message || 'Formulario enviado correctamente');
} catch (error) {
console.log(getAxiosError(error));
toast.error('Hubo un error al enviar el formulario');
} finally {
// Redirigir al usuario después de enviar el formulario
router.push('/');
}
};
// ------------------------
// Condición para mostrar el formulario
// ------------------------
const mostrarFormularioRestante =
(isComunidad &&
confirmativeOption.includes(isComunidad.label) &&
!!cuentaInfo) ||
(isComunidad && negativeOption.includes(isComunidad.label));
if (error) {
console.error('Error al cargar el cuestionario:', error);
return <div>Error al cargar el cuestionario</div>;
}
return (
<div className="preguntas">
<hr />
<h2 className="text-xl font-bold">{data?.cuestionario.nombre_form}</h2>
{data?.cuestionario.descripcion && (
<MarkdownRenderer markdown={data.cuestionario.descripcion} />
)}
{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.includes(isComunidad?.label) &&
preguntaCuenta && (
<div className="my-4">
<label
className={`form-label ${
preguntaCuenta.obligatoria ? 'required' : ''
}`}
>
{preguntaCuenta.pregunta}
</label>
<SimpleInput
type="text"
name="cuenta"
placeholder="Ingrese su cuenta"
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 (
confirmativeOption.includes(isComunidad?.label) &&
cuentaInfo &&
respuestaActual === undefined // solo si no ha respondido
) {
if (pregunta.pregunta.validacion === 'genero') {
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>
);
}
@@ -0,0 +1,80 @@
import React from 'react';
import SimpleInput from '@/components/input';
type Props = {
idPregunta: number;
enunciado: string;
obligatorio?: boolean;
validacion?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
respuestas: Record<string, any>;
cuentaInfo: {
nombre: string;
apellidos: string;
correo: string;
genero: 'M' | 'F';
carrera: string;
} | null;
isComunidad: string | undefined;
onChange: (id: string, valor: string) => void;
preguntaComunidad: string;
};
const confirmativeOption = ['Sí', 'Si', 'Si, claro', 'Claro que sí'];
export default function PrefetchAbiertaCorta({
idPregunta,
enunciado,
validacion,
respuestas,
cuentaInfo,
isComunidad,
obligatorio,
onChange,
preguntaComunidad,
}: Props) {
console.log(preguntaComunidad);
let defaultValue = '';
let disabled = false;
if (confirmativeOption.includes(isComunidad ?? '') && cuentaInfo) {
if (validacion === 'nombre' && cuentaInfo.nombre) {
defaultValue = cuentaInfo.nombre;
disabled = true;
}
if (validacion === 'apellidos' && cuentaInfo.apellidos) {
defaultValue = cuentaInfo.apellidos;
disabled = true;
}
if (validacion === 'correo' && cuentaInfo.correo) {
defaultValue = cuentaInfo.correo;
if (preguntaComunidad !== 'comunidad_trabajador') {
disabled = true;
}
}
if (validacion === 'institucion') {
defaultValue = 'FES Acatlán';
disabled = true;
}
if (validacion === 'carrera' && cuentaInfo.carrera) {
defaultValue = cuentaInfo.carrera;
disabled = true;
}
}
return (
<div className="my-4">
<label className={`form-label ${obligatorio ? 'required' : ''}`}>
{enunciado}
</label>
<SimpleInput
type="text"
name={`respuesta_${idPregunta}`}
placeholder="Ingrese una respuesta"
value={respuestas[idPregunta] ?? defaultValue}
disabled={disabled}
onChange={(e) => onChange(idPregunta.toString(), e.target.value)}
/>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
'use client';
import MarkdownRenderer from '@/components/markdown-render';
import { useGetApi } from '@/hooks/use-get-api';
import Image from 'next/image';
import React from 'react';
import FormularioRegistro from '../formulario/formulario-registro';
import { GetEventoWithCuestionario } from '@/types/evento';
import { formatFecha } from '@/utils/date-utils';
export default function FormularioPage({
id_evento,
id_cuestionario,
}: {
id_evento: string;
id_cuestionario: string;
}) {
const { data, error } = useGetApi<GetEventoWithCuestionario>(
`/evento/${id_evento}/cuestionario/${id_cuestionario}`
);
if (error) {
return <div>Error al cargar el cuestionario: {error.message}</div>;
}
return (
<div className="container my-5">
<div className="text-center mb-4">
{data?.banner && (
<Image
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`}
alt={data?.nombre_evento || ''}
width={800}
height={400}
className="img-fluid rounded"
/>
)}
</div>
<h1>{data?.nombre_evento}</h1>
{data?.descripcion_evento && (
<MarkdownRenderer markdown={data?.descripcion_evento} />
)}
{data?.fecha_fin && data?.fecha_inicio && (
<>
<p className="mb-0 mt-2 text-muted">Horario:</p>
<p className="text-muted">
<i className="bi bi-clock me-1 text-primary"></i>
{formatFecha(data.fecha_inicio!, {
horas: true,
minutos: true,
})}{' '}
al{' '}
{formatFecha(data.fecha_fin!, {
horas: true,
minutos: true,
})}
</p>
</>
)}
{data && (
<FormularioRegistro
id_cuestionario={data?.cuestionario.id_cuestionario}
/>
)}
</div>
);
}
+107 -23
View File
@@ -1,4 +1,4 @@
import { FormularioCreacion } from '@/types/crear-formulario';
import { FormularioCreacion } from '@/types/create-formulario';
interface Plantilla {
imagen: string;
@@ -9,17 +9,16 @@ interface Plantilla {
export const plantillasDisponibles: Plantilla[] = [
{
imagen: '/banner1.png',
nombre: 'Registro para la Feria',
id: 'feria-sexualidad',
imagen: '/plantilla_trabajadores.png',
nombre: 'Registro Comunidad Trabajador',
id: 'registro-comunidad-estudiantil',
datos: {
nombre_form: 'Registro para la Feria de la Sexualidad - FES Acatlán',
nombre_form: 'Registro',
descripcion:
'La Feria de la Sexualidad “Placer sí, Responsabilidad también” es un espacio lúdico seguro e informativo dirigido a toda la comunidad universitaria. Regístrate y confirma tu asistencia, así podrás obtener tu constancia de asistencia.',
fecha_inicio: '2025-03-07T00:00:01',
fecha_fin: '2025-03-18T23:59:59',
id_tipo_cuestionario: 1,
evento: 'Feria de la Sexualidad',
'Con este formulario estaras reservando tu lugar en el evento. 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',
@@ -31,39 +30,107 @@ export const plantillasDisponibles: Plantilla[] = [
tipo: 'Cerrada',
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
validacion: 'comunidad_trabajador',
},
{
titulo: 'Numero de cuenta',
tipo: 'Abierta',
titulo: 'RFC',
tipo: 'Abierta (Respuesta corta)',
obligatoria: false,
limite: 250,
validacion: 'cuenta_alumno',
validacion: 'rfc',
},
{
titulo: 'Correo electrónico',
tipo: 'Abierta',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
limite: 250,
validacion: 'correo',
},
{
titulo: 'Nombre(s)',
tipo: 'Abierta',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
limite: 250,
validacion: 'nombre',
},
{
titulo: 'Apellidos',
tipo: 'Abierta',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
limite: 250,
validacion: 'nombre',
validacion: 'apellidos',
},
{
titulo: 'Género',
tipo: 'Cerrada',
obligatoria: true,
validacion: 'genero',
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
{
titulo: 'Carrera',
tipo: 'Abierta (Respuesta corta)',
validacion: 'carrera',
obligatoria: true,
},
],
},
],
},
},
{
imagen: '/plantilla_alumnos.png',
nombre: 'Registro Comunidad Alumnos',
id: 'registro-comunidad-alumnos',
datos: {
nombre_form: 'Registro',
descripcion:
'Con este formulario estaras reservando tu lugar en el evento. 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 para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
tipo: 'Cerrada',
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
validacion: 'comunidad_alumno',
},
{
titulo: 'Numero de cuenta',
tipo: 'Abierta (Respuesta corta)',
obligatoria: false,
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: 'Género',
tipo: 'Cerrada',
obligatoria: true,
validacion: 'genero',
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
@@ -72,9 +139,15 @@ export const plantillasDisponibles: Plantilla[] = [
},
{
titulo: 'Institución de procedencia',
tipo: 'Abierta',
tipo: 'Abierta (Respuesta corta)',
validacion: 'institucion',
obligatoria: true,
},
{
titulo: 'Carrera',
tipo: 'Abierta (Respuesta corta)',
validacion: 'carrera',
obligatoria: true,
limite: 250,
},
],
},
@@ -82,3 +155,14 @@ export const plantillasDisponibles: Plantilla[] = [
},
},
];
/*
¡Bienvenidas y bienvenidos a la Facultad!
Inicia esta nueva etapa con un recorrido especial por nuestras instalaciones, pensado para que conozcas tu nueva casa universitaria. Te acompañaremos por aulas, laboratorios, bibliotecas, áreas comunes y servicios que estarán a tu disposición durante tu formación. Además, podrás resolver dudas y conocer a quienes serán parte de tu camino académico.
¡No faltes, esta actividad marcará el inicio de grandes experiencias!
*/
/*
Recorrido de bienvenida para nuevo personal
Damos la más cordial bienvenida al personal de nuevo ingreso a nuestra Facultad. Este recorrido está diseñado para familiarizarlos con las principales áreas administrativas, académicas y de servicios. También tendrán la oportunidad de conocer al equipo de trabajo y obtener información clave para integrarse con éxito a la comunidad universitaria.
*/
+31
View File
@@ -0,0 +1,31 @@
import axiosInstance from '@/utils/api-config';
import { getAxiosError } from '@/utils/errors-utils';
import { useEffect, useState } from 'react';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const useGetApi = <T,>(url: string, params?: Record<string, any>) => {
const [data, setData] = useState<T>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ErrorState | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const res = await axiosInstance.get<T>(url, { params });
setData(res.data);
setError(null);
} catch (err) {
console.error(err);
const message = getAxiosError(err);
setError(message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url, params]);
return { data, setData, loading, error };
};
+38 -2
View File
@@ -28,6 +28,13 @@ $theme-colors: map-merge($theme-colors, $custom-colors);
@import 'hover';
@import 'tabs';
@import 'animation';
@import './global.scss';
.carousel-control-prev-icon,
.carousel-control-next-icon {
filter: invert(100%) sepia(100%) saturate(0%) hue-rotate(0deg)
brightness(200%);
}
//Add additional custom code here
@@ -72,6 +79,7 @@ ul.list-unstyled li {
position: relative;
overflow: hidden;
margin: -1.5rem 1rem 0;
cursor: pointer;
@extend .rounded;
@extend .shadow;
@@ -82,7 +90,7 @@ ul.list-unstyled li {
left: 0;
width: 100%;
height: 80%; // ajusta según el tamaño deseado
background: linear-gradient(to top, rgba(0, 0, 0, 0.4), transparent);
background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);
pointer-events: none;
z-index: 1;
}
@@ -97,13 +105,20 @@ ul.list-unstyled li {
.card-caption {
position: absolute;
bottom: 15px;
bottom: 5px;
left: 15px;
color: #fff;
font-weight: bold;
font-size: 1.25rem;
text-shadow: 0 2px 5px rgba(33, 33, 33, 0.5);
z-index: 2;
}
.card-description {
> p {
margin: 0;
}
}
}
.table {
@@ -138,3 +153,24 @@ ul.list-unstyled li {
input {
--bs-body-bg: #fff;
}
.required {
position: relative;
&::after {
content: '*';
position: absolute;
color: red;
font-weight: bold;
margin-left: 10px;
}
}
.preguntas {
@include media-breakpoint-up(md) {
margin-inline: 5rem;
}
@include media-breakpoint-up(lg) {
margin-inline: 14rem;
}
}
+91
View File
@@ -0,0 +1,91 @@
.form-control,
.form-select {
--bs-body-bg: #ffffff;
}
.w-md-auto {
width: 100% !important;
@include media-breakpoint-up(md) {
width: auto !important;
}
}
.scroll-container {
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
scrollbar-width: thin;
scrollbar-color: rgba($azul, 0.75) $white;
}
.nav.nav-tabs {
border-bottom: 0cap;
gap: 1rem;
}
.nav-tabs.style-box {
.nav-item .nav-link {
@extend .box;
@extend .py-2;
@extend .text-black;
margin: 0 !important;
border: 0;
&.active {
@extend .text-primary;
border: 0;
}
&.disabled {
@extend .text-muted;
cursor: not-allowed;
}
}
}
/* HTML: <div class="loader"></div> */
.loader {
margin-top: 100px;
height: 20px;
aspect-ratio: 5;
-webkit-mask: linear-gradient(90deg, #0000, $azul 20% 80%, #0000);
background: radial-gradient(closest-side at 37.5% 50%, $azul 94%, #0000) 0 /
calc(80% / 3) 100%;
animation: l48 0.75s infinite linear;
}
@keyframes l48 {
100% {
background-position: 36.36%;
}
}
hr {
@extend .text-muted;
}
.content-dashboard {
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-track {
background: red;
}
::-webkit-scrollbar-thumb {
background-color: $primary;
border-radius: 10px;
border: 3px solid red;
}
@include color-mode(dark) {
::-webkit-scrollbar-track {
background: blue;
}
::-webkit-scrollbar-thumb {
background-color: $primary;
border: 3px solid blue;
}
}
}
-28
View File
@@ -1,28 +0,0 @@
export interface FormularioCreacion {
nombre_form: string;
descripcion: string;
fecha_inicio: string; // ISO 8601 date string
fecha_fin: string;
id_tipo_cuestionario: number;
evento: string;
secciones: SeccionFormulario[];
}
export interface SeccionFormulario {
titulo: string;
descripcion: string;
preguntas: PreguntaFormulario[];
}
export interface PreguntaFormulario {
titulo: string;
tipo: 'Abierta' | 'Cerrada' | 'Multiple';
opciones?: { valor: string }[];
obligatoria: boolean;
limite?: number;
validacion?: 'correo' | 'cuenta_alumno' | 'nombre'; // puedes ampliar esta lista si lo necesitas
}
export interface OpcionFormulario {
valor: string;
}
+7
View File
@@ -0,0 +1,7 @@
export interface CreateEventoType {
tipo_evento: string;
nombre_evento: string;
descripcion_evento?: string; // Es opciona
fecha_inicio: Date;
fecha_fin: Date;
}
+51
View File
@@ -0,0 +1,51 @@
export type TiposValidacion =
| 'correo'
| 'correo_institucional'
| 'telefono'
| 'nombre'
| 'entero'
| 'apellidos'
| 'decimal'
| 'comunidad_alumno'
| 'cuenta_alumno'
| 'comunidad_trabajador'
| 'cuenta_trabajador'
| 'genero'
| 'carrera'
| 'institucion'
| 'rfc'
export type TipoPregunta =
| 'Abierta (Respuesta corta)'
| 'Abierta (Parrafo)'
| 'Cerrada'
| 'Multiple';
export interface FormularioCreacion {
nombre_form: string;
descripcion: string;
fecha_inicio: string; // ISO 8601 date string
fecha_fin: string;
id_tipo_cuestionario: number;
cupo_maximo?: number; // Opcional, si no se requiere un cupo máximo
secciones: SeccionFormulario[];
}
export interface SeccionFormulario {
titulo: string;
descripcion: string;
preguntas: PreguntaFormulario[];
}
export interface PreguntaFormulario {
titulo: string;
tipo: TipoPregunta;
validacion?: TiposValidacion;
obligatoria: boolean;
opciones?: { valor: string }[]; // Opciones para preguntas cerradas o múltiples
limite?: number;
}
export interface OpcionFormulario {
valor: string;
}
+2
View File
@@ -4,8 +4,10 @@
export interface GetCuestionario {
id_cuestionario: number;
nombre_form: string;
banner?: string;
descripcion: string;
contador_secciones: number;
cupo_maximo?: number | null; // Puede ser null si no hay límite
editable: boolean;
fecha_fin: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
fecha_inicio: string; // Formato ISO: 'YYYY-MM-DDTHH:mm:ss'
+91
View File
@@ -0,0 +1,91 @@
import { TipoPregunta, TiposValidacion } from "./create-formulario";
export interface GetEvento {
id_evento: number;
nombre_evento: string;
descripcion_evento: string;
tipo_evento: string;
fecha_inicio: string; // formato ISO 8601
fecha_fin: string; // formato ISO 8601
// eslint-disable-next-line @typescript-eslint/no-explicit-any
asistencias: any | null;
banner: string | null;
}
export interface Cuestionario {
id_cuestionario: number;
nombre_form: string;
banner: string | null;
contador_secciones: number;
descripcion: string;
editable: boolean;
fecha_inicio: string; // formato ISO 8601
fecha_fin: string; // formato ISO 8601
id_tipo_cuestionario: number;
id_evento: number;
cupo_maximo: number | null;
tipoCuestionario: {
id_tipo_cuestionario: number;
tipo_cuestionario: string;
};
}
export interface CuestionarioWithCupo extends Cuestionario {
cupos_usados: number;
cupos_disponibles: number;
}
export interface GetCuestionario {
tipo_cuestionario: {
id_tipo_cuestionario: number;
tipo_cuestionario: string;
};
evento: GetEvento;
cuestionario: CuestionarioWithSecciones;
}
export interface CuestionarioWithSecciones extends Cuestionario {
secciones: {
id_cuestionario_seccion: number;
posicion: number;
seccion: {
id_seccion: number;
titulo: string;
contador_pregunta: number;
descripcion: string;
};
preguntas: {
id_seccion_pregunta: number;
posicion: number;
pregunta: {
id_pregunta: number;
pregunta: string;
contador_opcion: number;
obligatoria: boolean;
id_tipo_pregunta: number;
validacion: TiposValidacion;
tipo_pregunta: {
id_tipo: number;
tipo_pregunta: TipoPregunta;
};
opciones: {
id_pregunta_opcion: number;
posicion: number;
id_opcion: number;
opcion: {
id_opcion: number;
opcion: string;
};
}[];
};
}[];
}[];
}
export interface GetEventoWithCuestionarios extends GetEvento {
cuestionarios: CuestionarioWithCupo[];
}
export interface GetEventoWithCuestionario extends GetEvento {
cuestionario: Cuestionario;
}
+2 -1
View File
@@ -1,7 +1,8 @@
export interface ParticipacionEvento {
id_participante: number;
id_evento: number;
fecha_inscripcion: string; // formato ISO
id_cuestionario: number;
fecha_registro: string; // formato ISO
estatus: boolean;
fecha_asistencia: string | null;
participante: Participante;
+2 -1
View File
@@ -7,8 +7,9 @@ interface User {
carrera?: string;
}
interface ErrorState {
status: boolean;
message: string;
code: number;
}
}
+102
View File
@@ -1,4 +1,106 @@
/**
* Formatea una fecha en formato legible en español, con opción a incluir hora, minutos y día de la semana.
*
* @param {string} fechaStr - Cadena de texto representando una fecha válida (ISO 8601 o similar).
* @param {Object} [config] - Configuración opcional para mostrar hora, minutos y día de la semana.
* @param {boolean} [config.horas=false] - Indica si se debe incluir la hora.
* @param {boolean} [config.minutos=false] - Indica si se deben incluir los minutos (requiere config.horas).
* @param {boolean} [config.diaSemana=false] - Indica si se debe incluir el día de la semana.
* @returns {string} Fecha formateada en español.
* @throws {Error} Lanza un error si la fecha proporcionada no es válida.
*
* @example
* formatFecha('2025-03-25T14:30:00Z');
* // Retorna: "25 de marzo del 2025"
*
* @example
* formatFecha('2025-03-25T14:30:00Z', { horas: true });
* // Retorna: "25 de marzo del 2025 - 14"
*
* @example
* formatFecha('2025-03-25T14:30:00Z', { horas: true, minutos: true });
* // Retorna: "25 de marzo del 2025 - 14:30"
*
* @example
* formatFecha('2025-03-25T14:30:00Z', { diaSemana: true });
* // Retorna: "martes, 25 de marzo del 2025"
*/
export function formatFecha(
fechaStr: string,
config: { horas?: boolean; minutos?: boolean; diaSemana?: boolean } = {
horas: false,
minutos: false,
diaSemana: false,
}
): string {
const fecha = new Date(fechaStr);
if (isNaN(fecha.getTime())) {
throw new Error(`Fecha inválida: ${fechaStr}`);
}
const diasSemana = [
'domingo',
'lunes',
'martes',
'miércoles',
'jueves',
'viernes',
'sábado',
];
const meses = [
'enero',
'febrero',
'marzo',
'abril',
'mayo',
'junio',
'julio',
'agosto',
'septiembre',
'octubre',
'noviembre',
'diciembre',
];
const dia = fecha.getUTCDate();
const mes = meses[fecha.getUTCMonth()];
const año = fecha.getUTCFullYear();
const hora = (fecha.getUTCHours() - 6 + 24) % 24;
const minutos = fecha.getUTCMinutes().toString().padStart(2, '0');
const diaSemana = diasSemana[fecha.getUTCDay()];
let fechaFormateada = '';
if (config.diaSemana) {
fechaFormateada += `${diaSemana}, `;
}
fechaFormateada += `${dia} de ${mes} del ${año}`;
if (config.horas) {
fechaFormateada += ` - ${hora}`;
if (config.minutos) {
fechaFormateada += `:${minutos}`;
}
}
return fechaFormateada;
}
export function formatearFecha(fecha: string, tipo: 'inicio' | 'fin'): string {
const sufijo = tipo === 'inicio' ? 'T00:00:01' : 'T23:59:59';
return `${fecha}${sufijo}`;
}
export function formatDateLocal(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
const yyyy = date.getFullYear();
const MM = pad(date.getMonth() + 1);
const dd = pad(date.getDate());
const hh = pad(date.getHours());
const mm = pad(date.getMinutes());
return `${yyyy}-${MM}-${dd}T${hh}:${mm}`;
}