develop #12
+14
-2
@@ -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;
|
||||
|
||||
Generated
+2365
-144
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
Vendored
+38
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
-28
@@ -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;
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
export interface CreateEventoType {
|
||||
tipo_evento: string;
|
||||
nombre_evento: string;
|
||||
descripcion_evento?: string; // Es opciona
|
||||
fecha_inicio: Date;
|
||||
fecha_fin: Date;
|
||||
}
|
||||
Vendored
+51
@@ -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;
|
||||
}
|
||||
Vendored
+2
@@ -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'
|
||||
|
||||
Vendored
+91
@@ -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;
|
||||
}
|
||||
Vendored
+2
-1
@@ -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;
|
||||
|
||||
Vendored
+2
-1
@@ -7,8 +7,9 @@ interface User {
|
||||
carrera?: string;
|
||||
}
|
||||
|
||||
|
||||
interface ErrorState {
|
||||
status: boolean;
|
||||
message: string;
|
||||
code: number;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user