feat: Implement event creation and management features for administrators

- Added page for creating forms associated with events, including template selection and validation of form dates against event dates.
- Created layout component to provide context for event-related pages.
- Developed event detail page with editing capabilities and form management.
- Introduced event creation page with banner upload functionality and preview.
- Implemented event listing page displaying active and recent events with associated forms.
- Added layout for user section including header, navbar, and footer.
- Created page for uploading teacher data from Excel files.
- Developed QR code scanning page for attendance registration with modal confirmation.
- Implemented login functionality with fake user data for testing purposes.
- Added event card preview component to display event details and banner.
This commit is contained in:
miguel
2025-08-14 20:27:25 -06:00
parent be8f207ecd
commit 7f2ab5b359
25 changed files with 1905 additions and 367 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

@@ -3,6 +3,7 @@
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';
@@ -71,20 +72,54 @@ export default function Page() {
};
return (
<div className="container">
<CreateEvento
evento={evento}
handleChange={handleChange}
handleBannerChange={handleBannerChange}
/>
{/* Botón de guardar en la parte inferior */}
<div className="row">
<div className="container-fluid my-4">
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="d-flex justify-content-end">
<Button icon="arrow-right" onClick={handleCrearEvento}>
Siguiente
</Button>
<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>
+30 -29
View File
@@ -1,17 +1,19 @@
'use client';
import Button from '@/components/button';
import CuestionarioCard from '@/components/cuestionario-card';
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 Link from 'next/link';
import React from 'react';
export default function Page() {
const { loading, data, error } = useGetApi<
GetEventoWithCuestionariosWithCupos[]
>('/evento/activos/cuestionarios');
const {
loading,
data: activos,
error,
} = useGetApi<GetEventoWithCuestionariosWithCupos[]>(
'/evento/activos/cuestionarios'
);
const { data: recientes } = useGetApi<GetEventoWithCuestionariosWithCupos[]>(
'/evento/recientes/cuestionarios'
);
@@ -20,25 +22,19 @@ export default function Page() {
if (error) return <div className="alert alert-danger">{error.message}</div>;
return (
<>
<div className="d-flex justify-content-between align-items-center my-4">
<h1>Eventos</h1>
<Link href="/administrador/eventos/crear">
<Button>Crear Evento</Button>
</Link>
</div>
<div className="my-4">
{loading && <div>Cargando...</div>}
{data && data.length > 0 && (
{activos && activos.length > 0 && (
<div className="row">
<h1>Eventos Activos</h1>
{data.map((item, key) => {
{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={item} />
<EventoCardAdmin evento={evento} />
</div>
);
})}
@@ -46,21 +42,26 @@ export default function Page() {
)}
{recientes && recientes.length > 0 && (
<div className="row">
<h2>Eventos Recientes</h2>
<h2>Formularios Recientes</h2>
<p className="text-muted">Eventos de los ultimos 30 días.</p>
{recientes.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}
>
<CuestionarioCard evento={item} user="administrador" />
</div>
);
})}
{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>
);
}
+3 -1
View File
@@ -1,11 +1,13 @@
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/>
<Header small />
<Navbar />
<main className="container flex-grow-1">{children}</main>
<Footer />
</div>
@@ -0,0 +1,192 @@
'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>
);
}
@@ -0,0 +1,189 @@
'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>
);
}
@@ -0,0 +1,20 @@
'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>
);
}
@@ -0,0 +1,130 @@
'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>
);
}
@@ -0,0 +1,128 @@
'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>
);
}
+67
View File
@@ -0,0 +1,67 @@
'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
@@ -0,0 +1,15 @@
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>
);
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import SimpleInput from '@/components/input';
import axiosInstance from '@/utils/api-config';
import React, { useState } from 'react';
export default function Page() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [response, setResponse] = useState<{
insertados: number;
omitidos: number;
} | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setSelectedFile(e.target.files[0]);
}
};
const handleSubmit = async () => {
if (!selectedFile) return;
const formData = new FormData();
formData.append('file', selectedFile);
try {
const res = await axiosInstance.post('/trabajadores/cargar', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
setResponse(res.data);
} catch (error) {
console.error('Error al subir el archivo:', error);
}
};
return (
<div>
<h2>Subir Excel de Profesores</h2>
<SimpleInput type="file" onChange={handleFileChange} />
<button onClick={handleSubmit} disabled={!selectedFile}>
Enviar
</button>
{response && (
<div>
<h3>Resultado de la carga:</h3>
<p>Insertados: {response.insertados}</p>
<p>Omitidos: {response.omitidos}</p>
</div>
)}
</div>
);
}
+277
View File
@@ -0,0 +1,277 @@
'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-fluid flex-grow-1 d-flex flex-column justify-content-center py-4">
<div className="row justify-content-center">
<div className="col-12 col-md-8 col-lg-6 col-xl-5">
<div className="card shadow border-0 rounded-3">
<div className="card-header bg-primary text-white text-center py-4 border-0">
<div className="mb-3">
<i className="bi bi-qr-code-scan display-4"></i>
</div>
<h2 className="mb-2 fw-bold">Registro de Asistencia</h2>
<p className="mb-0 opacity-75">
Escanea el código QR para registrar la asistencia
</p>
</div>
<div className="card-body p-4">
{enableScan ? (
<div className="text-center">
<div className="mb-4 mx-auto position-relative d-inline-block">
<div className="border border-3 border-primary rounded-3 p-3 bg-light">
<Scanner
onScan={(codes) => {
if (codes.length > 0) {
handleScan(codes[0].rawValue);
}
}}
onError={(err) => console.error('QR Error:', err)}
constraints={{ facingMode: 'environment' }}
styles={{
container: {
width: '100%',
maxWidth: '300px',
borderRadius: '8px',
overflow: 'hidden',
},
}}
/>
</div>
</div>
<div className="mb-4">
<div className="alert alert-info border-0 rounded-3 d-flex align-items-center">
<i className="bi bi-info-circle-fill me-2"></i>
<small>
Mantén el código QR centrado en el marco de la cámara
</small>
</div>
</div>
<Button
onClick={() => setEnableScan(false)}
variant="outline-danger"
className="px-4 py-2 rounded-pill"
>
<i className="bi bi-camera-video-off me-2"></i>
Detener cámara
</Button>
</div>
) : (
<div className="text-center py-5">
<div className="mb-4">
<i
className="bi bi-camera-video-off-fill text-muted"
style={{ fontSize: '4rem' }}
></i>
</div>
<h4 className="text-muted mb-3">Cámara desactivada</h4>
<p className="text-muted mb-4 lead">
La cámara está detenida. Actívala para continuar escaneando
códigos QR.
</p>
<Button
onClick={() => setEnableScan(true)}
variant="primary"
className="px-4 py-2 rounded-pill"
>
<i className="bi bi-camera-video me-2"></i>
Activar cámara
</Button>
</div>
)}
{statusMessage && (
<div className="mt-4">
<div
className={`alert border-0 rounded-3 d-flex align-items-center ${
statusMessage.includes('❌')
? 'alert-danger'
: statusMessage.includes('⚠️')
? 'alert-warning'
: 'alert-success'
}`}
>
<div className="me-2">
{statusMessage.includes('❌') && (
<i className="bi bi-exclamation-triangle-fill"></i>
)}
{statusMessage.includes('⚠️') && (
<i className="bi bi-exclamation-circle-fill"></i>
)}
{!statusMessage.includes('❌') &&
!statusMessage.includes('⚠️') && (
<i className="bi bi-check-circle-fill"></i>
)}
</div>
<span>{statusMessage}</span>
</div>
</div>
)}
</div>
</div>
</div>
</div>
<Modal
isVisible={showModal}
size="md"
onClose={() => {
setShowModal(false);
setEnableScan(true);
setScannedData(null);
}}
closeButton
className={{
content: 'border-0 rounded-3',
body: 'text-center',
}}
>
<div className="modal-header bg-success text-white border-0 rounded-top-3">
<h5 className="modal-title mb-0 fw-bold">
<i className="bi bi-check-circle-fill me-2"></i>
QR Escaneado Correctamente
</h5>
</div>
<div className="modal-body p-4">
{scannedData ? (
<>
<div className="mb-4">
<div className="mb-3">
<i
className="bi bi-person-check-fill text-success"
style={{ fontSize: '3rem' }}
></i>
</div>
<h5 className="mb-3 fw-semibold">
Información del participante
</h5>
<div className="card bg-light border-0 rounded-3">
<div className="card-body py-3">
<div className="d-flex align-items-center justify-content-center">
<i className="bi bi-envelope-fill text-primary me-2"></i>
<strong className="me-2">Correo:</strong>
<span className="text-primary fw-medium">
{participante}
</span>
</div>
</div>
</div>
</div>
<div className="d-grid gap-2 d-md-flex justify-content-center">
<Button
variant="success"
onClick={handleConfirm}
className="px-4 py-2 rounded-pill fw-semibold"
>
<i className="bi bi-check2 me-2"></i>
Confirmar Asistencia
</Button>
<Button
variant="outline-secondary"
onClick={() => {
setShowModal(false);
setEnableScan(true);
setScannedData(null);
}}
className="px-4 py-2 rounded-pill"
>
<i className="bi bi-x-lg me-2"></i>
Cancelar
</Button>
</div>
</>
) : (
<div className="py-4">
<div className="mb-3">
<i
className="bi bi-exclamation-triangle-fill text-warning"
style={{ fontSize: '2.5rem' }}
></i>
</div>
<h6 className="text-danger mb-0">QR inválido o malformado</h6>
</div>
)}
</div>
</Modal>
</main>
);
}
@@ -1,66 +0,0 @@
'use client';
import Button from '@/components/button';
import Input from '@/components/input';
import { useRouter } from 'next/navigation';
import React, { useState } from 'react';
import Cookies from 'js-cookie';
export default function Page() {
const router = useRouter();
const [formData, setFormData] = useState({ username: '', password: '' });
const [error, setError] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
setError('');
};
const handleOnSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const { username, password } = formData;
if (username === 'user-admin' && password === '@dm1nP@ss') {
Cookies.set('token', 'staff1');
router.push('/administrador/eventos');
} else {
setError('Usuario o contraseña incorrectos');
}
};
return (
<div className="d-flex flex-column justify-content-center align-items-center">
<h1 className="text-dorado">Iniciar sesión</h1>
<h2 className="text-azul">Administradores</h2>
<form className="w-300px" onSubmit={handleOnSubmit}>
<Input
name="username"
label="Usuario"
value={formData.username}
onChange={handleChange}
/>
<Input
name="password"
type="password"
label="Contraseña"
value={formData.password}
onChange={handleChange}
/>
{error && (
<div className="alert alert-danger text-center" role="alert">
{error}
</div>
)}
<div className="mb-3">
<Button className="w-100" type="submit">
Iniciar sesión
</Button>
</div>
</form>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
'use client';
import SimpleInput from '@/components/input';
import PasswordInput from '@/components/password';
import React from 'react';
import { useRouter } from 'next/navigation';
export default function LoginForm() {
const router = useRouter();
// Fake user data
const fakeUsers = [
{
username: 'user-admin',
password: '@dm1nP@ss',
role: 'administrator',
redirectPath: '/user/eventos',
},
{
username: 'staff1',
password: 'st@ffP@ss',
role: 'staff',
redirectPath: '/user/eventos',
},
];
const [loginData, setLoginData] = React.useState({
username: '',
password: '',
});
const [error, setError] = React.useState('');
const [isLoading, setIsLoading] = React.useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setLoginData((prevData) => ({ ...prevData, [name]: value }));
// Clear error when user starts typing
if (error) setError('');
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
// Simulate API call delay
setTimeout(() => {
const user = fakeUsers.find(
(u) =>
u.username === loginData.username && u.password === loginData.password
);
if (user) {
// Store user data in localStorage (in a real app, you'd use proper session management)
localStorage.setItem(
'user',
JSON.stringify({
username: user.username,
role: user.role,
})
);
// Redirect to appropriate dashboard
router.push(user.redirectPath);
} else {
setError('Usuario o contraseña incorrectos');
}
setIsLoading(false);
}, 1000);
};
return (
<div className="container my-5 my-md-0">
<div className="row justify-content-center">
<div className="col-xl-10">
<div className="card border-0">
<div className="card-body p-0">
<div className="row">
<div className="col-lg-6">
<div className="p-5">
<h3 className="fs-4 font-weight-bold text-azul mb-3 login">
Iniciar sesión
</h3>
<h6 className="h5 mb-0">
Bienvenido de vuelta al{' '}
<span className="text-dorado">Sistema de Eventos</span>
</h6>
<p className="text-muted mt-2 mb-4">
Ingresa tu nombre de usuario y tu contraseña para acceder
a tu cuenta.
</p>
{/* Error message */}
{error && (
<div className="alert alert-danger mb-4">{error}</div>
)}
<form onSubmit={handleSubmit}>
<SimpleInput
label="Nombre de usuario"
type="text"
id="username"
name="username"
value={loginData.username}
onChange={handleChange}
required
/>
<PasswordInput
label="Contraseña"
id="password"
name="password"
value={loginData.password}
onChange={handleChange}
required
/>
<div className="justify-content-between align-items-center d-flex">
<button
type="submit"
className="btn btn-azul"
disabled={isLoading}
>
{isLoading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Ingresando...
</>
) : (
'Ingresar'
)}
</button>
</div>
</form>
</div>
</div>
<div className="col-lg-6 d-none d-lg-inline-block">
<div className="position-relative h-100 p-2">
<div
style={{
borderRadius: '0.5rem',
backgroundImage: 'url(/assets/image.png)',
backgroundSize: 'cover',
backgroundPosition: 'center',
height: '100%',
width: '100%',
}}
></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
-62
View File
@@ -1,62 +0,0 @@
'use client';
import Button from '@/components/button';
import Input from '@/components/input';
import { useRouter } from 'next/navigation';
import React, { useState } from 'react';
import Cookies from 'js-cookie';
export default function Page() {
const router = useRouter();
const [formData, setFormData] = useState({ username: '', password: '' });
const [error, setError] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
setError('');
};
const handleOnSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const { username, password } = formData;
if (username === 'staff1' && password === 'st@ffP@ss') {
Cookies.set('token', 'staff1');
router.push('/staff');
} else {
setError('Usuario o contraseña incorrectos');
}
};
return (
<div className='d-flex flex-column justify-content-center align-items-center'>
<h1 className='text-dorado'>Iniciar sesión</h1>
<h2 className='text-azul'>Staff</h2>
<form className='w-300px' onSubmit={handleOnSubmit}>
<Input
name='username'
label='Usuario'
value={formData.username}
onChange={handleChange}
/>
<Input
name='password'
type='password'
label='Contraseña'
value={formData.password}
onChange={handleChange}
/>
{error && <div className='alert alert-danger text-center' role='alert'>{error}</div>}
<div className='mb-3'>
<Button className='w-100' type='submit'>
Iniciar sesión
</Button>
</div>
</form>
</div>
);
}
+2 -13
View File
@@ -3,7 +3,7 @@
import ClientCarousel from '@/client-components/client-carousel';
import FormularioCardUser from '@/components/formulario/formulario-card-user';
import { useGetApi } from '@/hooks/use-get-api';
import { GetEvento, GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
import React from 'react';
export default function Page() {
@@ -35,17 +35,6 @@ export default function Page() {
<div className="row">
{data.flatMap((evento) =>
evento.cuestionarios.map((cuestionario, index) => {
const eventoData: GetEvento = {
id_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,
asistencias: evento.asistencias,
banner: evento.banner,
};
return (
<div
className={`col-md-6 col-lg-4 my-3 fade-in-up-bounce delay-${
@@ -54,7 +43,7 @@ export default function Page() {
key={`${evento.id_evento}-${cuestionario.id_cuestionario}`}
>
<FormularioCardUser
evento={eventoData}
evento={evento}
formulario={cuestionario}
/>
</div>
+3 -1
View File
@@ -9,6 +9,7 @@ interface BannerUploaderProps {
onChange?: (file: File | null) => void;
defaultBanner?: string;
className?: string;
showPreview?: boolean;
}
export default function ImageUploader({
@@ -16,6 +17,7 @@ export default function ImageUploader({
onChange,
defaultBanner,
className = '',
showPreview = true,
}: BannerUploaderProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(defaultBanner || null);
@@ -63,7 +65,7 @@ export default function ImageUploader({
</Button>
)}
</div>
{preview && (
{preview && showPreview && (
<div className="d-flex justify-content-center">
<Image
src={preview}
+23 -17
View File
@@ -3,6 +3,7 @@ import Image from 'next/image';
import Link from 'next/link';
import React, { useState } from 'react';
import MarkdownRenderer from '../markdown-render';
import { formatearFechaCard } from '@/utils/date-utils';
export default function EventoCardAdmin({
evento,
@@ -18,6 +19,12 @@ export default function EventoCardAdmin({
? `${descripcion.slice(0, limite)}...`
: descripcion;
// Obtener información de fecha usando la utilidad
const { mesTexto, diaTexto } = formatearFechaCard(
evento.fecha_inicio,
evento.fecha_fin
);
return (
<div
className="card shadow-sm border-0"
@@ -51,26 +58,25 @@ export default function EventoCardAdmin({
<div className="card-body p-3 d-flex flex-column">
{/* Fecha del evento */}
<div className="d-flex align-items-center mb-2">
<div className="me-3">
<div className="text-muted text-center small">
{new Date(evento.fecha_inicio)
.toLocaleDateString('es-ES', { month: 'short' })
.toUpperCase()}
<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 h4 mb-0"
style={{ lineHeight: 1, whiteSpace: 'nowrap' }}
className="fw-bold mb-0 text-center"
style={{
lineHeight: 1,
whiteSpace: 'nowrap',
fontSize: diaTexto.toString().length > 2 ? '1rem' : '1.25rem',
}}
>
{(() => {
const fechaInicio = new Date(evento.fecha_inicio);
const fechaFin = new Date(evento.fecha_fin);
const diaInicio = fechaInicio.getDate();
const diaFin = fechaFin.getDate();
const esElMismoDia =
fechaInicio.toDateString() === fechaFin.toDateString();
return esElMismoDia ? diaInicio : `${diaInicio} - ${diaFin}`;
})()}
{diaTexto}
</div>
</div>
<div>
@@ -0,0 +1,143 @@
'use client';
import { CreateEventoType } from '@/types/create-evento';
import Image from 'next/image';
import React, { useState, useEffect } from 'react';
import MarkdownRenderer from '@/components/markdown-render';
import { formatearFechaCard } from '@/utils/date-utils';
import Button from '../button';
interface EventoPreviewProps {
evento: CreateEventoType;
eventoBanner?: File | null;
}
export default function EventoCardPreview({
evento,
eventoBanner,
}: EventoPreviewProps) {
const [verMas, setVerMas] = useState(false);
const [bannerUrl, setBannerUrl] = useState<string>('/default-banner.png');
const descripcion = evento.descripcion_evento ?? '';
const limite = 100;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
// Actualizar URL del banner cuando cambie el archivo
useEffect(() => {
if (eventoBanner) {
const url = URL.createObjectURL(eventoBanner);
setBannerUrl(url);
// Limpiar URL anterior cuando el componente se desmonte
return () => URL.revokeObjectURL(url);
} else {
setBannerUrl('/default-banner.png');
}
}, [eventoBanner]);
// Obtener información de fecha usando la utilidad
const getDateInfo = () => {
if (!evento.fecha_inicio || !evento.fecha_fin) {
return { mesTexto: 'MES', diaTexto: 'DD' };
}
return formatearFechaCard(evento.fecha_inicio, evento.fecha_fin);
};
const { mesTexto, diaTexto } = getDateInfo();
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' }}
/>
{/* Badge indicando que no hay formularios aún */}
<div className="position-absolute top-0 end-0 m-2">
<span className="badge bg-secondary">Sin formularios</span>
</div>
</div>
<div className="card-body p-3 d-flex flex-column">
{/* Fecha del evento */}
<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">
{evento.nombre_evento || 'Nombre del evento'}
</h5>
</div>
</div>
{/* Tipo de evento */}
{evento.tipo_evento && (
<div className="mb-2">
<small className="text-muted">
<i className="bi bi-tag-fill me-1"></i>
{evento.tipo_evento}
</small>
</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 evento...</p>
)}
</div>
{/* Botón principal - deshabilitado */}
<Button variant="primary" className="w-100 rounded-pill" disabled>
Ver/Editar evento
</Button>
</div>
</div>
);
}
@@ -1,5 +1,148 @@
import React from 'react';
import { CuestionarioWithCupo, GetEvento } from '@/types/evento';
import Image from 'next/image';
import Link from 'next/link';
import React, { useState } from 'react';
import MarkdownRenderer from '../markdown-render';
import { formatearFechaCard } from '@/utils/date-utils';
import Button from '../button';
export default function FormularioCardAdmin() {
return <div>FormularioCardAdmin</div>;
export default function FormularioCardAdmin({
cuestionario,
evento,
}: {
cuestionario: CuestionarioWithCupo;
evento: GetEvento;
}) {
const [verMas, setVerMas] = useState(false);
const descripcion = cuestionario.descripcion ?? '';
const limite = 100;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
: descripcion;
// Obtener información de fecha usando la utilidad
const { mesTexto, diaTexto } = formatearFechaCard(
cuestionario.fecha_inicio,
cuestionario.fecha_fin
);
return (
<div
className="card shadow-sm border-0"
style={{ borderRadius: '12px', overflow: 'hidden' }}
>
<div className="position-relative">
<Link
href={`/administrador/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
>
<Image
width={400}
height={250}
className="img-fluid w-100"
src={
cuestionario.banner
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`
: `${process.env.NEXT_PUBLIC_API_URL}/banners/default-banner.png`
}
alt="Banner formulario"
style={{ objectFit: 'cover', height: '200px' }}
/>
</Link>
{/* 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">
{cuestionario.tipoCuestionario?.tipo_cuestionario || 'Formulario'}
</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">
{cuestionario.nombre_form}
</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">
<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>
)}
</div>
{/* Información de cupos y estadísticas */}
<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">
{cuestionario.cupo_maximo !== null
? `${cuestionario.cupos_usados} / ${cuestionario.cupo_maximo}`
: 'Sin límite'}
</span>
</div>
</div>
</div>
</div>
{/* Botones de acción */}
<div className="d-grid gap-2">
<Link
href={`/administrador/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">
<i className="bi bi-download me-2"></i>
Descargar respuestas
</Button>
</div>
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import Link from 'next/link';
import React, { useState } from 'react';
import MarkdownRenderer from '../markdown-render';
import { toParam } from '@/utils/slugify';
import Button from '../button';
export default function FormularioCardUser({
evento,
@@ -17,6 +18,10 @@ export default function FormularioCardUser({
const descripcion = formulario.descripcion ?? '';
const limite = 100;
// Determinar si hay cupos disponibles
const sinCupos =
formulario.cupo_maximo !== null && formulario.cupos_disponibles <= 0;
const descripcionRecortada =
descripcion.length > limite && !verMas
? `${descripcion.slice(0, limite)}...`
@@ -28,31 +33,47 @@ export default function FormularioCardUser({
style={{ borderRadius: '12px', overflow: 'hidden' }}
>
<div className="position-relative">
<Link
href={`/evento/${toParam(
evento.nombre_evento,
evento.id_evento
)}/${toParam(formulario.nombre_form, formulario.id_cuestionario)}`}
>
<Image
width={400}
height={250}
className="img-fluid w-100"
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${formulario.banner}`}
alt="Banner formulario"
style={{ objectFit: 'cover', height: '200px' }}
/>
</Link>
{sinCupos ? (
<div className="position-relative">
<Image
width={400}
height={250}
className="img-fluid w-100"
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${formulario.banner}`}
alt="Banner formulario"
style={{
objectFit: 'cover',
height: '200px',
filter: 'grayscale(50%) opacity(0.7)',
}}
/>
<div className="position-absolute top-50 start-50 translate-middle bg-gradient bg-black bg-opacity-75 text-white px-3 py-2 rounded-pill">
Sin cupos disponibles
</div>
</div>
) : (
<Link
href={`/evento/${toParam(
evento.nombre_evento,
evento.id_evento
)}/${toParam(formulario.nombre_form, formulario.id_cuestionario)}`}
>
<Image
width={400}
height={250}
className="img-fluid w-100"
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${formulario.banner}`}
alt="Banner formulario"
style={{ objectFit: 'cover', height: '200px' }}
/>
</Link>
)}
{/* Badge de cupos en la esquina superior derecha */}
<div className="position-absolute top-0 end-0 m-2">
{formulario.cupo_maximo === null ? (
<span className="badge bg-success">Sin límite</span>
) : (
<span className="badge bg-primary">
{formulario.cupos_disponibles} cupos
</span>
)}
<span className="badge bg-primary">
{formulario.tipoCuestionario.tipo_cuestionario}
</span>
</div>
</div>
@@ -92,7 +113,7 @@ export default function FormularioCardUser({
</div>
{/* Descripción */}
<div className="card-description flex-grow-1 mb-3">
<div className="card-description flex-grow-1 mb-2">
<MarkdownRenderer markdown={descripcionRecortada} />
{descripcion.length > limite && (
<button
@@ -105,16 +126,49 @@ export default function FormularioCardUser({
)}
</div>
{/* Información de cupos y estadísticas */}
<div className="mb-3">
<div
className={`py-2 px-3 rounded ${
sinCupos ? 'bg-primary-subtle' : 'bg-light'
}`}
>
<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 text-primary`}>
{formulario.cupo_maximo !== null
? `${formulario.cupos_usados} / ${formulario.cupo_maximo}`
: 'Sin límite'}
</span>
{sinCupos && (
<div className="small text-primary mt-1">
<i className="bi bi-exclamation-circle me-1"></i>
Cupos agotados
</div>
)}
</div>
</div>
</div>
</div>
{/* Botón de registro */}
<Link
href={`/evento/${toParam(
evento.nombre_evento,
evento.id_evento
)}/${toParam(formulario.nombre_form, formulario.id_cuestionario)}`}
className="btn btn-primary w-100 rounded-pill"
>
Registro
</Link>
{sinCupos ? (
<Button disabled className="rounded-pill">
Sin cupos disponibles
</Button>
) : (
<Link
href={`/evento/${toParam(
evento.nombre_evento,
evento.id_evento
)}/${toParam(formulario.nombre_form, formulario.id_cuestionario)}`}
className="btn btn-primary w-100 rounded-pill"
>
Registro
</Link>
)}
</div>
</div>
);
+35 -34
View File
@@ -1,9 +1,21 @@
import React from "react";
import Link from "next/link";
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const Navbar: React.FC = () => {
const pathname = usePathname();
const navItems = [
{ href: '/', label: 'Inicio' },
{ href: '/user/eventos', label: 'Eventos' },
{ href: '/user/qr', label: 'Lector de QRs' },
{ href: '/user/eventos/crear', label: 'Crear Evento' },
{ href: '/signout', label: 'Cerrar sesión' },
];
return (
<nav className="navbar navbar-expand-lg navbar-light bg-transparent fixed-top">
<nav className="navbar navbar-expand-lg navbar-dark bg-azul">
<div className="container">
<button
className="navbar-toggler border-0 ms-auto"
@@ -31,38 +43,27 @@ const Navbar: React.FC = () => {
aria-label="Close"
></button>
</div>
<div className="offcanvas-body justify-content-center">
<div className="offcanvas-body align-items-center justify-content-between">
<h2 className="h5 mb-0 text-white">
Sistema de Registro de Eventos
</h2>
<ul className="navbar-nav gap-lg-5">
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/">
Inicio
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/table">
Table
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/input">
Input
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/pagination">
Pagination
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/options">
Options
</Link>
</li>
<li className="nav-item" data-bs-dismiss="offcanvas">
<Link className="nav-link" href="/styles">
Styles
</Link>
</li>
{navItems.map((item, index) => (
<li
key={index}
className={`nav-item`}
data-bs-dismiss="offcanvas"
>
<Link
className={`nav-link ${
pathname === item.href ? 'text-white' : ''
}`}
href={item.href}
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
</div>
+75 -96
View File
@@ -26,107 +26,86 @@ export default function CreateEvento({
}: CreateEventoProps) {
return (
<div className="mt-2">
<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.
</p>
<p className="mb-0 text-muted">
Presiona siguiente para continuar con la creacion del formulario.
<h4 className="mb-3">Detalles del Evento</h4>
<div className="mb-2">
<ImageUploader
label="Banner del evento"
onChange={handleBannerChange}
showPreview={false}
/>
<p className="text-muted mt-2 mb-0 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 className="row mt-3">
{/* Columna Izquierda - Banner */}
<div className="col-lg-6">
<div className="pe-lg-3">
<ImageUploader
label="Banner del evento"
onChange={handleBannerChange}
/>
<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>
<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(evento.fecha_inicio)}
onChange={(e) =>
handleChange('fecha_inicio', new Date(e.target.value))
}
/>
</div>
{/* Columna Derecha - Detalles del Evento */}
<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="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(evento.fecha_inicio)}
onChange={(e) =>
handleChange('fecha_inicio', new Date(e.target.value))
}
/>
</div>
<div className="col-md-6">
<Input
label="Fecha de fin"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(evento.fecha_fin)}
onChange={(e) =>
handleChange('fecha_fin', new Date(e.target.value))
}
/>
</div>
</div>
</div>
<div className="col-md-6">
<Input
label="Fecha de fin"
type="datetime-local"
className={{ container: 'mb-3' }}
value={formatDateLocal(evento.fecha_fin)}
onChange={(e) =>
handleChange('fecha_fin', new Date(e.target.value))
}
/>
</div>
</div>
</div>
+75
View File
@@ -193,3 +193,78 @@ export function formatearRangoFechas(
// Si tanto el año como el mes son diferentes
return `del ${diaInicio} de ${mesInicio} del ${añoInicio} al ${diaFin} de ${mesFin} del ${añoFin}`;
}
/**
* Formatea fechas para mostrar en las cartas de eventos, optimizando el espacio disponible.
* Retorna un objeto con el texto del mes y el texto del día para mostrar en la interfaz.
*
* @param {Date | string} fechaInicio - Fecha de inicio del evento.
* @param {Date | string} fechaFin - Fecha de fin del evento.
* @returns {{ mesTexto: string, diaTexto: string }} Objeto con texto del mes y día formateados.
* @throws {Error} Lanza un error si alguna de las fechas proporcionadas no es válida.
*
* @example
* formatearFechaCard(new Date('2025-08-15'), new Date('2025-08-15'));
* // Retorna: { mesTexto: 'AGO', diaTexto: '15' }
*
* @example
* formatearFechaCard(new Date('2025-08-15'), new Date('2025-08-20'));
* // Retorna: { mesTexto: 'AGO', diaTexto: '15 - 20' }
*
* @example
* formatearFechaCard(new Date('2025-08-28'), new Date('2025-09-05'));
* // Retorna: { mesTexto: 'AGO - SEP', diaTexto: '28 - 5' }
*/
export function formatearFechaCard(
fechaInicio: Date | string,
fechaFin: Date | string
): { mesTexto: string; diaTexto: string } {
// Convertir a Date si son strings
const inicio =
typeof fechaInicio === 'string' ? new Date(fechaInicio) : fechaInicio;
const fin = typeof fechaFin === 'string' ? new Date(fechaFin) : fechaFin;
if (isNaN(inicio.getTime())) {
throw new Error(`Fecha de inicio inválida: ${fechaInicio}`);
}
if (isNaN(fin.getTime())) {
throw new Error(`Fecha de fin inválida: ${fechaFin}`);
}
const mesInicioCorto = inicio
.toLocaleDateString('es-ES', { month: 'short' })
.toUpperCase();
const mesFinCorto = fin
.toLocaleDateString('es-ES', { month: 'short' })
.toUpperCase();
const diaInicio = inicio.getDate();
const diaFin = fin.getDate();
// Si es el mismo día
const esElMismoDia = inicio.toDateString() === fin.toDateString();
// Si es el mismo mes pero diferentes días
const esElMismoMes =
inicio.getMonth() === fin.getMonth() &&
inicio.getFullYear() === fin.getFullYear();
if (esElMismoDia) {
return {
mesTexto: mesInicioCorto,
diaTexto: diaInicio.toString(),
};
} else if (esElMismoMes) {
return {
mesTexto: mesInicioCorto,
diaTexto: `${diaInicio} - ${diaFin}`,
};
} else {
// Diferentes meses
return {
mesTexto: `${mesInicioCorto} - ${mesFinCorto}`,
diaTexto: `${diaInicio} - ${diaFin}`,
};
}
}