feat: Enhance event and form management with previews and improved UI

- Added EventoCardPreview component to display event details with existing and new banners.
- Updated FormularioCardAdmin and FormularioCardUser components to handle event images and display questionnaire counts.
- Improved FormularioEditor with better layout and added preview functionality for forms.
- Introduced EmptyEventsState component for better user experience when no events are available.
- Enhanced FormularioCardPreview to support both existing and new forms with dynamic banner handling.
- Refactored code for better readability and maintainability across components.
This commit is contained in:
miguel
2025-08-14 21:59:38 -06:00
parent 7f2ab5b359
commit 24642d1c3b
26 changed files with 998 additions and 1483 deletions
@@ -1,192 +0,0 @@
'use client';
import Breadcrumb from '@/components/breadcrumb';
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 { useParams } from 'next/navigation';
import React, { useEffect } from 'react';
import toast from 'react-hot-toast';
import { useEvento } from '@/context/evento';
type Params = {
id_evento: string;
id_cuestionario: string;
};
export default function Page() {
const params = useParams<Params>();
const { evento } = useEvento();
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())
);
const breadcrumbItems = [
{ label: 'Inicio', href: '/administrador/eventos' },
{
label: evento?.nombre_evento || 'Evento',
href: `/administrador/evento/${params.id_evento}`,
},
{ label: cuestionario?.nombre_form || 'Formulario' },
];
return (
<div className="my-4">
<Breadcrumb items={breadcrumbItems} />
{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>
);
}
@@ -1,189 +0,0 @@
'use client';
import { plantillasDisponibles } from '@/data/plantillas';
import { FormularioCreacion } from '@/types/create-formulario';
import Image from 'next/image';
import React, { useState } from 'react';
import CreateFormulario from '@/containers/create-formulario';
import Button from '@/components/button';
import Breadcrumb from '@/components/breadcrumb';
import { useEvento } from '@/context/evento';
import { useParams, useRouter } from 'next/navigation';
import axiosInstance from '@/utils/api-config';
import { getAxiosError } from '@/utils/errors-utils';
import toast from 'react-hot-toast';
type Params = {
id_evento: string;
};
export default function Page() {
const params = useParams<Params>();
const router = useRouter();
const { evento, refetch } = useEvento();
const [datosFormulario, setDatosFormulario] =
useState<FormularioCreacion | null>(null);
const handleSeleccionarPlantilla = (plantillaId: string) => {
const seleccionada = plantillasDisponibles.find(
(p) => p.id === plantillaId
);
if (seleccionada) {
setDatosFormulario(seleccionada.datos);
console.log('Plantilla seleccionada:', seleccionada);
}
};
const breadcrumbItems = [
{ label: 'Inicio', href: '/administrador/eventos' },
{
label: evento?.nombre_evento || 'Evento',
href: `/administrador/evento/${params.id_evento}`,
},
{ label: 'Crear formulario' },
];
const handleCrearEvento = async () => {
if (!datosFormulario || !evento) {
toast.error('Faltan datos para crear el formulario');
return;
}
// Validar fechas del formulario respecto al evento
const fechaInicioFormulario = new Date(datosFormulario.fecha_inicio);
const fechaFinFormulario = new Date(datosFormulario.fecha_fin);
const fechaInicioEvento = new Date(evento.fecha_inicio);
const fechaFinEvento = new Date(evento.fecha_fin);
// Convertir a solo fecha (sin hora) para comparación
const fechaInicioFormularioSoloFecha = new Date(
fechaInicioFormulario.getFullYear(),
fechaInicioFormulario.getMonth(),
fechaInicioFormulario.getDate()
);
const fechaFinFormularioSoloFecha = new Date(
fechaFinFormulario.getFullYear(),
fechaFinFormulario.getMonth(),
fechaFinFormulario.getDate()
);
const fechaInicioEventoSoloFecha = new Date(
fechaInicioEvento.getFullYear(),
fechaInicioEvento.getMonth(),
fechaInicioEvento.getDate()
);
const fechaFinEventoSoloFecha = new Date(
fechaFinEvento.getFullYear(),
fechaFinEvento.getMonth(),
fechaFinEvento.getDate()
);
console.log('Fechas del formulario:', {
inicio: fechaInicioFormulario,
fin: fechaFinFormulario,
});
console.log('Fechas del evento:', {
inicio: fechaInicioEvento,
fin: fechaFinEvento,
});
if (fechaInicioFormularioSoloFecha < fechaInicioEventoSoloFecha) {
toast.error(
'La fecha de inicio del formulario debe ser posterior o igual a la fecha de inicio del evento'
);
return;
}
if (fechaFinFormularioSoloFecha > fechaFinEventoSoloFecha) {
toast.error(
'La fecha de fin del formulario debe ser anterior o igual a la fecha de fin del evento'
);
return;
}
if (fechaInicioFormulario > fechaFinFormulario) {
toast.error(
'La fecha de inicio del formulario debe ser anterior a la fecha de fin'
);
return;
}
try {
const res = await axiosInstance.post(`/cuestionario`, {
id_evento: evento?.id_evento || params.id_evento,
...datosFormulario,
});
console.log('Formulario creado:', res.data);
refetch();
toast.success('Formulario creado exitosamente');
router.push(`/administrador/evento/${params.id_evento}`);
} catch (error) {
const msg = getAxiosError(error);
toast.error(msg.message || 'Error al crear el formulario');
}
};
return (
<div className="my-4">
<Breadcrumb items={breadcrumbItems} />
<div className="p-4 border rounded bg-light mt-2">
<h2 className="mb-2">Creación de formulario</h2>
<p className="mb-0 text-muted">
Esta sección te permite crear y configurar sub-eventos en forma de
formularios asociados al evento. Selecciona una plantilla compatible,
ajusta los campos necesarios y publícalo para habilitar el registro,
encuestas o confirmaciones dentro de este evento.
</p>
</div>
{!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
evento={evento ? evento : undefined}
formulario={datosFormulario}
onChange={(nuevo) => setDatosFormulario({ ...nuevo })}
/>
)}
<Button className="mb-4" onClick={handleCrearEvento}>
Crear Evento y Formulario
</Button>
</div>
);
}
@@ -1,20 +0,0 @@
'use client';
import { EventoProvider } from '@/context/evento/evento-context';
import { useParams } from 'next/navigation';
import React from 'react';
type Params = {
id_evento: string;
};
export default function Layout({ children }: { children: React.ReactNode }) {
const params = useParams<Params>();
if (!params.id_evento) {
return <div>Error: ID de evento no encontrado</div>;
}
return (
<EventoProvider id_evento={params.id_evento}>{children}</EventoProvider>
);
}
@@ -1,130 +0,0 @@
'use client';
import EditEvento from '@/containers/edit-evento';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import { useParams, useRouter } from 'next/navigation';
import React, { useState } from 'react';
import { CreateEventoType } from '@/types/create-evento';
import { useEvento } from '@/context/evento';
import axiosInstance from '@/utils/api-config';
import toast from 'react-hot-toast';
import { downloadFile } from '@/utils/downloas-utils';
import NewCuestionarioCard from '@/components/new-cuestionario-card';
import Breadcrumb from '@/components/breadcrumb';
import Button from '@/components/button';
type Params = {
id_evento: string;
};
export default function Page() {
const router = useRouter();
const params = useParams<Params>();
const { evento, loading, error, updateEvento, setEventoData } = useEvento();
const [loadingStates, setLoadingStates] = useState<Record<number, boolean>>(
{}
);
const handleChange = (
field: keyof CreateEventoType,
value: string | Date
) => {
// Update the evento state directly using the context
updateEvento({ [field]: value });
};
const handleEventoActualizado = (
eventoActualizado: GetEventoWithCuestionariosWithCupos
) => {
// Set the complete updated evento data
setEventoData(eventoActualizado);
};
if (loading) return <p>Cargando...</p>;
if (error) return <p>Error: {error}</p>;
if (!evento) return <p>No se encontró el evento</p>;
const handleDownload = async (id_cuestionario: number, nombre: string) => {
setLoadingStates((prev) => ({ ...prev, [id_cuestionario]: 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 {
setLoadingStates((prev) => ({ ...prev, [id_cuestionario]: false }));
}
};
return (
<div className="my-4">
<Breadcrumb
items={[
{ label: 'Eventos', href: '/administrador/eventos' },
{
label: `${evento.nombre_evento}`,
href: `/administrador/evento/${params.id_evento}`,
},
]}
/>
<EditEvento
evento={evento}
handleChange={handleChange}
handleOnChange={handleEventoActualizado}
/>
<div className="p-4 border rounded bg-light">
<div className="d-flex justify-content-between align-items-center">
<div>
<h2 className="mb-0">Formularios del evento</h2>
<p className="mb-0 text-muted">
Aquí puedes ver y gestionar los formularios asociados a este
evento.
</p>
</div>
<Button
icon="plus"
variant="primary"
onClick={() =>
router.push(
`/administrador/evento/${params.id_evento}/formularios/crear`
)
}
>
Crear nuevo formulario
</Button>
</div>
</div>
{evento?.cuestionarios && evento.cuestionarios.length > 0 && (
<div className="row mt-3">
{evento.cuestionarios.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}
>
<NewCuestionarioCard
cuestionario={item}
user="administrador"
onDownload={handleDownload}
loading={loadingStates[item.id_cuestionario] || false}
/>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -1,128 +0,0 @@
'use client';
import React, { useState } from 'react';
import { CreateEventoType } from '@/types/create-evento';
import CreateEvento from '@/containers/create-evento';
import EventoCardPreview from '@/components/evento/evento-card-preview';
import axiosInstance from '@/utils/api-config';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { getAxiosError } from '@/utils/errors-utils';
import Button from '@/components/button';
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 handleChange = (
field: keyof CreateEventoType,
value: string | Date
) => {
setEvento((prev) => ({
...prev,
[field]: value,
}));
};
const handleBannerChange = (file: File | null) => {
setEventoBanner(file);
};
const handleCrearEvento = async () => {
try {
const createEvento = await axiosInstance.post('/evento', evento);
if (createEvento) {
const formData = new FormData();
if (eventoBanner) {
formData.append('banner', eventoBanner);
const bannercreated = await axiosInstance.post(
`/evento/${createEvento.data.id_evento}/banner`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
console.log('Banner creado:', bannercreated.data);
}
}
toast.success('Evento y formulario creados exitosamente');
router.push(
`/administrador/evento/${createEvento.data.id_evento}/formularios/crear`
);
} 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-fluid my-4">
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Creación del Evento</h2>
<p className="mb-0 text-muted">
Completa la siguiente información para describir los detalles
generales del evento. Presiona siguiente para continuar con la
creación del formulario.
</p>
</div>
</div>
</div>
<div className="row">
{/* Columna Izquierda - Formulario */}
<div className="col-xl-7 col-lg-6">
<div className="pe-lg-4">
<CreateEvento
evento={evento}
handleChange={handleChange}
handleBannerChange={handleBannerChange}
/>
{/* Botón de guardar */}
<div className="d-flex justify-content-end">
<Button icon="arrow-right" onClick={handleCrearEvento}>
Siguiente
</Button>
</div>
</div>
</div>
{/* Columna Derecha - Preview */}
<div className="col-xl-5 col-lg-6">
<div className="ps-lg-4">
<div className="sticky-top" style={{ top: '20px' }}>
<h4 className="mb-3 d-none d-lg-block">Preview del Evento</h4>
<div className="d-lg-none mt-4">
<h4 className="mb-3">Preview del Evento</h4>
</div>
<EventoCardPreview evento={evento} eventoBanner={eventoBanner} />
<div className="alert alert-info text-center">
<strong>Nota:</strong> Esta preview es la que veras en tu ruta
de eventos y en la ruta publica de eventos
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,67 +0,0 @@
'use client';
import EventoCardAdmin from '@/components/evento/evento-card-admin';
import FormularioCardAdmin from '@/components/formulario/formulario-card-admin';
import { useGetApi } from '@/hooks/use-get-api';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import React from 'react';
export default function Page() {
const {
loading,
data: activos,
error,
} = useGetApi<GetEventoWithCuestionariosWithCupos[]>(
'/evento/activos/cuestionarios'
);
const { data: recientes } = useGetApi<GetEventoWithCuestionariosWithCupos[]>(
'/evento/recientes/cuestionarios'
);
if (loading) return <div>Cargando formularios...</div>;
if (error) return <div className="alert alert-danger">{error.message}</div>;
return (
<div className="my-4">
{loading && <div>Cargando...</div>}
{activos && activos.length > 0 && (
<div className="row">
<h1>Eventos Activos</h1>
{activos.map((evento, 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}
>
<EventoCardAdmin evento={evento} />
</div>
);
})}
</div>
)}
{recientes && recientes.length > 0 && (
<div className="row">
<h2>Formularios Recientes</h2>
<p className="text-muted">Eventos de los ultimos 30 días.</p>
{recientes.map((evento) =>
evento.cuestionarios.map((cuestionario, cuestionarioIndex) => {
const fadeClass = `delay-${(cuestionarioIndex % 5) + 1}`;
return (
<div
className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`}
key={`${evento.id_evento}-${cuestionario.id_cuestionario}`}
>
<FormularioCardAdmin
cuestionario={cuestionario}
evento={evento}
/>
</div>
);
})
)}
</div>
)}
</div>
);
}
-15
View File
@@ -1,15 +0,0 @@
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
import Navbar from '@/components/navbar';
import React from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="d-flex flex-column min-vh-100">
<Header small />
<Navbar />
<main className="container flex-grow-1">{children}</main>
<Footer />
</div>
);
}
@@ -1,54 +0,0 @@
'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>
);
}
-27
View File
@@ -1,27 +0,0 @@
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={'/staff/'} className='text-decoration-none'>
<div className='box'>Escaner</div>
</Link>
<Link
href={'/staff/lista-manual'}
className='text-decoration-none'
>
<div className='box'>Lista manual</div>
</Link>
</nav>
{children}
</main>
<Footer />
</div>
);
}
@@ -1,181 +0,0 @@
'use client';
import Button from '@/components/button';
import Input from '@/components/input';
import Select from '@/components/select';
import Table, { Header } from '@/components/table';
import {
GetCuestionario,
GetCuestionarioWithEvento,
} from '@/types/cuestionario';
import { GetEvento } from '@/types/evento';
import { ParticipacionEvento } from '@/types/participante-evento';
import axiosInstance from '@/utils/api-config';
import React, { useEffect } from 'react';
import toast from 'react-hot-toast';
export default function Page() {
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: (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
size="sm"
outline
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 [eventos, setEventos] = React.useState<GetCuestionarioWithEvento[]>([]);
const [participantes, setParticipantes] = React.useState<
ParticipacionEvento[]
>([]);
const [loadingAsistencia, setLoadingAsistencia] = React.useState<
Record<number, boolean>
>({});
const [busquedaCorreo, setBusquedaCorreo] = React.useState('');
useEffect(() => {
const getEventos = async () => {
try {
const { data } = await axiosInstance.get<GetCuestionario[]>(
`/cuestionario`
);
if (!data) throw new Error('No se encontraron eventos');
const cuestionariosConEvento = await Promise.all(
data.map(async (cuestionario) => {
const { data: evento } = await axiosInstance.get<GetEvento>(
`/evento/${cuestionario.id_evento}`
);
return { ...cuestionario, evento };
})
);
setEventos(cuestionariosConEvento);
} catch (error) {
console.error('Error en getEventos:', error);
}
};
getEventos();
}, []);
const handleOnSelect = async (idSeleccionado: number) => {
try {
const { data } = await axiosInstance.get<ParticipacionEvento[]>(
`/participante-evento/evento/${idSeleccionado}`
);
setParticipantes(data);
} catch (error) {
console.error('Error al obtener participante-evento:', error);
}
};
const confirmarAsistencia = async (
id_participante: number,
id_cuestionario: number
) => {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: true }));
try {
await axiosInstance.post(
`/participante-evento/asistencia/${id_participante}/${id_cuestionario}`
);
toast.success('Asistencia confirmada');
// Actualiza lista
await handleOnSelect(id_cuestionario);
} catch (error) {
toast.error('Error al confirmar asistencia');
console.error(error);
} finally {
setLoadingAsistencia((prev) => ({ ...prev, [id_participante]: false }));
}
};
const participantesFiltrados = participantes.filter((p) =>
p.participante.correo.toLowerCase().includes(busquedaCorreo.toLowerCase())
);
return (
<div>
<Select
label="Selecciona un evento"
options={eventos.map((evento) => ({
value: evento.id_cuestionario,
label: `${evento.evento.nombre_evento} - ${evento.nombre_form}`,
}))}
onChange={(e) => {
const id = Number(e?.target?.value ?? e);
if (!isNaN(id)) handleOnSelect(id);
}}
placeholder="Selecciona un evento"
/>
{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>
</>
)}
{participantes.length === 0 && (
<div className="alert alert-warning mt-4">
No hay participantes registrados para este evento.
</div>
)}
</div>
);
}
-144
View File
@@ -1,144 +0,0 @@
'use client';
import React, { useState } from 'react';
import dynamic from 'next/dynamic';
import { Scanner } from '@yudiel/react-qr-scanner';
import Button from '@/components/button';
import axiosInstance from '@/utils/api-config';
import toast from 'react-hot-toast';
const Modal = dynamic(() => import('@/components/modal'), { ssr: false });
export default function Page() {
const [scannedData, setScannedData] = useState<{
id_participante: number;
id_cuestionario: number;
} | null>(null);
const [showModal, setShowModal] = useState(false);
const [enableScan, setEnableScan] = useState(true);
const [statusMessage, setStatusMessage] = useState('');
const [participante, setParticipante] = useState('');
const handleScan = async (rawValue: string) => {
console.log('QR Escaneado:', rawValue);
if (!enableScan) return;
try {
const data = JSON.parse(rawValue);
if (data.id_participante && data.id_cuestionario) {
try {
const response = await axiosInstance.get(
`/participante-evento/${data.id_participante}/${data.id_cuestionario}`
);
setParticipante(response.data.participante.correo);
setScannedData({
id_participante: data.id_participante,
id_cuestionario: data.id_cuestionario,
});
setShowModal(true);
setEnableScan(false);
} catch (err) {
console.warn('Participante no registrado en el cuestionario:', err);
setStatusMessage(
'❌ El participante no está registrado en este evento.'
);
}
} else {
setStatusMessage('⚠️ El QR no contiene los campos requeridos');
}
} catch (err) {
console.error('QR malformado:', err);
setStatusMessage('❌ Error al leer el QR: formato inválido');
}
};
const handleConfirm = async () => {
if (!scannedData) return;
try {
const response = await axiosInstance.post(
`/participante-evento/asistencia/${scannedData.id_participante}/${scannedData.id_cuestionario}`
);
console.log('Asistencia registrada:', response.data);
toast.success('✅ Asistencia registrada correctamente');
} catch (err) {
console.error('Error en la petición:', err);
setStatusMessage('❌ Error de red al registrar asistencia');
}
setShowModal(false);
setEnableScan(true);
setScannedData(null);
};
return (
<main className="container flex-grow-1 d-flex flex-column align-items-center justify-content-center">
<h1 className="mb-4">Lectura de QRs</h1>
{enableScan ? (
<>
<div style={{ width: '100%', maxWidth: '400px' }}>
<Scanner
onScan={(codes) => {
if (codes.length > 0) {
handleScan(codes[0].rawValue);
}
}}
onError={(err) => console.error('QR Error:', err)}
constraints={{ facingMode: 'environment' }}
/>
</div>
<Button
onClick={() => setEnableScan(false)}
variant="outline-danger"
className="mt-3"
>
Detener cámara
</Button>
</>
) : (
<>
<p className="text-muted mb-3">La cámara está detenida.</p>
<Button onClick={() => setEnableScan(true)} variant="outline-primary">
Activar cámara
</Button>
</>
)}
{statusMessage && <p className="mt-3">{statusMessage}</p>}
<Modal
isVisible={showModal}
size="md"
onClose={() => {
setShowModal(false);
setEnableScan(true);
setScannedData(null);
}}
closeButton
className={{
content: 'p-3',
body: 'text-center',
}}
>
<h5 className="mb-3">Datos escaneados</h5>
{scannedData ? (
<>
<p>
<strong>Participante:</strong> {participante}
</p>
<Button variant="success" onClick={handleConfirm}>
Confirmar lectura
</Button>
</>
) : (
<p>QR inválido</p>
)}
</Modal>
</main>
);
}
@@ -143,10 +143,10 @@ export default function Page() {
);
const breadcrumbItems = [
{ label: 'Inicio', href: '/administrador/eventos' },
{ label: 'Inicio', href: '/user/eventos' },
{
label: evento?.nombre_evento || 'Evento',
href: `/administrador/evento/${params.id_evento}`,
href: `/user/evento/${params.id_evento}`,
},
{ label: cuestionario?.nombre_form || 'Formulario' },
];
@@ -158,6 +158,7 @@ export default function Page() {
{cuestionario && (
<EditFormulario
cuestionario={cuestionario}
evento={evento}
handleChange={handleChange}
handleOnChange={handleCuestionarioActualizado}
/>
@@ -4,6 +4,7 @@ import { FormularioCreacion } from '@/types/create-formulario';
import Image from 'next/image';
import React, { useState } from 'react';
import CreateFormulario from '@/containers/create-formulario';
import FormularioCardPreview from '@/components/formulario/formulario-card-preview';
import Button from '@/components/button';
import Breadcrumb from '@/components/breadcrumb';
import { useEvento } from '@/context/evento';
@@ -27,17 +28,46 @@ export default function Page() {
const seleccionada = plantillasDisponibles.find(
(p) => p.id === plantillaId
);
if (seleccionada) {
setDatosFormulario(seleccionada.datos);
if (seleccionada && evento) {
// Crear una copia de los datos de la plantilla
const datosPlantilla = { ...seleccionada.datos };
// Ajustar las fechas para que estén dentro del rango del evento
const fechaInicioEvento = new Date(evento.fecha_inicio);
const fechaFinEvento = new Date(evento.fecha_fin);
// Formatear las fechas para inputs datetime-local (YYYY-MM-DDTHH:MM)
const formatoFechaInput = (fecha: Date) => {
return fecha.toISOString().slice(0, 16);
};
// Establecer fecha de inicio del formulario igual a la del evento
datosPlantilla.fecha_inicio = formatoFechaInput(fechaInicioEvento);
// Establecer fecha de fin del formulario igual a la del evento
datosPlantilla.fecha_fin = formatoFechaInput(fechaFinEvento);
setDatosFormulario(datosPlantilla);
console.log('Plantilla seleccionada:', seleccionada);
console.log('Fechas ajustadas al evento:', {
inicio: datosPlantilla.fecha_inicio,
fin: datosPlantilla.fecha_fin,
});
} else if (seleccionada) {
// Si no hay evento disponible, usar las fechas originales de la plantilla
setDatosFormulario(seleccionada.datos);
console.log(
'Plantilla seleccionada (sin ajuste de fechas):',
seleccionada
);
}
};
const breadcrumbItems = [
{ label: 'Inicio', href: '/administrador/eventos' },
{ label: 'Inicio', href: '/user/eventos' },
{
label: evento?.nombre_evento || 'Evento',
href: `/administrador/evento/${params.id_evento}`,
href: `/user/evento/${params.id_evento}`,
},
{ label: 'Crear formulario' },
];
@@ -114,7 +144,7 @@ export default function Page() {
console.log('Formulario creado:', res.data);
refetch();
toast.success('Formulario creado exitosamente');
router.push(`/administrador/evento/${params.id_evento}`);
router.push(`/user/evento/${params.id_evento}`);
} catch (error) {
const msg = getAxiosError(error);
toast.error(msg.message || 'Error al crear el formulario');
@@ -122,18 +152,25 @@ export default function Page() {
};
return (
<div className="my-4">
<div className="container-fluid my-4">
<Breadcrumb items={breadcrumbItems} />
<div className="p-4 border rounded bg-light mt-2">
<h2 className="mb-2">Creación de formulario</h2>
<p className="mb-0 text-muted">
Esta sección te permite crear y configurar sub-eventos en forma de
formularios asociados al evento. Selecciona una plantilla compatible,
ajusta los campos necesarios y publícalo para habilitar el registro,
encuestas o confirmaciones dentro de este evento.
</p>
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Creación de formulario</h2>
<p className="mb-0 text-muted">
Esta sección te permite crear y configurar sub-eventos en forma de
formularios asociados al evento. Selecciona una plantilla
compatible, ajusta los campos necesarios y publícalo para
habilitar el registro, encuestas o confirmaciones dentro de este
evento.
</p>
</div>
</div>
</div>
{!datosFormulario && (
<div className="border p-3 rounded my-4">
<h2 className="h5 fw-bold mb-2">
@@ -174,16 +211,80 @@ export default function Page() {
</div>
</div>
)}
{datosFormulario && (
<CreateFormulario
evento={evento ? evento : undefined}
formulario={datosFormulario}
onChange={(nuevo) => setDatosFormulario({ ...nuevo })}
/>
<>
<div className="row">
{/* Columna Izquierda - Formulario */}
<div className="col-xl-7 col-lg-6">
<div className="pe-lg-4">
{(() => {
const formularioEditor = CreateFormulario({
formulario: datosFormulario,
onChange: (nuevo) => setDatosFormulario({ ...nuevo }),
evento: evento ? evento : undefined,
});
return formularioEditor.informacionBasica();
})()}
{/* Botón de crear */}
<div className="d-flex justify-content-end mt-4">
<Button icon="save" onClick={handleCrearEvento}>
Crear Formulario
</Button>
</div>
</div>
</div>
{/* Columna Derecha - Preview */}
<div className="col-xl-5 col-lg-6">
<div className="ps-lg-4">
<div className="sticky-top" style={{ top: '20px' }}>
<h4 className="mb-3 d-none d-lg-block">
Preview del Formulario
</h4>
<div className="d-lg-none mt-4">
<h4 className="mb-3">Preview del Formulario</h4>
</div>
<FormularioCardPreview
formulario={datosFormulario}
evento={evento || undefined}
/>
{/* Alert de imagen del evento */}
{evento?.banner && (
<div className="alert alert-info mt-3">
<i className="bi bi-info-circle-fill me-2"></i>
<strong>Nota:</strong> Al no proporcionar una imagen para
este formulario, se usará automáticamente la imagen del
evento.
</div>
)}
<div className="alert alert-warning text-center mt-3">
<strong>Vista previa:</strong> Así se verá tu formulario en
las listas de formularios.
</div>
</div>
</div>
</div>
</div>
{/* Secciones y preguntas - Ancho completo */}
<div className="row mt-5">
<div className="col-12">
{(() => {
const formularioEditor = CreateFormulario({
formulario: datosFormulario,
onChange: (nuevo) => setDatosFormulario({ ...nuevo }),
evento: evento ? evento : undefined,
});
return formularioEditor.seccionesYPreguntas();
})()}
</div>
</div>
</>
)}
<Button className="mb-4" onClick={handleCrearEvento}>
Crear Evento y Formulario
</Button>
</div>
);
}
@@ -8,9 +8,9 @@ import { useEvento } from '@/context/evento';
import axiosInstance from '@/utils/api-config';
import toast from 'react-hot-toast';
import { downloadFile } from '@/utils/downloas-utils';
import NewCuestionarioCard from '@/components/new-cuestionario-card';
import Breadcrumb from '@/components/breadcrumb';
import Button from '@/components/button';
import FormularioCardAdmin from '@/components/formulario/formulario-card-admin';
type Params = {
id_evento: string;
@@ -44,7 +44,11 @@ export default function Page() {
if (error) return <p>Error: {error}</p>;
if (!evento) return <p>No se encontró el evento</p>;
const handleDownload = async (id_cuestionario: number, nombre: string) => {
const handleDownload = async (
id_cuestionario: number,
nombre_formulario: string,
nombre_evento: string
) => {
setLoadingStates((prev) => ({ ...prev, [id_cuestionario]: true }));
try {
@@ -55,7 +59,7 @@ export default function Page() {
}
);
downloadFile(res.data, `${nombre}`, 'csv');
downloadFile(res.data, `${nombre_evento} - ${nombre_formulario}`, 'csv');
} catch (error) {
console.error('Error al descargar el archivo:', error);
toast.error('Error al descargar el archivo');
@@ -68,10 +72,10 @@ export default function Page() {
<div className="my-4">
<Breadcrumb
items={[
{ label: 'Eventos', href: '/administrador/eventos' },
{ label: 'Eventos', href: '/user/eventos' },
{
label: `${evento.nombre_evento}`,
href: `/administrador/evento/${params.id_evento}`,
href: `/user/evento/${params.id_evento}`,
},
]}
/>
@@ -95,9 +99,7 @@ export default function Page() {
icon="plus"
variant="primary"
onClick={() =>
router.push(
`/administrador/evento/${params.id_evento}/formularios/crear`
)
router.push(`/user/evento/${params.id_evento}/formularios/crear`)
}
>
Crear nuevo formulario
@@ -114,11 +116,11 @@ export default function Page() {
className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`}
key={key}
>
<NewCuestionarioCard
<FormularioCardAdmin
cuestionario={item}
user="administrador"
onDownload={handleDownload}
loading={loadingStates[item.id_cuestionario] || false}
evento={evento}
handleDownload={handleDownload}
disabled={loadingStates[item.id_cuestionario] || false}
/>
</div>
);
+1 -1
View File
@@ -58,7 +58,7 @@ export default function Page() {
toast.success('Evento y formulario creados exitosamente');
router.push(
`/administrador/evento/${createEvento.data.id_evento}/formularios/crear`
`/user/evento/${createEvento.data.id_evento}/formularios/crear`
);
} catch (error) {
const msg = getAxiosError(error);
+35 -1
View File
@@ -4,9 +4,15 @@ import EventoCardAdmin from '@/components/evento/evento-card-admin';
import FormularioCardAdmin from '@/components/formulario/formulario-card-admin';
import { useGetApi } from '@/hooks/use-get-api';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import React from 'react';
import axiosInstance from '@/utils/api-config';
import { downloadFile } from '@/utils/downloas-utils';
import React, { useState } from 'react';
import toast from 'react-hot-toast';
export default function Page() {
const [loadingStates, setLoadingStates] = useState<Record<number, boolean>>(
{}
);
const {
loading,
data: activos,
@@ -21,6 +27,30 @@ export default function Page() {
if (loading) return <div>Cargando formularios...</div>;
if (error) return <div className="alert alert-danger">{error.message}</div>;
const handleDownload = async (
id_cuestionario: number,
nombre_formulario: string,
nombre_evento: string
) => {
setLoadingStates((prev) => ({ ...prev, [id_cuestionario]: true }));
try {
const res = await axiosInstance.get(
`/cuestionario-respondido/reporte-respuestas/${id_cuestionario}`,
{
responseType: 'blob',
}
);
downloadFile(res.data, `${nombre_evento} - ${nombre_formulario}`, 'csv');
} catch (error) {
console.error('Error al descargar el archivo:', error);
toast.error('Error al descargar el archivo');
} finally {
setLoadingStates((prev) => ({ ...prev, [id_cuestionario]: false }));
}
};
return (
<div className="my-4">
{loading && <div>Cargando...</div>}
@@ -55,6 +85,10 @@ export default function Page() {
<FormularioCardAdmin
cuestionario={cuestionario}
evento={evento}
handleDownload={handleDownload}
disabled={
loadingStates[cuestionario.id_cuestionario] || false
}
/>
</div>
);
+48 -19
View File
@@ -2,6 +2,7 @@
import ClientCarousel from '@/client-components/client-carousel';
import FormularioCardUser from '@/components/formulario/formulario-card-user';
import EmptyEventsState from '@/components/empty-events-state';
import { useGetApi } from '@/hooks/use-get-api';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import React from 'react';
@@ -13,25 +14,53 @@ export default function Page() {
return (
<div>
<div className="mx-auto mb-5 mt-3 fade-in-down-bounce">
<ClientCarousel
images={
data
? data.map((evento) => ({
src: `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`,
alt: `Banner de ${evento.nombre_evento}`,
}))
: [
{
src: '/banner.jpeg',
alt: 'Banner de eventos activos',
},
]
}
/>
</div>
{loading && <div>Cargando...</div>}
{data && (
{data && data.length > 0 && (
<div className="mx-auto mb-5 mt-3 fade-in-down-bounce">
<ClientCarousel
images={
data && data.length > 0
? data.map((evento) => ({
src: `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`,
alt: `Banner de ${evento.nombre_evento}`,
}))
: [
{
src: '/default-banner.jpeg',
alt: 'Banner de eventos activos',
},
]
}
/>
</div>
)}
{loading && (
<div className="text-center py-5 fade-in-scale">
<div className="d-flex justify-content-center align-items-center mb-3">
<div
className="spinner-border text-primary me-3"
role="status"
style={{ width: '2rem', height: '2rem' }}
>
<span className="visually-hidden">Cargando...</span>
</div>
<div>
<h5 className="mb-0 text-primary">Cargando eventos...</h5>
<small className="text-muted">
Esto solo tomará unos segundos
</small>
</div>
</div>
</div>
)}
{!loading && data && data.length === 0 && (
<div className="flex-grow-1 d-flex flex-column justify-content-center align-items-center">
<EmptyEventsState />
</div>
)}
{!loading && data && data.length > 0 && (
<div className="row">
{data.flatMap((evento) =>
evento.cuestionarios.map((cuestionario, index) => {
+62
View File
@@ -0,0 +1,62 @@
'use client';
import React from 'react';
import Button from './button';
export default function EmptyEventsState() {
return (
<div className="container py-5">
<div className="row justify-content-center">
<div className="col-lg-8">
<div className="text-center fade-in-scale">
{/* Ilustración principal con Bootstrap */}
<div className="mb-2">
<div
className="d-inline-flex align-items-center justify-content-center bg-dorado bg-opacity-10 rounded-circle mb-4"
style={{ width: '150px', height: '150px' }}
>
<i className="bi bi-calendar-x text-dorado display-1"></i>
</div>
</div>
{/* Título y mensaje principal */}
<div className="mb-5">
<h1 className="display-5 fw-bold text-azul mb-3 fade-in-up delay-1">
No hay eventos disponibles
</h1>
<p className="lead text-muted mb-0 fade-in-up delay-2">
En este momento no tenemos eventos activos para mostrar.
<br className="d-none d-md-block" />
<span className="text-azul fw-semibold">
¡No te preocupes!
</span>{' '}
Pronto habrá nuevas oportunidades de participación.
</p>
</div>
{/* Botón de acción usando clases Bootstrap */}
<div className="fade-in-up delay-4">
<Button
icon="bi bi-arrow-clockwise"
onClick={() => window.location.reload()}
size="lg"
className="rounded-pill px-5"
>
Actualizar pagina
</Button>
</div>
{/* Mensaje adicional */}
<div className="mt-4 fade-in-up delay-5">
<small className="text-muted">
<i className="bi bi-info-circle me-1"></i>
Esta página se actualiza automáticamente para mostrar los
eventos más recientes
</small>
</div>
</div>
</div>
</div>
</div>
);
}
+4 -4
View File
@@ -31,7 +31,7 @@ export default function EventoCardAdmin({
style={{ borderRadius: '12px', overflow: 'hidden' }}
>
<div className="position-relative">
<Link href={`/administrador/evento/${evento.id_evento}`}>
<Link href={`/user/evento/${evento.id_evento}`}>
<Image
width={400}
height={250}
@@ -110,7 +110,7 @@ export default function EventoCardAdmin({
<div className="py-2 px-3 bg-light border-0">
<div className="d-flex justify-content-between align-items-center">
<Link
href={`/administrador/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
href={`/user/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
className="text-decoration-none fw-semibold"
>
{cuestionario.nombre_form}
@@ -131,7 +131,7 @@ export default function EventoCardAdmin({
<i className="bi bi-clipboard-x fs-4 text-muted mb-2 d-block"></i>
<p className="text-muted mb-2 small">Sin formularios disponibles</p>
<Link
href={`/administrador/evento/${evento.id_evento}/formularios/crear`}
href={`/user/evento/${evento.id_evento}/formularios/crear`}
className="btn btn-sm btn-outline-primary"
>
<i className="bi bi-plus-circle me-1"></i>
@@ -142,7 +142,7 @@ export default function EventoCardAdmin({
{/* Botón principal */}
<Link
href={`/administrador/evento/${evento.id_evento}`}
href={`/user/evento/${evento.id_evento}`}
className="btn btn-primary w-100 rounded-pill"
>
Ver/Editar evento
+22 -4
View File
@@ -10,11 +10,15 @@ import Button from '../button';
interface EventoPreviewProps {
evento: CreateEventoType;
eventoBanner?: File | null;
existingBanner?: string | null; // Banner existente desde la API
cuestionariosCount?: number; // Número de formularios existentes
}
export default function EventoCardPreview({
evento,
eventoBanner,
existingBanner,
cuestionariosCount = 0,
}: EventoPreviewProps) {
const [verMas, setVerMas] = useState(false);
const [bannerUrl, setBannerUrl] = useState<string>('/default-banner.png');
@@ -27,18 +31,25 @@ export default function EventoCardPreview({
? `${descripcion.slice(0, limite)}...`
: descripcion;
// Actualizar URL del banner cuando cambie el archivo
// Actualizar URL del banner cuando cambie el archivo o cuando haya un banner existente
useEffect(() => {
if (eventoBanner) {
// Si hay un nuevo banner seleccionado, usarlo
const url = URL.createObjectURL(eventoBanner);
setBannerUrl(url);
// Limpiar URL anterior cuando el componente se desmonte
return () => URL.revokeObjectURL(url);
} else if (existingBanner) {
// Si no hay nuevo banner pero sí hay uno existente desde la API, usarlo
setBannerUrl(
`${process.env.NEXT_PUBLIC_API_URL}/banners/${existingBanner}`
);
} else {
// Si no hay ningún banner, usar el banner por defecto
setBannerUrl('/default-banner.png');
}
}, [eventoBanner]);
}, [eventoBanner, existingBanner]);
// Obtener información de fecha usando la utilidad
const getDateInfo = () => {
@@ -66,9 +77,16 @@ export default function EventoCardPreview({
style={{ objectFit: 'cover', height: '200px' }}
/>
{/* Badge indicando que no hay formularios aún */}
{/* Badge de cantidad de formularios en la esquina superior derecha */}
<div className="position-absolute top-0 end-0 m-2">
<span className="badge bg-secondary">Sin formularios</span>
{cuestionariosCount > 0 ? (
<span className="badge bg-primary">
{cuestionariosCount} formulario
{cuestionariosCount !== 1 ? 's' : ''}
</span>
) : (
<span className="badge bg-secondary">Sin formularios</span>
)}
</div>
</div>
@@ -9,9 +9,17 @@ import Button from '../button';
export default function FormularioCardAdmin({
cuestionario,
evento,
handleDownload,
disabled,
}: {
cuestionario: CuestionarioWithCupo;
evento: GetEvento;
handleDownload: (
id_cuestionario: number,
nombre_formulario: string,
nombre_evento: string
) => void;
disabled: boolean;
}) {
const [verMas, setVerMas] = useState(false);
const descripcion = cuestionario.descripcion ?? '';
@@ -35,7 +43,7 @@ export default function FormularioCardAdmin({
>
<div className="position-relative">
<Link
href={`/administrador/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
href={`/user/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
>
<Image
width={400}
@@ -44,13 +52,25 @@ export default function FormularioCardAdmin({
src={
cuestionario.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`
: `${process.env.NEXT_PUBLIC_API_URL}/banners/default-banner.png`
: evento.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
: `/default-banner.png`
}
alt="Banner formulario"
style={{ objectFit: 'cover', height: '200px' }}
/>
</Link>
{/* Mensaje cuando se usa la imagen del evento */}
{!cuestionario.banner && evento.banner && (
<div className="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-black bg-opacity-25">
<div className="bg-white bg-opacity-90 px-3 py-2 rounded-pill text-center small fw-bold">
<i className="bi bi-info-circle-fill me-1 text-primary"></i>
Usando imagen del evento
</div>
</div>
)}
{/* Badge de tipo de cuestionario en la esquina superior izquierda */}
<div className="position-absolute top-0 start-0 m-2">
<span className="badge bg-info">
@@ -130,14 +150,27 @@ export default function FormularioCardAdmin({
{/* Botones de acción */}
<div className="d-grid gap-2">
<Link
href={`/administrador/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
href={`/user/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
className="btn btn-primary rounded-pill"
>
Ver/Editar formulario
</Link>
{/* Botón secundario para ver respuestas */}
<Button outline variant="success" size="sm" className="rounded-pill">
<Button
outline
variant="success"
size="sm"
className="rounded-pill"
onClick={() =>
handleDownload(
cuestionario.id_cuestionario,
cuestionario.nombre_form,
evento.nombre_evento
)
}
disabled={disabled}
>
<i className="bi bi-download me-2"></i>
Descargar respuestas
</Button>
@@ -0,0 +1,280 @@
import { GetCuestionario } from '@/types/cuestionario';
import { FormularioCreacion } from '@/types/create-formulario';
import { GetEvento, GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import Image from 'next/image';
import React, { useState, useEffect } from 'react';
import MarkdownRenderer from '../markdown-render';
import { formatearFechaCard } from '@/utils/date-utils';
interface FormularioCardPreviewProps {
// Para formularios existentes
cuestionario?: GetCuestionario;
// Para formularios en creación
formulario?: FormularioCreacion;
// Evento asociado
evento?: GetEvento | GetEventoWithCuestionariosWithCupos | null;
// Banner personalizado
nuevoBanner?: File | null;
}
export default function FormularioCardPreview({
cuestionario,
formulario,
evento,
nuevoBanner,
}: FormularioCardPreviewProps) {
const [verMas, setVerMas] = useState(false);
const [bannerUrl, setBannerUrl] = useState<string>('/default-banner.png');
// Determinar qué datos usar (formulario existente o en creación)
const isCreating = !!formulario && !cuestionario;
const data = cuestionario || formulario;
// Manejar la imagen del banner
useEffect(() => {
if (nuevoBanner) {
// Si hay un nuevo banner seleccionado, usarlo
const url = URL.createObjectURL(nuevoBanner);
setBannerUrl(url);
return () => URL.revokeObjectURL(url);
} else if (!isCreating && cuestionario?.banner) {
// Si es un formulario existente y tiene banner propio
setBannerUrl(
`${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`
);
} else if (evento?.banner) {
// Si hay banner del evento, usarlo
setBannerUrl(
`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
);
} else {
// Banner por defecto
setBannerUrl('/default-banner.png');
}
}, [nuevoBanner, evento?.banner, cuestionario?.banner, isCreating]);
if (!data) {
return null; // No hay datos para mostrar
}
const descripcion = data.descripcion ?? '';
const limite = 100;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
// Obtener información de fecha usando la utilidad
const getDateInfo = () => {
if (!data.fecha_inicio || !data.fecha_fin) {
return { mesTexto: 'MES', diaTexto: 'DD' };
}
let fechaInicio: Date, fechaFin: Date;
if (isCreating) {
// Para formularios en creación, las fechas vienen como string
fechaInicio = new Date(data.fecha_inicio);
fechaFin = new Date(data.fecha_fin);
} else {
// Para formularios existentes, convertir a Date también
fechaInicio = new Date(data.fecha_inicio);
fechaFin = new Date(data.fecha_fin);
}
return formatearFechaCard(fechaInicio, fechaFin);
};
const { mesTexto, diaTexto } = getDateInfo();
// Determinar si se está usando la imagen del evento
const isUsingEventImage =
!nuevoBanner && (isCreating || !cuestionario?.banner) && evento?.banner;
// Obtener el tipo de cuestionario
const getTipoCuestionario = () => {
const tipoId = isCreating
? (formulario as FormularioCreacion).id_tipo_cuestionario
: (cuestionario as GetCuestionario).id_tipo_cuestionario;
if (tipoId === 1) return 'Registro';
if (tipoId === 2) return 'Evaluación';
return 'Formulario';
};
// Obtener nombre del formulario
const getNombreFormulario = () => {
return data.nombre_form || 'Nombre del formulario';
};
// Obtener información de cupos
const getCuposInfo = () => {
if (isCreating) {
const form = formulario as FormularioCreacion;
return form.cupo_maximo && form.cupo_maximo > 0
? `0 / ${form.cupo_maximo}`
: 'Sin límite';
} else {
const cuest = cuestionario as GetCuestionario;
return cuest.cupo_maximo !== null && cuest.cupo_maximo !== undefined
? `0 / ${cuest.cupo_maximo}`
: 'Sin límite';
}
};
// Obtener información de secciones y preguntas (solo para formularios en creación)
const getSeccionesInfo = () => {
if (!isCreating || !formulario) return null;
const totalSecciones = formulario.secciones.length;
const totalPreguntas = formulario.secciones.reduce(
(total, seccion) => total + seccion.preguntas.length,
0
);
return { totalSecciones, totalPreguntas };
};
const seccionesInfo = getSeccionesInfo();
return (
<div
className="card shadow-sm border-0 mb-2"
style={{ borderRadius: '12px', overflow: 'hidden' }}
>
<div className="position-relative">
<Image
width={400}
height={250}
className="img-fluid w-100"
src={bannerUrl}
alt="Banner formulario"
style={{ objectFit: 'cover', height: '200px' }}
/>
{/* Mensaje cuando se usa la imagen del evento */}
{isUsingEventImage && (
<div className="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-black bg-opacity-25">
<div className="bg-white bg-opacity-90 px-3 py-2 rounded-pill text-center small fw-bold">
<i className="bi bi-info-circle-fill me-1 text-primary"></i>
Usando imagen del evento
</div>
</div>
)}
{/* Badge de tipo de cuestionario en la esquina superior izquierda */}
<div className="position-absolute top-0 start-0 m-2">
<span className="badge bg-info">{getTipoCuestionario()}</span>
</div>
</div>
<div className="card-body p-3 d-flex flex-column">
{/* Fecha del formulario */}
<div className="d-flex align-items-center mb-2">
<div className="me-3" style={{ minWidth: '50px' }}>
<div
className="text-muted text-center small"
style={{
fontSize: mesTexto.includes(' - ') ? '0.65rem' : '0.75rem',
lineHeight: 1.1,
}}
>
{mesTexto}
</div>
<div
className="fw-bold mb-0 text-center"
style={{
lineHeight: 1,
whiteSpace: 'nowrap',
fontSize: diaTexto.toString().length > 2 ? '1rem' : '1.25rem',
}}
>
{diaTexto}
</div>
</div>
<div>
<h5 className="card-title mb-1 fw-bold">{getNombreFormulario()}</h5>
{evento && (
<small className="text-muted">
<i className="bi bi-calendar-event me-1"></i>
{evento.nombre_evento}
</small>
)}
</div>
</div>
{/* Descripción */}
<div className="card-description flex-grow-1 mb-3">
{descripcion ? (
<>
<MarkdownRenderer markdown={descripcionRecortada} />
{descripcion.length > limite && (
<button
onClick={() => setVerMas(!verMas)}
className="btn btn-link btn-sm p-0 ms-1 align-baseline text-decoration-none"
style={{ fontSize: '0.875rem' }}
>
{verMas ? 'Ver menos' : 'Ver más'}
</button>
)}
</>
) : (
<p className="text-muted small">Descripción del formulario...</p>
)}
</div>
{/* Información de cupos */}
<div className="mb-3">
<div className="py-2 px-3 bg-light rounded">
<div className="d-flex align-items-center">
<i className="bi bi-people-fill me-2 text-primary"></i>
<div>
<small className="text-muted d-block">Cupos</small>
<span className="fw-semibold">{getCuposInfo()}</span>
</div>
</div>
</div>
</div>
{/* Información de secciones y preguntas (solo para formularios en creación) */}
{seccionesInfo && (
<div className="mb-3">
<div className="py-2 px-3 bg-light rounded">
<div className="d-flex justify-content-between align-items-center text-sm">
<div className="d-flex align-items-center">
<i className="bi bi-list-task me-2 text-success"></i>
<span className="fw-semibold">
{seccionesInfo.totalSecciones} sección
{seccionesInfo.totalSecciones !== 1 ? 'es' : ''}
</span>
</div>
<div className="d-flex align-items-center">
<i className="bi bi-question-circle me-2 text-warning"></i>
<span className="fw-semibold">
{seccionesInfo.totalPreguntas} pregunta
{seccionesInfo.totalPreguntas !== 1 ? 's' : ''}
</span>
</div>
</div>
</div>
</div>
)}
{/* Botón de acción */}
<div className="d-grid gap-2">
<button className="btn btn-primary rounded-pill" disabled>
Ver/Editar formulario
</button>
<button
className="btn btn-outline-success btn-sm rounded-pill"
disabled
>
<i className="bi bi-download me-2"></i>
Descargar respuestas
</button>
</div>
</div>
</div>
);
}
@@ -27,6 +27,12 @@ export default function FormularioCardUser({
? `${descripcion.slice(0, limite)}...`
: descripcion;
const imgSrc = formulario.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${formulario.banner}`
: evento.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
: `/default-banner.png`;
return (
<div
className="card shadow-sm border-0"
@@ -39,7 +45,7 @@ export default function FormularioCardUser({
width={400}
height={250}
className="img-fluid w-100"
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${formulario.banner}`}
src={imgSrc}
alt="Banner formulario"
style={{
objectFit: 'cover',
@@ -62,7 +68,7 @@ export default function FormularioCardUser({
width={400}
height={250}
className="img-fluid w-100"
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${formulario.banner}`}
src={imgSrc}
alt="Banner formulario"
style={{ objectFit: 'cover', height: '200px' }}
/>
+56 -36
View File
@@ -146,28 +146,31 @@ export default function FormularioEditor({
onChange(actualizado);
};
return (
<div className="row border p-3 rounded my-4">
<h2 className="mb-3">Editar Formulario</h2>
// Renderizar solo información básica del formulario
const renderInformacionBasica = () => (
<div className="p-4">
<h4 className="mb-3">Información del Formulario</h4>
<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="row">
<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 className="col-md-4">
<SimpleInput
label="Cupo máximo"
type="number"
placeholder="Opcional"
value={formulario.cupo_maximo || ''}
onChange={(e) =>
actualizarCampo('cupo_maximo', Number(e.target.value) || 0)
}
/>
</div>
</div>
<SimpleInput
@@ -194,10 +197,18 @@ export default function FormularioEditor({
/>
</div>
</div>
{evento && (
<div className="badge bg-primary fs-6 w-100 mb-3">
Este evento se realizara{' '}
{formatearRangoFechas(evento.fecha_inicio, evento.fecha_fin)}
<div className="alert alert-primary d-flex align-items-center">
<i className="bi bi-info-circle-fill me-2"></i>
<div>
<strong>Evento:</strong> {evento.nombre_evento}
<br />
<small>
Se realizará{' '}
{formatearRangoFechas(evento.fecha_inicio, evento.fecha_fin)}
</small>
</div>
</div>
)}
@@ -210,14 +221,27 @@ export default function FormularioEditor({
options={tiposCuestionario}
placeholder="Selecciona un tipo"
/>
</div>
);
<hr className="my-4" />
// Renderizar secciones y preguntas
const renderSeccionesYPreguntas = () => (
<div className="mt-4">
<div className="d-flex justify-content-between align-items-center mb-4">
<h4 className="mb-0">Secciones y Preguntas</h4>
<Button
className="btn btn-success"
onClick={agregarSeccion}
icon="plus"
>
Agregar sección
</Button>
</div>
{/* 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>
<h5 className="mb-0">Sección {seccionIdx + 1}</h5>
<Button
variant="danger"
icon="trash"
@@ -316,6 +340,7 @@ export default function FormularioEditor({
/>
</div>
</div>
<div className="form-check mb-2">
<input
className="form-check-input"
@@ -376,16 +401,11 @@ export default function FormularioEditor({
</Button>
</div>
))}
<div className="text-center mt-4">
<Button
className="btn btn-success"
onClick={agregarSeccion}
icon="plus"
>
Agregar sección
</Button>
</div>
</div>
);
return {
informacionBasica: renderInformacionBasica,
seccionesYPreguntas: renderSeccionesYPreguntas,
};
}
+143 -117
View File
@@ -3,9 +3,11 @@ import ImageUploader from '@/components/banner-uploader';
import Button from '@/components/button';
import Input from '@/components/input';
import Select from '@/components/select';
import EventoCardPreview from '@/components/evento/evento-card-preview';
import { CreateEventoType } from '@/types/create-evento';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import axiosInstance from '@/utils/api-config';
import { tipoEventos } from '@/utils/arrays';
import { formatDateLocal } from '@/utils/date-utils';
import { getAxiosError } from '@/utils/errors-utils';
import { commands } from '@uiw/react-md-editor';
@@ -14,6 +16,8 @@ import React, { useState } from 'react';
import toast from 'react-hot-toast';
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
const MAX_LENGTH = 500;
interface EditEventoProps {
evento: GetEventoWithCuestionariosWithCupos;
handleChange: (field: keyof CreateEventoType, value: string | Date) => void;
@@ -68,136 +72,158 @@ export default function EditEvento({
}
};
// Convertir evento a CreateEventoType para el preview
const eventoPreview: CreateEventoType = {
tipo_evento: evento.tipo_evento,
nombre_evento: evento.nombre_evento,
descripcion_evento: evento.descripcion_evento,
fecha_inicio: new Date(evento.fecha_inicio),
fecha_fin: new Date(evento.fecha_fin),
};
return (
<div className="my-2">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Editar Información del Evento</h2>
<p className="mb-0 text-muted">
Modifica los datos del evento que desees actualizar y confrimalos.
</p>
<div className="container-fluid my-4">
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Editar Información del Evento</h2>
<p className="mb-0 text-muted">
Modifica los datos del evento que desees actualizar y confirmalos.
</p>
</div>
</div>
</div>
<div className="p-4">
<div className="row">
{/* Columna Izquierda - Banner y Nombre */}
<div className="col-lg-6">
<div className="pe-lg-3">
<ImageUploader
label="Banner del evento"
defaultBanner={
evento.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
: undefined
}
onChange={(file) => {
setNuevoBanner(file);
}}
/>
<p className="text-muted mt-2 small">
<strong>
<i className="bi bi-info-circle-fill me-2"></i>
Esta es la imagen que se verá en el carrusel del evento.
</strong>
<br />
<strong>
<i className="bi bi-image me-2"></i>
Dimensiones recomendadas: 1200x600 píxeles.
</strong>
<br />
<strong>
<i className="bi bi-file-earmark-image me-2"></i>
Formatos soportados: PNG, JPG, JPEG.
</strong>
</p>
</div>
</div>
{/* Columna Derecha - Descripción, Tipo y Fechas */}
<div className="col-lg-6">
<div className="ps-lg-3">
<h4 className="mb-3">Detalles del Evento</h4>
<Input
label="Nombre del evento"
required
value={evento.nombre_evento}
onChange={(e) => handleChange('nombre_evento', e.target.value)}
/>
<div className="row">
{/* Columna Izquierda - Formulario */}
<div className="col-xl-7 col-lg-6">
<div className="pe-lg-4">
<div className="p-4">
{/* Banner */}
<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);
<ImageUploader
label="Banner del evento"
defaultBanner={
evento.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
: undefined
}
onChange={(file) => {
setNuevoBanner(file);
}}
/>
<p className="text-muted mt-2 small">
<strong>
<i className="bi bi-info-circle-fill me-2"></i>
Esta es la imagen que se verá en el carrusel del evento.
</strong>
<br />
<strong>
<i className="bi bi-image me-2"></i>
Dimensiones recomendadas: 1200x600 píxeles.
</strong>
<br />
<strong>
<i className="bi bi-file-earmark-image me-2"></i>
Formatos soportados: PNG, JPG, JPEG.
</strong>
</p>
</div>
{/* Detalles del evento */}
<div className="col-12">
<h4 className="mb-3">Detalles del Evento</h4>
<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={200}
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={tipoEventos}
/>
<div className="row">
<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))
}
}}
height={200}
commands={[commands.bold, commands.italic, commands.hr]}
/>
/>
</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>
</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="row">
<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>
{/* Botón de guardar */}
<div className="d-flex justify-content-end mt-4">
<Button icon="save" disabled={loading} onClick={handleOnSave}>
{loading ? 'Guardando...' : 'Guardar Cambios'}
</Button>
</div>
</div>
</div>
</div>
{/* Botón de guardar en la parte inferior */}
<div className="row mt-4">
<div className="col-12">
<div className="d-flex justify-content-end">
<Button icon="save" disabled={loading} onClick={handleOnSave}>
{loading ? 'Guardando...' : 'Guardar Cambios'}
</Button>
{/* Columna Derecha - Preview */}
<div className="col-xl-5 col-lg-6">
<div className="ps-lg-4">
<div className="sticky-top" style={{ top: '20px' }}>
<h4 className="mb-3 d-none d-lg-block">Preview del Evento</h4>
<div className="d-lg-none mt-4">
<h4 className="mb-3">Preview del Evento</h4>
</div>
<EventoCardPreview
evento={eventoPreview}
eventoBanner={nuevoBanner}
existingBanner={evento.banner}
cuestionariosCount={evento.cuestionarios?.length || 0}
/>
<div className="alert alert-info text-center mt-3">
<strong>Vista previa:</strong> Así se verá tu evento en las
listas de eventos.
</div>
</div>
</div>
</div>
+162 -112
View File
@@ -1,7 +1,9 @@
import ImageUploader from '@/components/banner-uploader';
import Button from '@/components/button';
import SimpleInput from '@/components/input';
import FormularioCardPreview from '@/components/formulario/formulario-card-preview';
import { GetCuestionario } from '@/types/cuestionario';
import { GetEvento } from '@/types/evento';
import axiosInstance from '@/utils/api-config';
import { formatDateLocal } from '@/utils/date-utils';
import { getAxiosError } from '@/utils/errors-utils';
@@ -11,12 +13,14 @@ import toast from 'react-hot-toast';
interface EditFormularioProps {
cuestionario: GetCuestionario;
evento: GetEvento | null;
handleChange: (field: keyof GetCuestionario, value: string | Date) => void;
handleOnChange: (eventoActualizado: GetCuestionario) => void;
}
export default function EditFormulario({
cuestionario,
evento,
handleChange,
}: EditFormularioProps) {
const [loading, setLoading] = useState(false);
@@ -65,130 +69,176 @@ export default function EditFormulario({
};
return (
<div className="my-2">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Editar Información del Formulario</h2>
<p className="mb-0 text-muted">
Modifica los datos del formulario que desees actualizar y confrimalos.
</p>
<div className="container-fluid my-4">
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="p-4 border rounded bg-light">
<h2 className="mb-2">Editar Información del Formulario</h2>
<p className="mb-0 text-muted">
Modifica los datos del formulario que desees actualizar y
confirmalos.
</p>
</div>
</div>
</div>
<div className="p-4">
<div className="row">
{/* Columna Izquierda - Banner */}
<div className="col-lg-6">
<div className="pe-lg-3">
<ImageUploader
label="Banner del formulario"
defaultBanner={
cuestionario.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`
: undefined
}
onChange={(file) => {
setNuevoBanner(file);
}}
/>
<p className="text-muted mt-2 small">
<strong>
<i className="bi bi-info-circle-fill me-2"></i>
Esta es la imagen que se usara para la carta de presentación
del formulario.
</strong>
<br />
<strong>
<i className="bi bi-image me-2"></i>
Dimensiones recomendadas: 1200x600 píxeles.
</strong>
<br />
<strong>
<i className="bi bi-file-earmark-image me-2"></i>
Formatos soportados: PNG, JPG, JPEG.
</strong>
</p>
</div>
</div>
{/* Columna Derecha - Detalles del formulario */}
<div className="col-lg-6">
<div className="ps-lg-3">
<h4 className="mb-3">Detalles del Formulario</h4>
<SimpleInput
label="Nombre del formulario"
required
value={cuestionario.nombre_form}
onChange={(e) => handleChange('nombre_form', e.target.value)}
/>
<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 className="mb-4">
<label className="form-label">Descripción del formulario</label>
<div data-color-mode="light">
<MDEditor
value={cuestionario.descripcion ?? ''}
onChange={(value) => {
const texto = value || '';
if (texto.length <= 500) {
handleChange(
'descripcion' as keyof GetCuestionario,
texto
);
<div className="row">
{/* Columna Izquierda - Formulario */}
<div className="col-xl-7 col-lg-6">
<div className="pe-lg-4">
<div className="p-4">
<div className="row">
{/* Banner */}
<div className="col-12">
<div className="mb-4">
<ImageUploader
label="Banner del formulario"
defaultBanner={
cuestionario.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`
: undefined
}
}}
height={200}
commands={[commands.bold, commands.italic, commands.hr]}
onChange={(file) => {
setNuevoBanner(file);
}}
/>
<p className="text-muted mt-2 small">
<strong>
<i className="bi bi-info-circle-fill me-2"></i>
Esta es la imagen que se usara para la carta de
presentación del formulario.
</strong>
<br />
<strong>
<i className="bi bi-image me-2"></i>
Dimensiones recomendadas: 1200x600 píxeles.
</strong>
<br />
<strong>
<i className="bi bi-file-earmark-image me-2"></i>
Formatos soportados: PNG, JPG, JPEG.
</strong>
</p>
{/* Alert sobre imagen por defecto */}
{!cuestionario.banner && !nuevoBanner && evento?.banner && (
<div className="alert alert-info mt-3">
<i className="bi bi-info-circle me-2"></i>
<strong>Imagen por defecto:</strong> Si no seleccionas
una imagen específica para este formulario, se utilizará
automáticamente la imagen del evento.
</div>
)}
</div>
</div>
{/* Detalles del formulario */}
<div className="col-12">
<h4 className="mb-3">Detalles del Formulario</h4>
<SimpleInput
label="Nombre del formulario"
required
value={cuestionario.nombre_form}
onChange={(e) =>
handleChange('nombre_form', e.target.value)
}
/>
<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 className="mb-4">
<label className="form-label">
Descripción del formulario
</label>
<div data-color-mode="light">
<MDEditor
value={cuestionario.descripcion ?? ''}
onChange={(value) => {
const texto = value || '';
if (texto.length <= 500) {
handleChange(
'descripcion' as keyof GetCuestionario,
texto
);
}
}}
height={200}
commands={[commands.bold, commands.italic, commands.hr]}
/>
</div>
</div>
<div className="row">
<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>
</div>
</div>
<div className="row">
<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>
{/* Botón de guardar */}
<div className="d-flex justify-content-end mt-4">
<Button icon="save" disabled={loading} onClick={handleOnSave}>
{loading ? 'Guardando...' : 'Guardar Cambios'}
</Button>
</div>
</div>
</div>
</div>
{/* Botón de guardar en la parte inferior */}
<div className="row mt-4">
<div className="col-12">
<div className="d-flex justify-content-end">
<Button icon="save" disabled={loading} onClick={handleOnSave}>
{loading ? 'Guardando...' : 'Guardar Cambios'}
</Button>
{/* Columna Derecha - Preview */}
<div className="col-xl-5 col-lg-6">
<div className="ps-lg-4">
<div className="sticky-top" style={{ top: '20px' }}>
<h4 className="mb-3 d-none d-lg-block">Preview del Formulario</h4>
<div className="d-lg-none mt-4">
<h4 className="mb-3">Preview del Formulario</h4>
</div>
<FormularioCardPreview
cuestionario={cuestionario}
evento={evento}
nuevoBanner={nuevoBanner}
/>
<div className="alert alert-info text-center mt-3">
<strong>Vista previa:</strong> Así se verá tu formulario en las
listas de formularios.
</div>
</div>
</div>
</div>