feat: Add event management features, including event editing and participant tracking; refactor layout and API integration

This commit is contained in:
miguel
2025-06-18 12:37:47 -06:00
parent f00bd8ac5e
commit b110109cb3
14 changed files with 316 additions and 198 deletions
+4
View File
@@ -8,6 +8,10 @@ const nextConfig: NextConfig = {
hostname: 'localhost',
port: '4200',
},
{
protocol: 'https',
hostname: 'venus.acatlan.unam.mx'
}
],
},
};
@@ -1,42 +1,56 @@
'use client';
import Button from '@/components/button';
import Input from '@/components/input';
import Table, { Header } from '@/components/table';
import SimpleInput from '@/components/input';
import EditEvento from '@/containers/edit-evento';
import { useGetApi } from '@/hooks/use-get-api';
import { GetEventoWithCuestionario } from '@/types/evento';
import { useParams } from 'next/navigation';
import React, { useState, useEffect } from 'react';
import { CreateEventoType } from '@/types/create-evento';
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';
import Table, { Header } from '@/components/table';
import Button from '@/components/button';
import { downloadFile } from '@/utils/downloas-utils';
type Params = {
id_evento: string;
id_cuestionario: string;
};
export default function Page() {
const params = useParams<{ id_formulario: string }>();
const id_formulario = Number(params?.id_formulario);
const params = useParams<Params>();
const { data } = useGetApi<GetEventoWithCuestionario>(
`/evento/${params.id_evento}/cuestionario/${params.id_cuestionario}`
);
const { data: participantes, setData } = useGetApi<ParticipacionEvento[]>(
`/participante-evento/evento/${params.id_cuestionario}`
);
const [participantes, setParticipantes] = React.useState<
ParticipacionEvento[]
>([]);
const [evento, setEvento] = useState<GetEventoWithCuestionario | null>(null);
const [busquedaCorreo, setBusquedaCorreo] = React.useState('');
const [loading, setLoading] = useState(false);
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);
}
};
if (data) setEvento(data);
}, [data]);
fetchParticipantes();
}, [id_formulario]);
const handleChange = (
field: keyof CreateEventoType,
value: string | Date
) => {
setEvento((prev) =>
prev
? {
...prev,
[field]: value,
}
: null
);
};
const confirmarAsistencia = async (
id_participante: number,
@@ -53,7 +67,7 @@ export default function Page() {
const { data } = await axiosInstance.get<ParticipacionEvento[]>(
`/participante-evento/evento/${id_evento}`
);
setParticipantes(data);
setData(data);
} catch (error) {
toast.error('Error al confirmar asistencia');
console.error(error);
@@ -62,6 +76,12 @@ export default function Page() {
}
};
const handleEventoActualizado = (
eventoActualizado: GetEventoWithCuestionario
) => {
setEvento(eventoActualizado);
};
const headers: Header<ParticipacionEvento>[] = [
{
key: 'id_participante',
@@ -74,9 +94,12 @@ export default function Page() {
render: (_, row) => row.participante.correo,
},
{
key: 'fecha_inscripcion',
label: 'Fecha inscripción',
render: (val) => new Date(val as string).toLocaleString(),
key: 'fecha_registro',
label: 'Fecha registro',
render: (_, row) =>
row.fecha_registro
? new Date(row.fecha_registro).toLocaleString()
: 'Sin registro',
},
{
key: 'estatus',
@@ -93,7 +116,7 @@ export default function Page() {
<button
className="btn btn-sm btn-outline-success"
onClick={() =>
confirmarAsistencia(row.id_participante, row.id_evento)
confirmarAsistencia(row.id_participante, row.id_cuestionario)
}
disabled={isLoading}
>
@@ -115,53 +138,21 @@ export default function Page() {
},
];
const participantesFiltrados = participantes.filter((p) =>
p.participante.correo.toLowerCase().includes(busquedaCorreo.toLowerCase())
);
const participantesFiltrados =
participantes &&
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');
}; */
if (!evento) return <p>Cargando...</p>;
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}`,
`/cuestionario-respondido/reporte-respuestas/${params.id_cuestionario}`,
{
responseType: 'blob',
}
@@ -169,7 +160,7 @@ export default function Page() {
downloadFile(
res.data,
`reporte_respuestas_formulario_${id_formulario}`,
`reporte_respuestas_formulario_${params.id_cuestionario}`,
'csv'
);
} catch (error) {
@@ -181,31 +172,38 @@ export default function Page() {
};
return (
<div>
<h2 className="mb-4">Participantes del formulario #{id_formulario}</h2>
<div className="container my-4">
<h2 className="mb-4">Evento</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 && (
<EditEvento
evento={evento}
handleChange={handleChange}
handleOnChange={handleEventoActualizado}
/>
<div className="d-flex justify-content-between align-items-center mt-4">
<h3 className="mt-5">Participantes</h3>
<div>
<Button onClick={handleDownload} icon="download" variant='success' disabled={loading}>
{loading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Descargando...
</>
) : (
'Descargar respuestas'
)}
</Button>
</div>
</div>
{participantes && participantes.length > 0 && (
<>
<Input
<SimpleInput
label="Buscar por correo"
placeholder="ejemplo@correo.com"
value={busquedaCorreo}
@@ -215,7 +213,7 @@ export default function Page() {
<div className="mt-4">
<Table
headers={headers}
data={participantesFiltrados}
data={participantesFiltrados ?? []}
rowKey={(row) => row.id_participante}
/>
</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 -1
View File
@@ -5,7 +5,7 @@ import React from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="d-flex flex-column min-vh-100">
<Header />
<Header small/>
<main className="container flex-grow-1">{children}</main>
<Footer />
</div>
+26 -58
View File
@@ -1,48 +1,19 @@
'use client';
import Button from '@/components/button';
import FormularioCard from '@/components/formulario-card';
import { GetCuestionario } from '@/types/cuestionario';
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, { useEffect, useState } from 'react';
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 (
<>
@@ -52,28 +23,25 @@ export default function Page() {
<Button>Crear Evento</Button>
</Link>
</div>
<div className="cards-1">
<div className="container">
<div className="row">
{eventos.map((cuestionario, index) => {
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={`/administrador/formulario/${cuestionario.id_cuestionario}`}
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}
user='administrador'
/>
</div>
);
})}
</div>
</div>
)}
</>
);
}
+2 -2
View File
@@ -22,8 +22,8 @@ export default function Page() {
render: (_, row) => row.participante.correo,
},
{
key: 'fecha_inscripcion',
label: 'Fecha inscripción',
key: 'fecha_registro',
label: 'Fecha registro',
render: (val) => new Date(val as string).toLocaleString(),
},
{
+8 -5
View File
@@ -26,11 +26,14 @@ export default function Page() {
{loading && <div>Cargando...</div>}
{data && (
<div className="row">
{data.map((item, key) => (
<div className="col-6 col-md-4" key={key}>
<EventoCard evento={item} />
</div>
))}
{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>
+16 -2
View File
@@ -6,8 +6,10 @@ 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;
@@ -22,7 +24,15 @@ export default function EventoCard({
<div className="card card-blog d-flex flex-column justify-content-between">
<div>
<div className="card-image scale-hover-1">
<Link href={`/evento/${evento.id_evento}`}>
<Link
href={
user === 'public'
? `/evento/${evento.nombre_evento.split(' ').join('_')}/${
evento.id_evento
}/${evento.cuestionarios[0]?.id_cuestionario}`
: `/${user}/evento/${evento.id_evento}/${evento.cuestionarios[0]?.id_cuestionario}`
}
>
<Image
width={400}
height={300}
@@ -51,7 +61,11 @@ export default function EventoCard({
evento.cuestionarios.map((cuestionario) => (
<div key={cuestionario.id_cuestionario} className="">
<Link
href={`/evento/${evento.nombre_evento.split(' ').join('_')}/${evento.id_evento}/${cuestionario.id_cuestionario}`}
href={
user === 'public'
? `/evento/${evento.nombre_evento.split(' ').join('_')}/${evento.id_evento}/${cuestionario.id_cuestionario}`
: `/${user}/evento/${evento.id_evento}/${cuestionario.id_cuestionario}`
}
className="btn mt-2 w-100 btn-outline-primary"
>
{cuestionario.nombre_form}
+142
View File
@@ -0,0 +1,142 @@
'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 { GetEventoWithCuestionario } from '@/types/evento';
import axiosInstance from '@/utils/api-config';
import { getAxiosError } from '@/utils/errors-utils';
import React, { useState } from 'react';
import toast from 'react-hot-toast';
interface EditEventoProps {
evento: GetEventoWithCuestionario;
handleChange: (field: keyof CreateEventoType, value: string | Date) => void;
handleOnChange: (eventoActualizado: GetEventoWithCuestionario) => void;
}
export default function EditEvento({
evento,
handleChange,
handleOnChange,
}: EditEventoProps) {
const toDateInputValue = (isoString: string) => isoString.slice(0, 10);
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-3">
<label className="form-label">Descripción del evento</label>
<textarea
className="form-control bg-white"
rows={5}
placeholder="Describe el evento..."
value={evento.descripcion_evento}
onChange={(e) => handleChange('descripcion_evento', e.target.value)}
/>
</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: 'Otro', value: 'otro' },
]}
/>
<div className="col-md-6">
<Input
label="Fecha de inicio"
type="date"
className={{ container: 'mb-3' }}
value={toDateInputValue(evento.fecha_inicio)}
onChange={(e) => handleChange('fecha_inicio', e.target.value)}
/>
</div>
<div className="col-md-6">
<Input
label="Fecha de fin"
type="date"
className={{ container: 'mb-3' }}
value={toDateInputValue(evento.fecha_fin)}
onChange={(e) => handleChange('fecha_fin', e.target.value)}
/>
</div>
<div>
<Button icon="save" disabled={loading} onClick={handleOnSave}>
{loading ? 'Guardando...' : 'Guardar Cambios'}
</Button>
</div>
</div>
);
}
@@ -84,15 +84,17 @@ export default function FormularioRegistro({
? `/alumnos/${cuenta}`
: `/trabajadores/${cuenta}`;
console.log('Buscando información de cuenta:', endpoint);
const res = await axiosInstance.get(endpoint);
console.log('Datos de cuenta obtenidos:', res.data);
// Usar datos fake para pruebas
// Usar datos fake para pruebas
setCuentaInfo({
nombre: 'Juan',
apellidos: 'Pérez',
correo: '421010301@pcpuma.acatlan.unam.mx',
correo: 'juan.perez@pcpuma.acatlan.unam.mx',
genero: 'M',
carrera: 'Ingeniería',
carrera: 'Ingeniería en Computación',
});
} catch (error) {
console.error('Error al obtener datos de cuenta:', error);
@@ -267,7 +269,7 @@ export default function FormularioRegistro({
}
return (
<div>
<div className="preguntas">
<hr />
<h2 className="text-xl font-bold">{data?.cuestionario.nombre_form}</h2>
{data?.cuestionario.descripcion && (
+3 -3
View File
@@ -34,13 +34,13 @@ export const plantillasDisponibles: Plantilla[] = [
validacion: 'comunidad_trabajador',
},
{
titulo: 'Número de trabajador',
titulo: 'RFC',
tipo: 'Abierta (Respuesta corta)',
obligatoria: false,
validacion: 'cuenta_trabajador',
validacion: 'rfc',
},
{
titulo: 'Correo electrónico institucional',
titulo: 'Correo electrónico',
tipo: 'Abierta (Respuesta corta)',
obligatoria: true,
validacion: 'correo',
+11 -1
View File
@@ -163,4 +163,14 @@ input {
font-weight: bold;
margin-left: 10px;
}
}
}
.preguntas {
@include media-breakpoint-up(md) {
margin-inline: 5rem;
}
@include media-breakpoint-up(lg) {
margin-inline: 14rem;
}
}
+1
View File
@@ -13,6 +13,7 @@ export type TiposValidacion =
| 'genero'
| 'carrera'
| 'institucion'
| 'rfc'
export type TipoPregunta =
| 'Abierta (Respuesta corta)'
+2 -1
View File
@@ -1,7 +1,8 @@
export interface ParticipacionEvento {
id_participante: number;
id_evento: number;
fecha_inscripcion: string; // formato ISO
id_cuestionario: number;
fecha_registro: string; // formato ISO
estatus: boolean;
fecha_asistencia: string | null;
participante: Participante;