develop #27
Binary file not shown.
|
After Width: | Height: | Size: 10 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -1,161 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { CreateEventoType } from '@/types/create-evento';
|
||||
import { FormularioCreacion } from '@/types/create-formulario';
|
||||
import CreateEvento from '@/containers/create-evento';
|
||||
import { plantillasDisponibles } from '@/data/plantillas';
|
||||
import Image from 'next/image';
|
||||
import CreateFormulario from '@/containers/create-formulario';
|
||||
import Button from '@/components/button';
|
||||
import axiosInstance from '@/utils/api-config';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getAxiosError } from '@/utils/errors-utils';
|
||||
|
||||
export default function Page() {
|
||||
const [eventoBanner, setEventoBanner] = useState<File | null>(null);
|
||||
const [evento, setEvento] = useState<CreateEventoType>({
|
||||
tipo_evento: '',
|
||||
nombre_evento: '',
|
||||
descripcion_evento: '',
|
||||
fecha_inicio: new Date(),
|
||||
fecha_fin: new Date(),
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const [datosFormulario, setDatosFormulario] =
|
||||
useState<FormularioCreacion | null>(null);
|
||||
|
||||
const handleChange = (
|
||||
field: keyof CreateEventoType,
|
||||
value: string | Date
|
||||
) => {
|
||||
setEvento((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBannerChange = (file: File | null) => {
|
||||
setEventoBanner(file);
|
||||
};
|
||||
|
||||
const handleSeleccionarPlantilla = (plantillaId: string) => {
|
||||
const seleccionada = plantillasDisponibles.find(
|
||||
(p) => p.id === plantillaId
|
||||
);
|
||||
if (seleccionada) {
|
||||
setDatosFormulario(seleccionada.datos);
|
||||
console.log('Plantilla seleccionada:', seleccionada);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCrearEventoYFormulario = async () => {
|
||||
try {
|
||||
const createEventoWithCuestionario = await axiosInstance.post('/cuestionario/withEvento', {
|
||||
evento: evento,
|
||||
cuestionario: datosFormulario,
|
||||
});
|
||||
|
||||
if (createEventoWithCuestionario) {
|
||||
const formData = new FormData();
|
||||
if (eventoBanner) {
|
||||
formData.append('banner', eventoBanner);
|
||||
const bannercreated = await axiosInstance.post(
|
||||
`/evento/${createEventoWithCuestionario.data.id_evento}/banner`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
);
|
||||
console.log('Banner creado:', bannercreated.data);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Evento y formulario creados:', createEventoWithCuestionario.data);
|
||||
|
||||
toast.success('Evento y formulario creados exitosamente');
|
||||
router.push(`/administrador`);
|
||||
} catch (error) {
|
||||
const msg = getAxiosError(error);
|
||||
toast.error(
|
||||
`Error al crear el evento o formulario: ${msg.message || 'Error desconocido'}`
|
||||
);
|
||||
console.error('Error al crear evento y formulario:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="my-4">
|
||||
<h1>Crear evento y formulario de registro</h1>
|
||||
<p>
|
||||
En esta pagina podras crear un evento y un primer formulario de
|
||||
registro, posteriormente podras crear mas formularios relacionados a
|
||||
este evento.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CreateEvento
|
||||
evento={evento}
|
||||
handleChange={handleChange}
|
||||
handleBannerChange={handleBannerChange}
|
||||
/>
|
||||
|
||||
{!datosFormulario && (
|
||||
<div className="border p-3 rounded my-4">
|
||||
<h2 className="h5 fw-bold mb-2">
|
||||
Selecciona una plantilla para el formulario:
|
||||
</h2>
|
||||
<p className="text-secondary small lh-sm">
|
||||
Elige una de las plantillas disponibles para crear tu formulario de
|
||||
registro.
|
||||
<br />
|
||||
Estas plantillas están diseñadas para garantizar la correcta
|
||||
integración con el sistema, permitiendo que los datos se obtengan y
|
||||
se prellenan automáticamente de forma precisa.
|
||||
<br />
|
||||
<span className="text-danger fw-semibold">
|
||||
Para asegurar el funcionamiento óptimo del prellenado, modifica la
|
||||
estructura solo si es necesario.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="d-flex gap-4 flex-wrap">
|
||||
{plantillasDisponibles.map((plantilla) => (
|
||||
<div
|
||||
className="mb-3 cursor-pointer"
|
||||
key={plantilla.id}
|
||||
onClick={() => handleSeleccionarPlantilla(plantilla.id)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<Image
|
||||
src={plantilla.imagen}
|
||||
width={200}
|
||||
height={200}
|
||||
alt={`Plantilla de formulario ${plantilla.nombre}`}
|
||||
className="img-fluid rounded shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{datosFormulario && (
|
||||
<CreateFormulario
|
||||
formulario={datosFormulario}
|
||||
onChange={(nuevo) => setDatosFormulario({ ...nuevo })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button className="mb-4" onClick={handleCrearEventoYFormulario}>
|
||||
Crear Evento y Formulario
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
'use client';
|
||||
import EditEvento from '@/containers/edit-evento';
|
||||
import { useGetApi } from '@/hooks/use-get-api';
|
||||
import {
|
||||
CuestionarioWithCupo,
|
||||
GetEventoWithCuestionarios,
|
||||
} from '@/types/evento';
|
||||
import { useParams } from 'next/navigation';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CreateEventoType } from '@/types/create-evento';
|
||||
import axiosInstance from '@/utils/api-config';
|
||||
import toast from 'react-hot-toast';
|
||||
import Table, { Header } from '@/components/table';
|
||||
import Button from '@/components/button';
|
||||
import { downloadFile } from '@/utils/downloas-utils';
|
||||
import Link from 'next/link';
|
||||
|
||||
type Params = {
|
||||
id_evento: string;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const params = useParams<Params>();
|
||||
const { data } = useGetApi<GetEventoWithCuestionarios>(
|
||||
`/evento/${params.id_evento}/cuestionarios`
|
||||
);
|
||||
|
||||
const [evento, setEvento] = useState<GetEventoWithCuestionarios | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setEvento(data);
|
||||
}, [data]);
|
||||
|
||||
const handleChange = (
|
||||
field: keyof CreateEventoType,
|
||||
value: string | Date
|
||||
) => {
|
||||
setEvento((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
[field]: value,
|
||||
}
|
||||
: null
|
||||
);
|
||||
};
|
||||
|
||||
const handleEventoActualizado = (
|
||||
eventoActualizado: GetEventoWithCuestionarios
|
||||
) => {
|
||||
setEvento(eventoActualizado);
|
||||
};
|
||||
|
||||
const headers: Header<CuestionarioWithCupo>[] = [
|
||||
{
|
||||
key: 'nombre_form',
|
||||
label: 'Nombre del cuestionario',
|
||||
},
|
||||
{
|
||||
key: 'cupo_maximo',
|
||||
label: 'Cupos',
|
||||
render: (_, row) => {
|
||||
if (row.cupo_maximo === null) {
|
||||
return 'Sin límite';
|
||||
}
|
||||
|
||||
return `${row.cupos_usados ?? 0} / ${row.cupo_maximo}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'banner',
|
||||
label: 'Ver / Editar',
|
||||
render: (_, row) => (
|
||||
<div className="d-flex gap-2">
|
||||
<Link
|
||||
className="btn btn-primary"
|
||||
href={`/administrador/evento/${params.id_evento}/${row.id_cuestionario}`}
|
||||
>
|
||||
Ver/Editar formulario
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'banner',
|
||||
label: 'Descargar respuestas',
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
variant="success"
|
||||
icon="download"
|
||||
onClick={() => handleDownload(row.id_cuestionario, row.nombre_form)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm me-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Descargando...
|
||||
</>
|
||||
) : (
|
||||
'Descargar respuestas'
|
||||
)}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!evento) return <p>Cargando...</p>;
|
||||
|
||||
const handleDownload = async (id_cuestionario: number, nombre: string) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await axiosInstance.get(
|
||||
`/cuestionario-respondido/reporte-respuestas/${id_cuestionario}`,
|
||||
{
|
||||
responseType: 'blob',
|
||||
}
|
||||
);
|
||||
|
||||
downloadFile(res.data, `${nombre}`, 'csv');
|
||||
} catch (error) {
|
||||
console.error('Error al descargar el archivo:', error);
|
||||
toast.error('Error al descargar el archivo');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-4">
|
||||
<Link
|
||||
href={`/administrador`}
|
||||
className="btn btn-link mb-3"
|
||||
>
|
||||
<i className="bi bi-arrow-left me-2"></i>
|
||||
Volver
|
||||
</Link>
|
||||
|
||||
<h2 className="mb-4">Evento</h2>
|
||||
|
||||
<EditEvento
|
||||
evento={evento}
|
||||
handleChange={handleChange}
|
||||
handleOnChange={handleEventoActualizado}
|
||||
/>
|
||||
|
||||
{data?.cuestionarios && data.cuestionarios.length > 0 && (
|
||||
<>
|
||||
<h3 className="mt-5">Cuestionarios del evento</h3>
|
||||
<div className="mt-2">
|
||||
<Table headers={headers} data={data.cuestionarios ?? []} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Button from '@/components/button';
|
||||
import EventoCard from '@/components/evento-card';
|
||||
import { useGetApi } from '@/hooks/use-get-api';
|
||||
import { GetEventoWithCuestionarios } from '@/types/evento';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const { loading, data, error } = useGetApi<GetEventoWithCuestionarios[]>(
|
||||
'/evento/activos/cuestionarios'
|
||||
);
|
||||
const { data: recientes } = useGetApi<GetEventoWithCuestionarios[]>(
|
||||
'/evento/recientes/cuestionarios'
|
||||
);
|
||||
|
||||
if (loading) return <div>Cargando formularios...</div>;
|
||||
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/crear-evento">
|
||||
<Button>Crear Evento</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{loading && <div>Cargando...</div>}
|
||||
{data && (
|
||||
<div className="row">
|
||||
{data.map((item, key) => {
|
||||
const fadeClass = `delay-${(key % 5) + 1}`;
|
||||
return (
|
||||
<div
|
||||
className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`}
|
||||
key={key}
|
||||
>
|
||||
<EventoCard evento={item} user="administrador" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{recientes && (
|
||||
<div className="row">
|
||||
<h2>Eventos 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}
|
||||
>
|
||||
<EventoCard evento={item} user="administrador" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+14
-10
@@ -1,4 +1,5 @@
|
||||
'use client';
|
||||
import Breadcrumb from '@/components/breadcrumb';
|
||||
import SimpleInput from '@/components/input';
|
||||
import Table, { Header } from '@/components/table';
|
||||
import EditFormulario from '@/containers/edit-formulario';
|
||||
@@ -6,10 +7,10 @@ import { useGetApi } from '@/hooks/use-get-api';
|
||||
import { GetCuestionario } from '@/types/cuestionario';
|
||||
import { ParticipacionEvento } from '@/types/participante-evento';
|
||||
import axiosInstance from '@/utils/api-config';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import React, { useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useEvento } from '@/context/evento';
|
||||
|
||||
type Params = {
|
||||
id_evento: string;
|
||||
@@ -18,6 +19,7 @@ type Params = {
|
||||
|
||||
export default function Page() {
|
||||
const params = useParams<Params>();
|
||||
const { evento } = useEvento();
|
||||
|
||||
const [cuestionario, setCuestionario] =
|
||||
React.useState<GetCuestionario | null>(null);
|
||||
@@ -140,21 +142,23 @@ export default function Page() {
|
||||
p.participante.correo.toLowerCase().includes(busquedaCorreo.toLowerCase())
|
||||
);
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: 'Inicio', href: '/user/eventos' },
|
||||
{
|
||||
label: evento?.nombre_evento || 'Evento',
|
||||
href: `/user/evento/${params.id_evento}`,
|
||||
},
|
||||
{ label: cuestionario?.nombre_form || 'Formulario' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="my-4">
|
||||
<Link
|
||||
href={`/administrador/evento/${params.id_evento}`}
|
||||
className="btn btn-link mb-3"
|
||||
>
|
||||
<i className="bi bi-arrow-left me-2"></i>
|
||||
Volver
|
||||
</Link>
|
||||
|
||||
<h2 className="mb-4">Formulario</h2>
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
|
||||
{cuestionario && (
|
||||
<EditFormulario
|
||||
cuestionario={cuestionario}
|
||||
evento={evento}
|
||||
handleChange={handleChange}
|
||||
handleOnChange={handleCuestionarioActualizado}
|
||||
/>
|
||||
@@ -0,0 +1,297 @@
|
||||
'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 FormularioCardPreview from '@/components/formulario/formulario-card-preview';
|
||||
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';
|
||||
import { useGetApi } from '@/hooks/use-get-api';
|
||||
import { TipoEvento } from '@/types/evento';
|
||||
|
||||
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 { data: tipo_eventos } = useGetApi<TipoEvento[]>(`/tipo-evento`);
|
||||
|
||||
const handleSeleccionarPlantilla = (plantillaId: string) => {
|
||||
const seleccionada = plantillasDisponibles.find(
|
||||
(p) => p.id === plantillaId
|
||||
);
|
||||
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: '/user/eventos' },
|
||||
{
|
||||
label: evento?.nombre_evento || 'Evento',
|
||||
href: `/user/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(`/user/evento/${params.id_evento}`);
|
||||
} catch (error) {
|
||||
const msg = getAxiosError(error);
|
||||
toast.error(msg.message || 'Error al crear el formulario');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container-fluid my-4">
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
|
||||
{/* 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">
|
||||
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 && (
|
||||
<>
|
||||
<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,
|
||||
tipo_eventos: tipo_eventos?.map((tipo) => ({
|
||||
value: tipo.id_tipo_evento,
|
||||
label: tipo.tipo_evento,
|
||||
})),
|
||||
});
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</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,132 @@
|
||||
'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 Breadcrumb from '@/components/breadcrumb';
|
||||
import Button from '@/components/button';
|
||||
import FormularioCardAdmin from '@/components/formulario/formulario-card-admin';
|
||||
|
||||
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_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">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: 'Eventos', href: '/user/eventos' },
|
||||
{
|
||||
label: `${evento.nombre_evento}`,
|
||||
href: `/user/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(`/user/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}
|
||||
>
|
||||
<FormularioCardAdmin
|
||||
cuestionario={item}
|
||||
evento={evento}
|
||||
handleDownload={handleDownload}
|
||||
disabled={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(
|
||||
`/user/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'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 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,
|
||||
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>;
|
||||
|
||||
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>}
|
||||
{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}
|
||||
handleDownload={handleDownload}
|
||||
disabled={
|
||||
loadingStates[cuestionario.id_cuestionario] || false
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
'use client'
|
||||
import dynamic from 'next/dynamic';
|
||||
import Footer from '@/components/layout/footer';
|
||||
import Header from '@/components/layout/header';
|
||||
const Navbar = dynamic(() => import('@/components/navbar'), { ssr: false });
|
||||
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,272 @@
|
||||
'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 [showModal, setShowModal] = useState(false);
|
||||
const [enableScan, setEnableScan] = useState(true);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
|
||||
const [participante, setParticipante] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
const [tokenValid, setTokenValid] = useState(false);
|
||||
|
||||
const handleScan = async (rawValue: string) => {
|
||||
console.log('QR Escaneado:', rawValue);
|
||||
if (!enableScan) return;
|
||||
|
||||
try {
|
||||
if (rawValue) {
|
||||
try {
|
||||
await axiosInstance.post(
|
||||
`/participante-evento/decode-token`, {
|
||||
token: rawValue
|
||||
}
|
||||
);
|
||||
|
||||
setToken(rawValue);
|
||||
setTokenValid(true);
|
||||
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 (!tokenValid) return;
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.post(
|
||||
`/participante-evento/asistencia`, {
|
||||
token: token
|
||||
}
|
||||
);
|
||||
|
||||
setParticipante(response.data.data.participante);
|
||||
setMessage(response.data.message);
|
||||
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);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{participante && (
|
||||
<div className="mb-4">
|
||||
<div className="alert alert-success border-0 rounded-3 d-flex align-items-center">
|
||||
<i className="bi bi-person-check-fill me-2"></i>
|
||||
<span>Ultimo participante registrado: {participante}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<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>
|
||||
<span>{message}</span>
|
||||
</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);
|
||||
setTokenValid(false);
|
||||
}}
|
||||
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">
|
||||
{tokenValid ? (
|
||||
<>
|
||||
<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);
|
||||
setTokenValid(false);
|
||||
setToken('');
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
import Button from '@/components/button';
|
||||
import Table, { Header } from '@/components/table';
|
||||
import { useGetApi } from '@/hooks/use-get-api';
|
||||
import { Administrador } from '@/types/administradores';
|
||||
import React from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const { data } = useGetApi<Administrador[]>('/administrador');
|
||||
|
||||
const headers: Header<Administrador>[] = [
|
||||
{ key: 'id_administrador', label: 'ID' },
|
||||
{ key: 'nombre_usuario', label: 'Nombre de Usuario' },
|
||||
{ key: 'correo', label: 'Correo' },
|
||||
{
|
||||
key: 'tipoUser',
|
||||
render: (_, row) => row.tipoUser.tipo,
|
||||
label: 'Tipo de Usuario',
|
||||
},
|
||||
{
|
||||
key: 'tipoUser',
|
||||
render: () => (
|
||||
<div className="d-flex gap-2">
|
||||
<Button className="w-100" disabled>
|
||||
Editar
|
||||
</Button>
|
||||
<Button variant="danger" className="w-100" disabled>
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
label: 'Acciones',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="d-flex justify-content-between align-items-center my-3">
|
||||
<h1>Administradores</h1>
|
||||
<Button variant="primary">Agregar Administrador</Button>
|
||||
</div>
|
||||
{data && <Table headers={headers} data={data} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
import SimpleInput from '@/components/input';
|
||||
import PasswordInput from '@/components/password';
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import axiosInstance from '@/utils/api-config';
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter();
|
||||
|
||||
const [loginData, setLoginData] = React.useState({
|
||||
nombre_usuario: '',
|
||||
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 = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.post('/administrador/login', {
|
||||
nombre_usuario: loginData.nombre_usuario,
|
||||
password: loginData.password,
|
||||
});
|
||||
|
||||
console.log(response)
|
||||
|
||||
// Guardar el token en sessionStorage
|
||||
if (response.data.token) {
|
||||
sessionStorage.setItem('token', response.data.token);
|
||||
|
||||
// Opcional: guardar información adicional del usuario si viene en la respuesta
|
||||
if (response.data.tipo_usuario) {
|
||||
sessionStorage.setItem('axxxd', JSON.stringify(response.data.tipo_usuario));
|
||||
}
|
||||
|
||||
// Redireccionar al dashboard
|
||||
router.push('/user/eventos');
|
||||
} else {
|
||||
setError('No se recibió token de autenticación');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('Error en login:', error);
|
||||
|
||||
// Type guard para axios error
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as {
|
||||
response?: { status?: number; data?: { message?: string } };
|
||||
};
|
||||
|
||||
if (axiosError.response?.status === 401) {
|
||||
setError('Credenciales incorrectas');
|
||||
} else if (axiosError.response?.data?.message) {
|
||||
setError(axiosError.response.data.message);
|
||||
} else {
|
||||
setError('Error al iniciar sesión. Inténtalo de nuevo.');
|
||||
}
|
||||
} else {
|
||||
setError('Error al iniciar sesión. Inténtalo de nuevo.');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 correo electrónico 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="nombre_usuario"
|
||||
name="nombre_usuario"
|
||||
value={loginData.nombre_usuario}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// src/app/(public)/evento/[evento]/[cuestionario]/page.tsx
|
||||
import FormularioPage from '@/containers/pages/formulario-page';
|
||||
import { GetEventoWithCuestionario } from '@/types/evento';
|
||||
import { fromParam } from '@/utils/slugify';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
type PageParams = {
|
||||
evento: string; // "carrera-de-botargas-123"
|
||||
cuestionario: string; // "formulario-inscripcion-456"
|
||||
};
|
||||
|
||||
type Props = {
|
||||
params: Promise<PageParams>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { evento, cuestionario } = await params; // <- await
|
||||
const { id: id_evento } = fromParam(evento);
|
||||
const { id: id_cuestionario } = fromParam(cuestionario);
|
||||
|
||||
if (!id_evento || !id_cuestionario) {
|
||||
return { title: 'Evento', description: 'Descripción del evento' };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/evento/${id_evento}/cuestionario/${id_cuestionario}`,
|
||||
{ next: { revalidate: 60 } }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return { title: 'Evento', description: 'Descripción del evento' };
|
||||
}
|
||||
|
||||
const data: GetEventoWithCuestionario = await res.json();
|
||||
const title = data?.nombre_evento || 'Evento';
|
||||
const description = data?.descripcion_evento || 'Descripción del evento';
|
||||
const banner = data?.banner
|
||||
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images: banner
|
||||
? [{ url: banner, width: 800, height: 400, alt: title }]
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
const { evento, cuestionario } = await params; // <- await
|
||||
const { id: id_evento } = fromParam(evento);
|
||||
const { id: id_cuestionario } = fromParam(cuestionario);
|
||||
|
||||
if (!id_evento || !id_cuestionario) {
|
||||
// import { notFound } from 'next/navigation';
|
||||
// return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<FormularioPage
|
||||
id_evento={String(id_evento)}
|
||||
id_cuestionario={String(id_cuestionario)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import FormularioPage from '@/containers/pages/formulario-page';
|
||||
import { GetEventoWithCuestionario } from '@/types/evento';
|
||||
import { Metadata } from 'next';
|
||||
import React from 'react';
|
||||
|
||||
type Params = {
|
||||
nombre_evento: string;
|
||||
id_evento: string;
|
||||
id_cuestionario: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
params: Promise<Params>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { id_evento, id_cuestionario } = await params;
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/evento/${id_evento}/cuestionario/${id_cuestionario}`
|
||||
);
|
||||
|
||||
const data: GetEventoWithCuestionario = await response.json();
|
||||
|
||||
return {
|
||||
title: data?.nombre_evento || 'Evento',
|
||||
description: data?.descripcion_evento || 'Descripción del evento',
|
||||
openGraph: {
|
||||
title: data?.nombre_evento || 'Evento',
|
||||
description: data?.descripcion_evento || 'Descripción del evento',
|
||||
images: [
|
||||
{
|
||||
url: `${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`,
|
||||
width: 800,
|
||||
height: 400,
|
||||
alt: data?.nombre_evento || 'Evento',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
const { id_evento, id_cuestionario } = await params;
|
||||
return (
|
||||
<FormularioPage id_evento={id_evento} id_cuestionario={id_cuestionario} />
|
||||
);
|
||||
}
|
||||
+68
-21
@@ -1,37 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import ClientCarousel from '@/client-components/client-carousel';
|
||||
import EventoCard from '@/components/evento-card';
|
||||
import FormularioCardUser from '@/components/formulario/formulario-card-user';
|
||||
import EmptyEventsState from '@/components/empty-events-state';
|
||||
import { useGetApi } from '@/hooks/use-get-api';
|
||||
import { GetEventoWithCuestionarios } from '@/types/evento';
|
||||
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||
import React from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const { loading, data } = useGetApi<GetEventoWithCuestionarios[]>(
|
||||
const { loading, data } = useGetApi<GetEventoWithCuestionariosWithCupos[]>(
|
||||
'/evento/activos/cuestionarios'
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mx-auto mb-5 mt-3 fade-in-down-bounce">
|
||||
<ClientCarousel
|
||||
images={[{
|
||||
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.png',
|
||||
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.map((item, key) => {
|
||||
const fadeClass = `delay-${(key % 5) + 1}`;
|
||||
return (
|
||||
<div className={`col-md-6 col-lg-4 my-4 fade-in-up-bounce ${fadeClass}`} key={key}>
|
||||
<EventoCard evento={item} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.flatMap((evento) =>
|
||||
evento.cuestionarios.map((cuestionario, index) => {
|
||||
return (
|
||||
<div
|
||||
className={`col-md-6 col-lg-4 my-3 fade-in-up-bounce delay-${
|
||||
(index % 5) + 1
|
||||
}`}
|
||||
key={`${evento.id_evento}-${cuestionario.id_cuestionario}`}
|
||||
>
|
||||
<FormularioCardUser
|
||||
evento={evento}
|
||||
formulario={cuestionario}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,16 @@ interface BannerUploaderProps {
|
||||
label?: string;
|
||||
onChange?: (file: File | null) => void;
|
||||
defaultBanner?: string;
|
||||
className?: string;
|
||||
showPreview?: boolean;
|
||||
}
|
||||
|
||||
export default function ImageUploader({
|
||||
label = 'Subir imagen',
|
||||
onChange,
|
||||
defaultBanner,
|
||||
className = '',
|
||||
showPreview = true,
|
||||
}: BannerUploaderProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [preview, setPreview] = useState<string | null>(defaultBanner || null);
|
||||
@@ -41,17 +45,28 @@ export default function ImageUploader({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className={className}>
|
||||
{label && <label className="block mb-2 font-semibold">{label}</label>}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="form-control mb-2"
|
||||
/>
|
||||
{preview && (
|
||||
<div className="d-flex flex-column align-items-center">
|
||||
<div className="d-flex gap-2 align-items-start mb-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="form-control"
|
||||
/>
|
||||
{preview && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleRemoveImage}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<i className="bi bi-trash"></i>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{preview && showPreview && (
|
||||
<div className="d-flex justify-content-center">
|
||||
<Image
|
||||
src={preview}
|
||||
alt="Previsualización"
|
||||
@@ -59,13 +74,6 @@ export default function ImageUploader({
|
||||
width={600}
|
||||
height={300}
|
||||
/>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleRemoveImage}
|
||||
className="mt-3"
|
||||
>
|
||||
Eliminar imagen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbProps {
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export default function Breadcrumb({ items }: BreadcrumbProps) {
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="mb-3">
|
||||
<ol className="breadcrumb">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className={`breadcrumb-item ${isLast ? 'active' : ''}`}
|
||||
aria-current={isLast ? 'page' : undefined}
|
||||
>
|
||||
{isLast ? (
|
||||
item.label
|
||||
) : (
|
||||
<Link href={item.href || '#'} className="text-decoration-none">
|
||||
{item.label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,10 @@ export interface CarouselProps {
|
||||
}[];
|
||||
}
|
||||
|
||||
export default function Carousel({ id="carousel-banners-eventos", images }: CarouselProps) {
|
||||
export default function Carousel({
|
||||
id = 'carousel-banners-eventos',
|
||||
images,
|
||||
}: CarouselProps) {
|
||||
if (!images || images.length === 0) {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import MarkdownRenderer from './markdown-render';
|
||||
|
||||
export default function CuestionarioCard({
|
||||
evento,
|
||||
user = 'public',
|
||||
}: {
|
||||
evento: GetEventoWithCuestionariosWithCupos;
|
||||
user?: 'public' | 'administrador' | 'staff';
|
||||
}) {
|
||||
const [verMas, setVerMas] = useState(false);
|
||||
const descripcion = evento.descripcion_evento ?? '';
|
||||
const limite = 100;
|
||||
|
||||
const descripcionRecortada =
|
||||
descripcion.length > limite && !verMas
|
||||
? `${descripcion.slice(0, limite)}...`
|
||||
: descripcion;
|
||||
|
||||
return (
|
||||
<div className="card card-blog d-flex flex-column justify-content-between">
|
||||
<div>
|
||||
<div className="card-image scale-hover-1">
|
||||
<Link
|
||||
href={
|
||||
user === 'administrador' || user === 'staff'
|
||||
? `/${user}/evento/${evento.id_evento}`
|
||||
: `/evento/${evento.nombre_evento.split(' ').join('_')}/${
|
||||
evento.id_evento
|
||||
}/${evento.cuestionarios[0].id_cuestionario}`
|
||||
}
|
||||
>
|
||||
<Image
|
||||
width={400}
|
||||
height={300}
|
||||
className="img-fluid"
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`}
|
||||
alt="Banner formulario"
|
||||
/>
|
||||
<div className="card-caption">{evento.nombre_evento}</div>
|
||||
</Link>
|
||||
<div className="ripple-cont"></div>
|
||||
</div>
|
||||
|
||||
<div className="card-body">
|
||||
<div className="card-description mb-0">
|
||||
{evento.cuestionarios[0]?.cupo_maximo === null ? (
|
||||
<span className="badge bg-success mb-2">Sin límite</span>
|
||||
) : (
|
||||
<span className="badge bg-primary mb-2">
|
||||
{evento.cuestionarios[0]?.cupos_disponibles} cupos disponibles
|
||||
</span>
|
||||
)}
|
||||
<MarkdownRenderer markdown={descripcionRecortada} />
|
||||
{descripcion.length > limite && (
|
||||
<button
|
||||
onClick={() => setVerMas(!verMas)}
|
||||
className="btn btn-link btn-sm p-0 ms-1 align-baseline"
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
{verMas ? 'Ver menos' : 'Ver más'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{evento.cuestionarios.length > 0 &&
|
||||
evento.cuestionarios.map((cuestionario) => (
|
||||
<div key={cuestionario.id_cuestionario} className="">
|
||||
{user === 'administrador' || user === 'staff' ? (
|
||||
<Link
|
||||
href={`/${user}/evento/${evento.id_evento}`}
|
||||
className="btn mt-2 w-100 btn-outline-primary"
|
||||
>
|
||||
Ver evento
|
||||
</Link>
|
||||
) : cuestionario.cupos_disponibles === 0 &&
|
||||
cuestionario.cupo_maximo !== null ? (
|
||||
<button
|
||||
className="btn mt-2 w-100 btn-outline-secondary"
|
||||
disabled
|
||||
>
|
||||
Cupo lleno
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={`/evento/${evento.nombre_evento
|
||||
.split(' ')
|
||||
.join('_')}/${evento.id_evento}/${
|
||||
cuestionario.id_cuestionario
|
||||
}`}
|
||||
className="btn mt-2 w-100 btn-outline-primary"
|
||||
>
|
||||
Registro
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+104
-44
@@ -1,15 +1,15 @@
|
||||
'use client';
|
||||
import { GetEventoWithCuestionarios } from '@/types/evento';
|
||||
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import MarkdownRenderer from './markdown-render';
|
||||
import { formatearRangoFechas } from '@/utils/date-utils';
|
||||
|
||||
export default function EventoCard({
|
||||
evento,
|
||||
user = 'public',
|
||||
}: {
|
||||
evento: GetEventoWithCuestionarios;
|
||||
evento: GetEventoWithCuestionariosWithCupos;
|
||||
user?: 'public' | 'administrador' | 'staff';
|
||||
}) {
|
||||
const [verMas, setVerMas] = useState(false);
|
||||
@@ -43,18 +43,9 @@ export default function EventoCard({
|
||||
/>
|
||||
<div className="card-caption">{evento.nombre_evento}</div>
|
||||
</Link>
|
||||
<div className="ripple-cont"></div>
|
||||
</div>
|
||||
|
||||
<div className="card-body">
|
||||
<div className="card-description mb-0">
|
||||
{evento.cuestionarios[0]?.cupo_maximo === null ? (
|
||||
<span className="badge bg-success mb-2">Sin límite</span>
|
||||
) : (
|
||||
<span className="badge bg-primary mb-2">
|
||||
{evento.cuestionarios[0]?.cupos_disponibles} cupos disponibles
|
||||
</span>
|
||||
)}
|
||||
<MarkdownRenderer markdown={descripcionRecortada} />
|
||||
{descripcion.length > limite && (
|
||||
<button
|
||||
@@ -66,39 +57,108 @@ export default function EventoCard({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{evento.cuestionarios.length > 0 ? (
|
||||
<>
|
||||
<h2 className="my-2 fs-3">Formularios</h2>
|
||||
<table className="table table-white">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Nombre</th>
|
||||
<th scope="col">Inscritos</th>
|
||||
<th scope="col">Capacidad total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{evento.cuestionarios.map((cuestionario) => (
|
||||
<tr key={cuestionario.id_cuestionario}>
|
||||
<td>
|
||||
<Link
|
||||
href={
|
||||
user === 'administrador' || user === 'staff'
|
||||
? `/${user}/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`
|
||||
: `/evento/${evento.nombre_evento
|
||||
.split(' ')
|
||||
.join('_')}/${evento.id_evento}/${
|
||||
cuestionario.id_cuestionario
|
||||
}`
|
||||
}
|
||||
>
|
||||
{cuestionario.nombre_form}
|
||||
</Link>
|
||||
</td>
|
||||
{cuestionario.cupo_maximo ? (
|
||||
<>
|
||||
<td>{cuestionario.cupos_usados}</td>
|
||||
<td>{cuestionario.cupo_maximo}</td>
|
||||
</>
|
||||
) : (
|
||||
<td colSpan={2} className="text-center">
|
||||
Sin límite
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center px-4 py-3 border rounded bg-light my-3">
|
||||
<i className="bi bi-clipboard-x fs-1 text-muted mb-3"></i>
|
||||
<p className="text-muted mb-3">
|
||||
Este evento no tiene formularios disponibles.
|
||||
</p>
|
||||
{(user === 'administrador' || user === 'staff') && (
|
||||
<Link
|
||||
href={`/${user}/evento/${evento.id_evento}/formularios/crear`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<i className="bi bi-plus-circle me-2"></i>
|
||||
Crear formulario
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="badge fs-6 w-100 bg-primary">
|
||||
Evento {formatearRangoFechas(evento.fecha_inicio, evento.fecha_fin)}
|
||||
</span>
|
||||
|
||||
{evento.cuestionarios.length > 0 &&
|
||||
evento.cuestionarios.map((cuestionario) => (
|
||||
<div key={cuestionario.id_cuestionario} className="">
|
||||
{user === 'administrador' || user === 'staff' ? (
|
||||
<Link
|
||||
href={`/${user}/evento/${evento.id_evento}`}
|
||||
className="btn mt-2 w-100 btn-outline-primary"
|
||||
>
|
||||
Ver evento
|
||||
</Link>
|
||||
) : cuestionario.cupos_disponibles === 0 &&
|
||||
cuestionario.cupo_maximo !== null ? (
|
||||
<button
|
||||
className="btn mt-2 w-100 btn-outline-secondary"
|
||||
disabled
|
||||
>
|
||||
Cupo lleno
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={`/evento/${evento.nombre_evento
|
||||
.split(' ')
|
||||
.join('_')}/${evento.id_evento}/${
|
||||
cuestionario.id_cuestionario
|
||||
}`}
|
||||
className="btn mt-2 w-100 btn-outline-primary"
|
||||
>
|
||||
Registro
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Botón único del evento */}
|
||||
<div className="mt-2">
|
||||
{user === 'administrador' || user === 'staff' ? (
|
||||
<Link
|
||||
href={`/${user}/evento/${evento.id_evento}`}
|
||||
className="btn w-100 btn-outline-primary"
|
||||
>
|
||||
Ver/Editar evento
|
||||
</Link>
|
||||
) : evento.cuestionarios.length === 0 ? (
|
||||
<button className="btn w-100 btn-outline-secondary" disabled>
|
||||
Sin formularios disponibles
|
||||
</button>
|
||||
) : evento.cuestionarios.some(
|
||||
(cuestionario) =>
|
||||
cuestionario.cupos_disponibles === 0 &&
|
||||
cuestionario.cupo_maximo !== null
|
||||
) &&
|
||||
evento.cuestionarios.every(
|
||||
(cuestionario) =>
|
||||
cuestionario.cupos_disponibles === 0 &&
|
||||
cuestionario.cupo_maximo !== null
|
||||
) ? (
|
||||
<button className="btn w-100 btn-outline-secondary" disabled>
|
||||
Cupo lleno
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={`/evento/${evento.nombre_evento.split(' ').join('_')}/${
|
||||
evento.id_evento
|
||||
}/${evento.cuestionarios[0]?.id_cuestionario || ''}`}
|
||||
className="btn w-100 btn-outline-primary"
|
||||
>
|
||||
Registro
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { GetEventoWithCuestionariosWithCupos } 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';
|
||||
|
||||
export default function EventoCardAdmin({
|
||||
evento,
|
||||
}: {
|
||||
evento: GetEventoWithCuestionariosWithCupos;
|
||||
}) {
|
||||
const [verMas, setVerMas] = useState(false);
|
||||
const descripcion = evento.descripcion_evento ?? '';
|
||||
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(
|
||||
evento.fecha_inicio,
|
||||
evento.fecha_fin
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card shadow-sm border-0"
|
||||
style={{ borderRadius: '12px', overflow: 'hidden' }}
|
||||
>
|
||||
<div className="position-relative">
|
||||
<Link href={`/user/evento/${evento.id_evento}`}>
|
||||
<Image
|
||||
width={400}
|
||||
height={250}
|
||||
className="img-fluid w-100"
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`}
|
||||
alt="Banner formulario"
|
||||
style={{ objectFit: 'cover', height: '200px' }}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Badge de cantidad de formularios en la esquina superior derecha */}
|
||||
<div className="position-absolute top-0 end-0 m-2">
|
||||
{evento.cuestionarios.length > 0 ? (
|
||||
<span className="badge bg-primary">
|
||||
{evento.cuestionarios.length} formulario
|
||||
{evento.cuestionarios.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
) : (
|
||||
<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}</h5>
|
||||
</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>
|
||||
|
||||
{/* Formularios/sub eventos */}
|
||||
{evento.cuestionarios.length > 0 ? (
|
||||
<div className="mb-3">
|
||||
<h6 className="fw-bold mb-2 text-muted">
|
||||
Formularios disponibles:
|
||||
</h6>
|
||||
<div className="row row-cols-1 g-2">
|
||||
{evento.cuestionarios.slice(0, 3).map((cuestionario) => (
|
||||
<div key={cuestionario.id_cuestionario} className="col">
|
||||
<div className="py-2 px-3 bg-light border-0">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<Link
|
||||
href={`/user/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`}
|
||||
className="text-decoration-none fw-semibold"
|
||||
>
|
||||
{cuestionario.nombre_form}
|
||||
</Link>
|
||||
<small className="text-muted">
|
||||
{cuestionario.cupo_maximo
|
||||
? `${cuestionario.cupos_usados}/${cuestionario.cupo_maximo}`
|
||||
: 'Sin límite'}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center px-3 py-3 bg-light rounded mb-3">
|
||||
<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={`/user/evento/${evento.id_evento}/formularios/crear`}
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
>
|
||||
<i className="bi bi-plus-circle me-1"></i>
|
||||
Crear formulario
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botón principal */}
|
||||
<Link
|
||||
href={`/user/evento/${evento.id_evento}`}
|
||||
className="btn btn-primary w-100 rounded-pill"
|
||||
>
|
||||
Ver/Editar evento
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'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;
|
||||
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');
|
||||
|
||||
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 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, existingBanner]);
|
||||
|
||||
// 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 de cantidad de formularios en la esquina superior derecha */}
|
||||
<div className="position-absolute top-0 end-0 m-2">
|
||||
{cuestionariosCount > 0 ? (
|
||||
<span className="badge bg-primary">
|
||||
{cuestionariosCount} formulario
|
||||
{cuestionariosCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import MarkdownRenderer from '../markdown-render';
|
||||
import { formatearRangoFechas } from '@/utils/date-utils';
|
||||
|
||||
export default function EventoCard({
|
||||
evento,
|
||||
user = 'public',
|
||||
}: {
|
||||
evento: GetEventoWithCuestionariosWithCupos;
|
||||
user?: 'public' | 'administrador' | 'staff';
|
||||
}) {
|
||||
const [verMas, setVerMas] = useState(false);
|
||||
const descripcion = evento.descripcion_evento ?? '';
|
||||
const limite = 100;
|
||||
|
||||
const descripcionRecortada =
|
||||
descripcion.length > limite && !verMas
|
||||
? `${descripcion.slice(0, limite)}...`
|
||||
: descripcion;
|
||||
|
||||
return (
|
||||
<div className="card card-blog d-flex flex-column justify-content-between">
|
||||
<div>
|
||||
<div className="card-image scale-hover-1">
|
||||
<Link
|
||||
href={
|
||||
user === 'administrador' || user === 'staff'
|
||||
? `/${user}/evento/${evento.id_evento}`
|
||||
: `/evento/${evento.nombre_evento.split(' ').join('_')}/${
|
||||
evento.id_evento
|
||||
}/${evento.cuestionarios[0].id_cuestionario}`
|
||||
}
|
||||
>
|
||||
<Image
|
||||
width={400}
|
||||
height={300}
|
||||
className="img-fluid"
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`}
|
||||
alt="Banner formulario"
|
||||
/>
|
||||
<div className="card-caption">{evento.nombre_evento}</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="card-description mb-0">
|
||||
<MarkdownRenderer markdown={descripcionRecortada} />
|
||||
{descripcion.length > limite && (
|
||||
<button
|
||||
onClick={() => setVerMas(!verMas)}
|
||||
className="btn btn-link btn-sm p-0 ms-1 align-baseline"
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
{verMas ? 'Ver menos' : 'Ver más'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{evento.cuestionarios.length > 0 ? (
|
||||
<>
|
||||
<h2 className="my-2 fs-3">Formularios</h2>
|
||||
<table className="table table-white">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Nombre</th>
|
||||
<th scope="col">Inscritos</th>
|
||||
<th scope="col">Capacidad total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{evento.cuestionarios.map((cuestionario) => (
|
||||
<tr key={cuestionario.id_cuestionario}>
|
||||
<td>
|
||||
<Link
|
||||
href={
|
||||
user === 'administrador' || user === 'staff'
|
||||
? `/${user}/evento/${evento.id_evento}/formulario/${cuestionario.id_cuestionario}`
|
||||
: `/evento/${evento.nombre_evento
|
||||
.split(' ')
|
||||
.join('_')}/${evento.id_evento}/${
|
||||
cuestionario.id_cuestionario
|
||||
}`
|
||||
}
|
||||
>
|
||||
{cuestionario.nombre_form}
|
||||
</Link>
|
||||
</td>
|
||||
{cuestionario.cupo_maximo ? (
|
||||
<>
|
||||
<td>{cuestionario.cupos_usados}</td>
|
||||
<td>{cuestionario.cupo_maximo}</td>
|
||||
</>
|
||||
) : (
|
||||
<td colSpan={2} className="text-center">
|
||||
Sin límite
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center px-4 py-3 border rounded bg-light my-3">
|
||||
<i className="bi bi-clipboard-x fs-1 text-muted mb-3"></i>
|
||||
<p className="text-muted mb-3">
|
||||
Este evento no tiene formularios disponibles.
|
||||
</p>
|
||||
{(user === 'administrador' || user === 'staff') && (
|
||||
<Link
|
||||
href={`/${user}/evento/${evento.id_evento}/formularios/crear`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<i className="bi bi-plus-circle me-2"></i>
|
||||
Crear formulario
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="badge fs-6 w-100 bg-primary">
|
||||
Evento {formatearRangoFechas(evento.fecha_inicio, evento.fecha_fin)}
|
||||
</span>
|
||||
|
||||
{/* Botón único del evento */}
|
||||
<div className="mt-2">
|
||||
{user === 'administrador' || user === 'staff' ? (
|
||||
<Link
|
||||
href={`/${user}/evento/${evento.id_evento}`}
|
||||
className="btn w-100 btn-outline-primary"
|
||||
>
|
||||
Ver/Editar evento
|
||||
</Link>
|
||||
) : evento.cuestionarios.length === 0 ? (
|
||||
<button className="btn w-100 btn-outline-secondary" disabled>
|
||||
Sin formularios disponibles
|
||||
</button>
|
||||
) : evento.cuestionarios.some(
|
||||
(cuestionario) =>
|
||||
cuestionario.cupos_disponibles === 0 &&
|
||||
cuestionario.cupo_maximo !== null
|
||||
) &&
|
||||
evento.cuestionarios.every(
|
||||
(cuestionario) =>
|
||||
cuestionario.cupos_disponibles === 0 &&
|
||||
cuestionario.cupo_maximo !== null
|
||||
) ? (
|
||||
<button className="btn w-100 btn-outline-secondary" disabled>
|
||||
Cupo lleno
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={`/evento/${evento.nombre_evento.split(' ').join('_')}/${
|
||||
evento.id_evento
|
||||
}/${evento.cuestionarios[0]?.id_cuestionario || ''}`}
|
||||
className="btn w-100 btn-outline-primary"
|
||||
>
|
||||
Registro
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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({
|
||||
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 ?? '';
|
||||
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={`/user/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}`
|
||||
: 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">
|
||||
{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={`/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"
|
||||
onClick={() =>
|
||||
handleDownload(
|
||||
cuestionario.id_cuestionario,
|
||||
cuestionario.nombre_form,
|
||||
evento.nombre_evento
|
||||
)
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<i className="bi bi-download me-2"></i>
|
||||
Descargar respuestas
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
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 { toParam } from '@/utils/slugify';
|
||||
import { formatearFechaCard, formatearHorario } from '@/utils/date-utils';
|
||||
import Button from '../button';
|
||||
|
||||
export default function FormularioCardUser({
|
||||
evento,
|
||||
formulario,
|
||||
}: {
|
||||
evento: GetEvento;
|
||||
formulario: CuestionarioWithCupo;
|
||||
}) {
|
||||
const [verMas, setVerMas] = useState(false);
|
||||
const descripcion = formulario.descripcion ?? '';
|
||||
const limite = 100;
|
||||
|
||||
// Determinar si hay cupos disponibles
|
||||
const sinCupos =
|
||||
formulario.cupo_maximo !== null && formulario.cupos_disponibles <= 0;
|
||||
|
||||
// Verificar si el evento es el mismo día
|
||||
const fechaInicio = new Date(formulario.fecha_inicio);
|
||||
const fechaFin = new Date(formulario.fecha_fin);
|
||||
const esElMismoDia = fechaInicio.toDateString() === fechaFin.toDateString();
|
||||
|
||||
// Obtener información de fecha formateada usando las utilidades
|
||||
const { mesTexto, diaTexto } = formatearFechaCard(
|
||||
formulario.fecha_inicio,
|
||||
formulario.fecha_fin
|
||||
);
|
||||
|
||||
// Obtener horario formateado si es el mismo día
|
||||
const horarioFormateado = esElMismoDia
|
||||
? formatearHorario(formulario.fecha_inicio, formulario.fecha_fin)
|
||||
: null;
|
||||
|
||||
const descripcionRecortada =
|
||||
descripcion.length > limite && !verMas
|
||||
? `${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"
|
||||
style={{ borderRadius: '12px', overflow: 'hidden' }}
|
||||
>
|
||||
<div className="position-relative">
|
||||
{sinCupos ? (
|
||||
<div className="position-relative">
|
||||
<Image
|
||||
width={400}
|
||||
height={250}
|
||||
className="img-fluid w-100"
|
||||
src={imgSrc}
|
||||
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={imgSrc}
|
||||
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">
|
||||
<span className="badge bg-primary">
|
||||
{formulario.tipoCuestionario.tipo_cuestionario}{' '}
|
||||
</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">
|
||||
<div className="text-muted text-center small">{mesTexto}</div>
|
||||
<div
|
||||
className="fw-bold h4 mb-0"
|
||||
style={{ lineHeight: 1, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{diaTexto}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted fw-bold">{evento.nombre_evento}</span>
|
||||
<h5 className="card-title mb-1 fw-bold">
|
||||
{formulario.nombre_form}
|
||||
</h5>
|
||||
{formulario.tipoEvento && (
|
||||
<h6 className="small text-muted">
|
||||
{formulario.tipoEvento.tipo_evento}
|
||||
</h6>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descripción */}
|
||||
<div className="card-description flex-grow-1 mb-2">
|
||||
<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="row mb-3">
|
||||
{/* Columna de horario - solo visible si es el mismo día */}
|
||||
{esElMismoDia && (
|
||||
<div className="col-6">
|
||||
<div
|
||||
className={`py-2 px-3 rounded ${
|
||||
sinCupos ? 'bg-primary-subtle' : 'bg-light'
|
||||
}`}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<i className={`bi bi-clock me-2 text-azul`}></i>
|
||||
<div>
|
||||
<small className="text-muted d-block">Horario</small>
|
||||
<span className={`fw-semibold text-azul`}>
|
||||
{horarioFormateado}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Columna de cupos - siempre visible */}
|
||||
<div className={esElMismoDia ? 'col-6' : 'col-12'}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Botón de registro */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
+45
-34
@@ -1,9 +1,31 @@
|
||||
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 tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
|
||||
|
||||
const navItemsAdmin = [
|
||||
{ href: '/', label: 'Inicio' },
|
||||
{ href: '/user/eventos', label: 'Eventos' },
|
||||
{ href: '/user/qr', label: 'Lector de QRs' },
|
||||
{ href: '/user/eventos/crear', label: 'Crear Evento' },
|
||||
{ href: '/user/usuarios', label: 'Usuarios' },
|
||||
{ href: '/login', label: 'Cerrar sesión' },
|
||||
];
|
||||
|
||||
const navItemsUser = [
|
||||
{ href: '/user/eventos', label: 'Eventos' },
|
||||
{ href: '/user/qr', label: 'Lector de QRs' },
|
||||
{ href: '/login', label: 'Cerrar sesión' },
|
||||
];
|
||||
|
||||
const navItems = tipo_usuario === "administrador" ? navItemsAdmin : navItemsUser;
|
||||
|
||||
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 +53,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>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
import { CuestionarioWithCupo } from '@/types/evento';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import MarkdownRenderer from './markdown-render';
|
||||
import { formatearRangoFechas } from '@/utils/date-utils';
|
||||
|
||||
export default function NewCuestionarioCard({
|
||||
cuestionario,
|
||||
user = 'public',
|
||||
onDownload,
|
||||
loading = false,
|
||||
}: {
|
||||
cuestionario: CuestionarioWithCupo;
|
||||
user?: 'public' | 'administrador' | 'staff';
|
||||
onDownload?: (id_cuestionario: number, nombre: string) => void;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const [verMas, setVerMas] = useState(false);
|
||||
const descripcion = cuestionario.descripcion ?? '';
|
||||
const limite = 100;
|
||||
|
||||
const descripcionRecortada =
|
||||
descripcion.length > limite && !verMas
|
||||
? `${descripcion.slice(0, limite)}...`
|
||||
: descripcion;
|
||||
|
||||
return (
|
||||
<div className="card card-blog d-flex flex-column justify-content-between">
|
||||
<div>
|
||||
<div className="card-image scale-hover-1">
|
||||
<Link
|
||||
href={
|
||||
user === 'administrador' || user === 'staff'
|
||||
? `/${user}/evento/${cuestionario.id_evento}`
|
||||
: `/evento/${cuestionario.nombre_form.split(' ').join('_')}/${
|
||||
cuestionario.id_evento
|
||||
}/${cuestionario.id_cuestionario}`
|
||||
}
|
||||
>
|
||||
<Image
|
||||
width={400}
|
||||
height={300}
|
||||
className="img-fluid"
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`}
|
||||
alt="Banner formulario"
|
||||
/>
|
||||
<div className="card-caption">{cuestionario.nombre_form}</div>
|
||||
</Link>
|
||||
<div className="ripple-cont"></div>
|
||||
</div>
|
||||
|
||||
<div className="card-body">
|
||||
<div className="card-description mb-0">
|
||||
{cuestionario.cupo_maximo === null ? (
|
||||
<span className="badge bg-success mb-2">Sin límite</span>
|
||||
) : (
|
||||
<span className="badge bg-primary mb-2">
|
||||
{cuestionario.cupos_disponibles} cupos disponibles
|
||||
</span>
|
||||
)}
|
||||
<MarkdownRenderer markdown={descripcionRecortada} />
|
||||
{descripcion.length > limite && (
|
||||
<button
|
||||
onClick={() => setVerMas(!verMas)}
|
||||
className="btn btn-link btn-sm p-0 ms-1 align-baseline"
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
{verMas ? 'Ver menos' : 'Ver más'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="badge fs-6 w-100 bg-primary">
|
||||
Evento{' '}
|
||||
{formatearRangoFechas(
|
||||
cuestionario.fecha_inicio,
|
||||
cuestionario.fecha_fin
|
||||
)}
|
||||
</span>
|
||||
<div className="">
|
||||
{user === 'administrador' || user === 'staff' ? (
|
||||
<>
|
||||
<Link
|
||||
href={`/${user}/evento/${cuestionario.id_evento}/formulario/${cuestionario.id_cuestionario}`}
|
||||
className="btn mt-2 w-100 btn-outline-primary"
|
||||
>
|
||||
Ver formulario
|
||||
</Link>
|
||||
{user === 'administrador' && onDownload && (
|
||||
<button
|
||||
className="btn mt-2 w-100 btn-success"
|
||||
onClick={() =>
|
||||
onDownload(
|
||||
cuestionario.id_cuestionario,
|
||||
cuestionario.nombre_form
|
||||
)
|
||||
}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm me-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Descargando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-download me-2"></i>
|
||||
Descargar respuestas
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : cuestionario.cupos_disponibles === 0 &&
|
||||
cuestionario.cupo_maximo !== null ? (
|
||||
<button className="btn mt-2 w-100 btn-outline-secondary" disabled>
|
||||
Cupo lleno
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={`/evento/${cuestionario.nombre_form
|
||||
.split(' ')
|
||||
.join('_')}/${cuestionario.id_evento}/${
|
||||
cuestionario.id_cuestionario
|
||||
}`}
|
||||
className="btn mt-2 w-100 btn-outline-primary"
|
||||
>
|
||||
Registro
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useId } from 'react';
|
||||
|
||||
interface Option {
|
||||
export interface Option {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
@@ -74,7 +74,10 @@ const Select: React.FC<SelectProps> = ({
|
||||
|
||||
return (
|
||||
<div className={`mb-3 ${className?.container ?? ''}`}>
|
||||
<label htmlFor={selectId} className={`form-label ${className?.label ?? ''}`}>
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className={`form-label ${className?.label ?? ''}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import ImageUploader from '@/components/banner-uploader';
|
||||
import Input from '@/components/input';
|
||||
import Select from '@/components/select';
|
||||
import { useGetApi } from '@/hooks/use-get-api';
|
||||
import { CreateEventoType } from '@/types/create-evento';
|
||||
import { TipoEvento } from '@/types/evento';
|
||||
import { formatDateLocal } from '@/utils/date-utils';
|
||||
import { commands } from '@uiw/react-md-editor';
|
||||
import dynamic from 'next/dynamic';
|
||||
@@ -23,15 +25,33 @@ export default function CreateEvento({
|
||||
handleChange,
|
||||
handleBannerChange,
|
||||
}: CreateEventoProps) {
|
||||
const { data: tipo_eventos } = useGetApi<TipoEvento[]>('tipo-evento');
|
||||
return (
|
||||
<div className="row border p-3 rounded my-4">
|
||||
<h2>Información del Evento</h2>
|
||||
<p>
|
||||
Completa la siguiente información para describir los detalles generales
|
||||
del evento.
|
||||
</p>
|
||||
|
||||
<ImageUploader label="Banner del evento" onChange={handleBannerChange} />
|
||||
<div className="mt-2">
|
||||
<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>
|
||||
|
||||
<Input
|
||||
label="Nombre del evento"
|
||||
@@ -51,53 +71,49 @@ export default function CreateEvento({
|
||||
handleChange('descripcion_evento', texto);
|
||||
}
|
||||
}}
|
||||
height={300}
|
||||
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={[
|
||||
{ label: 'Conferencia', value: 'conferencia' },
|
||||
{ label: 'Taller', value: 'taller' },
|
||||
{ label: 'Seminario', value: 'seminario' },
|
||||
{ label: 'Curso', value: 'curso' },
|
||||
{ label: 'Webinar', value: 'webinar' },
|
||||
{ label: 'Reunión', value: 'reunion' },
|
||||
{ label: 'Feria', value: 'feria' },
|
||||
{ label: 'Concierto', value: 'concierto' },
|
||||
{ label: 'Exposición', value: 'exposicion' },
|
||||
{ label: 'Deportivo', value: 'deportivo' },
|
||||
{ label: 'Cultural', value: 'cultural' },
|
||||
{ label: 'Otro', value: 'otro' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="col-md-6">
|
||||
<Input
|
||||
label="Fecha de inicio"
|
||||
type="datetime-local"
|
||||
className={{ container: 'mb-3' }}
|
||||
value={formatDateLocal(evento.fecha_inicio)}
|
||||
onChange={(e) =>
|
||||
handleChange('fecha_inicio', new Date(e.target.value))
|
||||
}
|
||||
{tipo_eventos && (
|
||||
<Select
|
||||
label="Tipo de evento"
|
||||
value={evento.tipo_evento}
|
||||
placeholder="Selecciona un tipo de evento"
|
||||
onChange={(e) => handleChange('tipo_evento', e.target.value)}
|
||||
options={tipo_eventos.map((tipo) => ({
|
||||
value: tipo.id_tipo_evento,
|
||||
label: tipo.tipo_evento,
|
||||
}))}
|
||||
/>
|
||||
</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 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>
|
||||
);
|
||||
|
||||
@@ -5,12 +5,16 @@ import {
|
||||
TiposValidacion,
|
||||
} from '@/types/create-formulario';
|
||||
import SimpleInput from '@/components/input';
|
||||
import Select from '@/components/select';
|
||||
import Select, { Option } from '@/components/select';
|
||||
import Button from '@/components/button';
|
||||
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||
import { formatearRangoFechas } from '@/utils/date-utils';
|
||||
|
||||
interface Props {
|
||||
evento?: GetEventoWithCuestionariosWithCupos;
|
||||
formulario: FormularioCreacion;
|
||||
onChange: (formularioActualizado: FormularioCreacion) => void;
|
||||
tipo_eventos?: Option[];
|
||||
}
|
||||
|
||||
const tipoPreguntaOpciones: TipoPregunta[] = [
|
||||
@@ -49,7 +53,12 @@ const tiposCuestionario = [
|
||||
{ label: 'Evaluación', value: 2 },
|
||||
];
|
||||
|
||||
export default function FormularioEditor({ formulario, onChange }: Props) {
|
||||
export default function FormularioEditor({
|
||||
formulario,
|
||||
onChange,
|
||||
evento,
|
||||
tipo_eventos,
|
||||
}: Props) {
|
||||
function actualizarCampo<K extends keyof FormularioCreacion>(
|
||||
campo: K,
|
||||
valor: FormularioCreacion[K]
|
||||
@@ -139,28 +148,31 @@ export default function FormularioEditor({ formulario, onChange }: Props) {
|
||||
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
|
||||
@@ -173,7 +185,7 @@ export default function FormularioEditor({ formulario, onChange }: Props) {
|
||||
<div className="col-md-6">
|
||||
<SimpleInput
|
||||
label="Fecha de Inicio"
|
||||
type="date"
|
||||
type="datetime-local"
|
||||
value={formulario.fecha_inicio.slice(0, 16)}
|
||||
onChange={(e) => actualizarCampo('fecha_inicio', e.target.value)}
|
||||
/>
|
||||
@@ -181,30 +193,72 @@ export default function FormularioEditor({ formulario, onChange }: Props) {
|
||||
<div className="col-md-6">
|
||||
<SimpleInput
|
||||
label="Fecha de Fin"
|
||||
type="date"
|
||||
type="datetime-local"
|
||||
value={formulario.fecha_fin.slice(0, 16)}
|
||||
onChange={(e) => actualizarCampo('fecha_fin', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Tipo de Cuestionario"
|
||||
value={formulario.id_tipo_cuestionario}
|
||||
onChange={(e) =>
|
||||
actualizarCampo('id_tipo_cuestionario', Number(e.target.value))
|
||||
}
|
||||
options={tiposCuestionario}
|
||||
placeholder="Selecciona un tipo"
|
||||
/>
|
||||
{evento && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<hr className="my-4" />
|
||||
<div className="row">
|
||||
<div className="col-md-6">
|
||||
<Select
|
||||
label="Tipo de Cuestionario"
|
||||
value={formulario.id_tipo_cuestionario}
|
||||
onChange={(e) =>
|
||||
actualizarCampo('id_tipo_cuestionario', Number(e.target.value))
|
||||
}
|
||||
options={tiposCuestionario}
|
||||
placeholder="Selecciona un tipo"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<Select
|
||||
label="Tipo de evento"
|
||||
value={formulario.id_tipo_evento}
|
||||
onChange={(e) =>
|
||||
actualizarCampo('id_tipo_evento', Number(e.target.value))
|
||||
}
|
||||
options={tipo_eventos!}
|
||||
placeholder="Selecciona un tipo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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"
|
||||
@@ -266,14 +320,28 @@ export default function FormularioEditor({ formulario, onChange }: Props) {
|
||||
<Select
|
||||
label="Tipo de pregunta"
|
||||
value={pregunta.tipo}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const nuevoTipo = e.target.value as TipoPregunta;
|
||||
handleUpdatePregunta(
|
||||
seccionIdx,
|
||||
preguntaIdx,
|
||||
'tipo',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
nuevoTipo
|
||||
);
|
||||
|
||||
// Si cambia a un tipo cerrado y no tiene opciones, agregar opciones por defecto
|
||||
if (
|
||||
(nuevoTipo === 'Cerrada' || nuevoTipo === 'Multiple') &&
|
||||
(!pregunta.opciones || pregunta.opciones.length === 0)
|
||||
) {
|
||||
handleUpdatePregunta(
|
||||
seccionIdx,
|
||||
preguntaIdx,
|
||||
'opciones',
|
||||
[{ valor: '' }, { valor: '' }]
|
||||
);
|
||||
}
|
||||
}}
|
||||
options={tipoPreguntaOpciones.map((tipo) => ({
|
||||
label: tipo,
|
||||
value: tipo,
|
||||
@@ -303,6 +371,7 @@ export default function FormularioEditor({ formulario, onChange }: Props) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-check mb-2">
|
||||
<input
|
||||
className="form-check-input"
|
||||
@@ -329,14 +398,16 @@ export default function FormularioEditor({ formulario, onChange }: Props) {
|
||||
{(pregunta.tipo === 'Cerrada' ||
|
||||
pregunta.tipo === 'Multiple') && (
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Opciones</label>
|
||||
{(pregunta.opciones || []).map((op, opIdx) => (
|
||||
<SimpleInput
|
||||
key={opIdx}
|
||||
value={op.valor}
|
||||
onChange={(e) => {
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<label className="form-label mb-0">Opciones</label>
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
icon="plus"
|
||||
outline
|
||||
onClick={() => {
|
||||
const nuevasOpciones = [...(pregunta.opciones || [])];
|
||||
nuevasOpciones[opIdx].valor = e.target.value;
|
||||
nuevasOpciones.push({ valor: '' });
|
||||
handleUpdatePregunta(
|
||||
seccionIdx,
|
||||
preguntaIdx,
|
||||
@@ -344,9 +415,61 @@ export default function FormularioEditor({ formulario, onChange }: Props) {
|
||||
nuevasOpciones
|
||||
);
|
||||
}}
|
||||
placeholder={`Opción ${opIdx + 1}`}
|
||||
/>
|
||||
>
|
||||
Agregar opción
|
||||
</Button>
|
||||
</div>
|
||||
{(pregunta.opciones || []).map((op, opIdx) => (
|
||||
<div key={opIdx} className="d-flex align-items-center mb-2">
|
||||
<SimpleInput
|
||||
value={op.valor}
|
||||
onChange={(e) => {
|
||||
const nuevasOpciones = [...(pregunta.opciones || [])];
|
||||
nuevasOpciones[opIdx].valor = e.target.value;
|
||||
handleUpdatePregunta(
|
||||
seccionIdx,
|
||||
preguntaIdx,
|
||||
'opciones',
|
||||
nuevasOpciones
|
||||
);
|
||||
}}
|
||||
placeholder={`Opción ${opIdx + 1}`}
|
||||
className={{
|
||||
container: 'me-2 flex-grow-1 mb-0',
|
||||
input: 'form-control',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon="trash"
|
||||
outline
|
||||
onClick={() => {
|
||||
const nuevasOpciones = [...(pregunta.opciones || [])];
|
||||
nuevasOpciones.splice(opIdx, 1);
|
||||
handleUpdatePregunta(
|
||||
seccionIdx,
|
||||
preguntaIdx,
|
||||
'opciones',
|
||||
nuevasOpciones
|
||||
);
|
||||
}}
|
||||
disabled={pregunta.opciones?.length === 1}
|
||||
title={
|
||||
pregunta.opciones?.length === 1
|
||||
? 'Una pregunta cerrada debe tener al menos una opción'
|
||||
: 'Eliminar opción'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{(!pregunta.opciones || pregunta.opciones.length === 0) && (
|
||||
<div className="text-muted small">
|
||||
<i className="bi bi-info-circle me-1"></i>
|
||||
Esta pregunta cerrada no tiene opciones. Agrega al menos
|
||||
una opción.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -363,16 +486,11 @@ export default function FormularioEditor({ formulario, onChange }: Props) {
|
||||
</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,
|
||||
};
|
||||
}
|
||||
|
||||
+167
-85
@@ -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 { GetEventoWithCuestionarios } from '@/types/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,10 +16,14 @@ 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: GetEventoWithCuestionarios;
|
||||
evento: GetEventoWithCuestionariosWithCupos;
|
||||
handleChange: (field: keyof CreateEventoType, value: string | Date) => void;
|
||||
handleOnChange: (eventoActualizado: GetEventoWithCuestionarios) => void;
|
||||
handleOnChange: (
|
||||
eventoActualizado: GetEventoWithCuestionariosWithCupos
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function EditEvento({
|
||||
@@ -25,7 +31,8 @@ export default function EditEvento({
|
||||
handleChange,
|
||||
handleOnChange,
|
||||
}: EditEventoProps) {
|
||||
|
||||
const tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nuevoBanner, setNuevoBanner] = useState<File | null>(null);
|
||||
|
||||
@@ -67,94 +74,169 @@ 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="row border p-3 rounded my-4">
|
||||
<h2>Editar Información del Evento</h2>
|
||||
<p>Modifica los datos del evento que desees actualizar.</p>
|
||||
|
||||
<ImageUploader
|
||||
label="Banner del evento"
|
||||
defaultBanner={
|
||||
evento.banner
|
||||
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${evento.banner}`
|
||||
: undefined
|
||||
}
|
||||
onChange={(file) => {
|
||||
setNuevoBanner(file);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Nombre del evento"
|
||||
required
|
||||
value={evento.nombre_evento}
|
||||
onChange={(e) => handleChange('nombre_evento', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="form-label">Descripción del evento</label>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={evento.descripcion_evento ?? ''}
|
||||
onChange={(value) => {
|
||||
const texto = value || '';
|
||||
if (texto.length <= 500) {
|
||||
handleChange('descripcion_evento', texto);
|
||||
}
|
||||
}}
|
||||
height={250}
|
||||
commands={[commands.bold, commands.italic, commands.hr]}
|
||||
/>
|
||||
<div 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>
|
||||
|
||||
<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">
|
||||
{/* 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">
|
||||
{tipo_usuario === 'administrador' && (
|
||||
<>
|
||||
<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 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>
|
||||
{/* Detalles del evento */}
|
||||
<div className="col-12">
|
||||
<h4 className="mb-3">Detalles del Evento</h4>
|
||||
|
||||
<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>
|
||||
<Input
|
||||
label="Nombre del evento"
|
||||
required
|
||||
value={evento.nombre_evento}
|
||||
onChange={(e) =>
|
||||
handleChange('nombre_evento', e.target.value)
|
||||
}
|
||||
disabled={tipo_usuario !== 'administrador'}
|
||||
|
||||
<div>
|
||||
<Button icon="save" disabled={loading} onClick={handleOnSave}>
|
||||
{loading ? 'Guardando...' : 'Guardar Cambios'}
|
||||
</Button>
|
||||
/>
|
||||
|
||||
<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) => {
|
||||
if (tipo_usuario !== 'administrador') return;
|
||||
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}
|
||||
disabled={tipo_usuario !== 'administrador'}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botón de guardar */}
|
||||
{tipo_usuario === "administrador" && <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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,14 +13,17 @@ 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 tipo_usuario = sessionStorage.getItem('axxxd')?.replace(/"/g, '') || '';
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nuevoBanner, setNuevoBanner] = useState<File | null>(null);
|
||||
|
||||
@@ -65,87 +70,181 @@ export default function EditFormulario({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="row border p-3 rounded my-4">
|
||||
<h2>Editar Información del formulario</h2>
|
||||
<p>Modifica los datos del evento que desees actualizar.</p>
|
||||
|
||||
<ImageUploader
|
||||
label="Banner del formulario"
|
||||
defaultBanner={
|
||||
cuestionario.banner
|
||||
? `${process.env.NEXT_PUBLIC_API_URL}/banners/${cuestionario.banner}`
|
||||
: undefined
|
||||
}
|
||||
onChange={(file) => {
|
||||
setNuevoBanner(file);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="col-md-8">
|
||||
<SimpleInput
|
||||
label="Nombre del formulario"
|
||||
required
|
||||
value={cuestionario.nombre_form}
|
||||
onChange={(e) => handleChange('nombre_form', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<SimpleInput
|
||||
label="Cupo máximo (Si no se requiere, dejar en blanco)"
|
||||
type="number"
|
||||
value={cuestionario.cupo_maximo ?? ''}
|
||||
onChange={(e) =>
|
||||
handleChange('cupo_maximo', e.target.value ? e.target.value : '')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="form-label">Descripción del evento</label>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={cuestionario.descripcion ?? ''}
|
||||
onChange={(value) => {
|
||||
const texto = value || '';
|
||||
if (texto.length <= 500) {
|
||||
handleChange(
|
||||
'descripcion' as keyof GetCuestionario,
|
||||
texto
|
||||
);
|
||||
}
|
||||
}}
|
||||
height={250}
|
||||
commands={[commands.bold, commands.italic, commands.hr]}
|
||||
/>
|
||||
<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="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="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
|
||||
}
|
||||
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 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>
|
||||
{/* 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>
|
||||
|
||||
<div>
|
||||
<Button icon="save" disabled={loading} onClick={handleOnSave}>
|
||||
{loading ? 'Guardando...' : 'Guardar Cambios'}
|
||||
</Button>
|
||||
{/* 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>
|
||||
|
||||
{/* Botón de guardar */}
|
||||
{tipo_usuario === "administrador" && (
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useGetApi } from '@/hooks/use-get-api';
|
||||
import { GetCuestionario } from '@/types/evento';
|
||||
import SimpleInput from '@/components/input';
|
||||
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
|
||||
import axiosInstance from '@/utils/api-config';
|
||||
import PrefetchAbiertaCorta from './prefetch-abierta-corta';
|
||||
import Button from '@/components/button';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getAxiosError } from '@/utils/errors-utils';
|
||||
import { SubmitResponse } from '@/types/submit';
|
||||
|
||||
export type UsuarioDataResponse = {
|
||||
cuenta: string | null;
|
||||
nombre: string | null;
|
||||
apellidos: string | null;
|
||||
carrera: string | null;
|
||||
genero: string | null;
|
||||
rfc?: string | null; // Solo para trabajadores
|
||||
};
|
||||
|
||||
type UsuarioData = {
|
||||
nombre: string; // alumno o trabajador
|
||||
apellidos: string; // alumno o trabajador
|
||||
correo: string; // alumno o trabajador (en los alumnos se usa el id_ncuenta en trabajadores ellos deben escribirlo)
|
||||
genero: 'M' | 'F'; // alumno o trabajador
|
||||
carrera: string; // solo para alumnos
|
||||
rfc?: string; // solo para trabajadores se usa para buscar información de cuenta
|
||||
};
|
||||
|
||||
const confirmativeOption = ['Sí', 'Si', 'Si, claro', 'Claro que sí'];
|
||||
const negativeOption = ['No', 'No, gracias', 'No, de momento'];
|
||||
|
||||
type RespuestaFormulario = Record<string, string | number>; // id_pregunta: respuesta
|
||||
|
||||
export default function FormularioRegistro({
|
||||
id_cuestionario,
|
||||
handleSubmitFormulario,
|
||||
}: {
|
||||
evento: string;
|
||||
cuestionario: string;
|
||||
id_evento: number;
|
||||
id_cuestionario: number;
|
||||
handleSubmitFormulario: (data: SubmitResponse) => void;
|
||||
}) {
|
||||
// ------------------------
|
||||
// Estados
|
||||
// ------------------------
|
||||
const [isComunidad, setIsComunidad] = useState<RadioOption<number>>();
|
||||
const [respuestas, setRespuestas] = useState<RespuestaFormulario>({});
|
||||
const [cuentaInfo, setCuentaInfo] = useState<UsuarioData | null>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// ------------------------
|
||||
// Carga del cuestionario
|
||||
// ------------------------
|
||||
const { data, error } = useGetApi<GetCuestionario>(
|
||||
`/cuestionario/${id_cuestionario}/formulario`
|
||||
);
|
||||
// ------------------------
|
||||
// Extracción de preguntas clave
|
||||
// ------------------------
|
||||
const preguntas = data?.cuestionario.secciones.flatMap((seccion) =>
|
||||
seccion.preguntas.map((pregunta) => pregunta.pregunta)
|
||||
);
|
||||
|
||||
const preguntaComunidad = preguntas?.find(
|
||||
(pregunta) =>
|
||||
pregunta.validacion === 'comunidad_alumno' ||
|
||||
pregunta.validacion === 'comunidad_trabajador'
|
||||
);
|
||||
|
||||
const preguntaCuenta = preguntas?.find(
|
||||
(pregunta) =>
|
||||
pregunta.validacion === 'cuenta_alumno' ||
|
||||
pregunta.validacion === 'cuenta_trabajador' ||
|
||||
pregunta.validacion === 'rfc'
|
||||
);
|
||||
|
||||
// Solución: Agregar estado para controlar si ya se hizo la búsqueda
|
||||
const [cuentaBuscada, setCuentaBuscada] = useState<string>(''); // Nuevo estado
|
||||
|
||||
// ------------------------
|
||||
// Efecto: buscar información de cuenta
|
||||
// ------------------------
|
||||
useEffect(() => {
|
||||
const cuentaId = preguntaCuenta?.id_pregunta ?? '';
|
||||
const cuenta = respuestas[cuentaId];
|
||||
|
||||
const esCuentaAlumno = preguntaCuenta?.validacion === 'cuenta_alumno';
|
||||
const esCuentaTrabajador =
|
||||
preguntaCuenta?.validacion === 'cuenta_trabajador' ||
|
||||
preguntaCuenta?.validacion === 'rfc';
|
||||
|
||||
const puedeBuscar =
|
||||
cuenta &&
|
||||
typeof cuenta === 'string' &&
|
||||
((esCuentaAlumno && cuenta.length === 9) ||
|
||||
(esCuentaTrabajador && cuenta.length === 10));
|
||||
|
||||
const fetchCuentaInfo = async () => {
|
||||
try {
|
||||
const endpoint = esCuentaAlumno
|
||||
? `/alumnos/${cuenta}`
|
||||
: `/trabajadores/${cuenta}`;
|
||||
|
||||
const res = await axiosInstance.get<UsuarioDataResponse>(endpoint);
|
||||
|
||||
console.log('Datos de cuenta obtenidos:', res.data);
|
||||
|
||||
// Verificar si realmente hay datos válidos
|
||||
const hayDatosValidos =
|
||||
res.data.nombre ||
|
||||
res.data.apellidos ||
|
||||
res.data.carrera ||
|
||||
res.data.genero;
|
||||
|
||||
if (hayDatosValidos) {
|
||||
setCuentaInfo({
|
||||
nombre: res.data.nombre ? res.data.nombre : '',
|
||||
apellidos: res.data.apellidos ? res.data.apellidos : '',
|
||||
correo: res.data.cuenta
|
||||
? `${res.data.cuenta}@pcpuma.acatlan.unam.mx`
|
||||
: '',
|
||||
genero:
|
||||
res.data.genero === 'M' || res.data.genero === 'F'
|
||||
? res.data.genero
|
||||
: 'M',
|
||||
carrera: res.data.carrera ? res.data.carrera : '',
|
||||
});
|
||||
} else {
|
||||
// Si no hay datos válidos, establecer como null
|
||||
setCuentaInfo(null);
|
||||
}
|
||||
|
||||
// Marcar que ya se buscó esta cuenta
|
||||
setCuentaBuscada(cuenta as string);
|
||||
} catch (error) {
|
||||
console.error('Error al obtener datos de cuenta:', error);
|
||||
setCuentaInfo(null);
|
||||
setCuentaBuscada(cuenta as string); // También marcar en caso de error
|
||||
}
|
||||
};
|
||||
|
||||
// Solo buscar si puede buscar Y no se ha buscado ya esta cuenta
|
||||
if (puedeBuscar && cuentaBuscada !== cuenta) {
|
||||
fetchCuentaInfo();
|
||||
} else if (!puedeBuscar) {
|
||||
setCuentaInfo(null);
|
||||
setCuentaBuscada(''); // Reset cuando no se puede buscar
|
||||
}
|
||||
}, [
|
||||
respuestas,
|
||||
preguntaCuenta?.id_pregunta,
|
||||
preguntaCuenta?.validacion,
|
||||
cuentaBuscada,
|
||||
]);
|
||||
|
||||
// También resetear cuentaBuscada cuando cambie el número de cuenta
|
||||
useEffect(() => {
|
||||
const cuentaId = preguntaCuenta?.id_pregunta ?? '';
|
||||
const cuenta = respuestas[cuentaId];
|
||||
|
||||
// Si la cuenta cambió, resetear el estado de búsqueda
|
||||
if (cuentaBuscada && cuenta !== cuentaBuscada) {
|
||||
setCuentaBuscada('');
|
||||
}
|
||||
}, [respuestas, preguntaCuenta?.id_pregunta, cuentaBuscada]);
|
||||
|
||||
// ------------------------
|
||||
// Efecto: precargar respuestas si hay cuentaInfo
|
||||
// ------------------------
|
||||
useEffect(() => {
|
||||
if (!cuentaInfo) {
|
||||
if (data?.cuestionario.secciones) {
|
||||
const preguntas = data.cuestionario.secciones.flatMap((seccion) =>
|
||||
seccion.preguntas.map((pregunta) => pregunta.pregunta)
|
||||
);
|
||||
|
||||
const idsPrefetch = preguntas
|
||||
.filter((pregunta) =>
|
||||
[
|
||||
'nombre',
|
||||
'apellidos',
|
||||
'correo',
|
||||
'institucion',
|
||||
'carrera',
|
||||
'genero',
|
||||
].includes(pregunta.validacion)
|
||||
)
|
||||
.map((pregunta) => pregunta.id_pregunta);
|
||||
|
||||
setRespuestas((prev) => {
|
||||
const nuevas = { ...prev };
|
||||
idsPrefetch.forEach((id) => {
|
||||
delete nuevas[id];
|
||||
});
|
||||
return nuevas;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nuevasRespuestas: RespuestaFormulario = {};
|
||||
|
||||
if (data?.cuestionario.secciones) {
|
||||
const preguntas = data.cuestionario.secciones.flatMap((seccion) =>
|
||||
seccion.preguntas.map((pregunta) => pregunta.pregunta)
|
||||
);
|
||||
|
||||
for (const pregunta of preguntas) {
|
||||
const id = pregunta.id_pregunta;
|
||||
switch (pregunta.validacion) {
|
||||
case 'nombre':
|
||||
nuevasRespuestas[id] = cuentaInfo.nombre;
|
||||
break;
|
||||
case 'apellidos':
|
||||
nuevasRespuestas[id] = cuentaInfo.apellidos;
|
||||
break;
|
||||
case 'correo':
|
||||
nuevasRespuestas[id] = cuentaInfo.correo;
|
||||
break;
|
||||
case 'institucion':
|
||||
nuevasRespuestas[id] = 'FES Acatlán';
|
||||
break;
|
||||
case 'carrera':
|
||||
nuevasRespuestas[id] = cuentaInfo.carrera;
|
||||
break;
|
||||
case 'genero':
|
||||
const generoTexto =
|
||||
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
|
||||
const opcion = pregunta.opciones?.find(
|
||||
(op) =>
|
||||
op.opcion.opcion.toLowerCase() === generoTexto.toLowerCase()
|
||||
);
|
||||
if (opcion) {
|
||||
nuevasRespuestas[id] = String(opcion.id_opcion);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setRespuestas((prev) => ({
|
||||
...prev,
|
||||
...nuevasRespuestas,
|
||||
}));
|
||||
}
|
||||
}, [cuentaInfo, data?.cuestionario.secciones]);
|
||||
|
||||
// ------------------------
|
||||
// Funciones auxiliares
|
||||
// ------------------------
|
||||
const actualizarRespuesta = (idPregunta: string, valor: string) => {
|
||||
setRespuestas((prev) => ({ ...prev, [idPregunta]: valor }));
|
||||
};
|
||||
|
||||
const handleOnSubmit = async () => {
|
||||
setIsSending(true);
|
||||
if (!data?.cuestionario.id_cuestionario) {
|
||||
alert('No se ha cargado correctamente el formulario');
|
||||
return;
|
||||
}
|
||||
|
||||
const preguntasObligatorias =
|
||||
data?.cuestionario.secciones.flatMap((seccion) =>
|
||||
seccion.preguntas
|
||||
.filter((pregunta) => pregunta.pregunta.obligatoria)
|
||||
.map((pregunta) => pregunta.pregunta)
|
||||
) || [];
|
||||
|
||||
const faltantes = preguntasObligatorias.filter(
|
||||
(pregunta) =>
|
||||
respuestas[pregunta.id_pregunta] === undefined ||
|
||||
respuestas[pregunta.id_pregunta] === '' ||
|
||||
respuestas[pregunta.id_pregunta] === null
|
||||
);
|
||||
|
||||
const preguntaCorreo = preguntasObligatorias.find(
|
||||
(pregunta) =>
|
||||
pregunta.validacion === 'correo' ||
|
||||
pregunta.validacion === 'correo_institucional'
|
||||
);
|
||||
|
||||
if (faltantes.length > 0) {
|
||||
toast.error('Por favor responde todas las preguntas obligatorias.');
|
||||
setIsSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Buscar un posible correo en las respuestas si no hay cuentaInfo
|
||||
let correo =
|
||||
cuentaInfo?.correo === respuestas[preguntaCorreo?.id_pregunta ?? '']
|
||||
? cuentaInfo?.correo
|
||||
: respuestas[preguntaCorreo?.id_pregunta ?? ''];
|
||||
|
||||
if (!correo) {
|
||||
// Buscar en las respuestas una que parezca correo
|
||||
const correoRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
for (const valor of Object.values(respuestas)) {
|
||||
if (typeof valor === 'string' && correoRegex.test(valor)) {
|
||||
correo = valor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
id_cuestionario: id_cuestionario,
|
||||
correo: correo || '',
|
||||
fecha_envio: new Date().toISOString(),
|
||||
respuestas: Object.entries(respuestas).map(([id_pregunta, valor]) => ({
|
||||
id_pregunta: Number(id_pregunta),
|
||||
valor: isNaN(Number(valor)) ? valor : Number(valor),
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await axiosInstance.post<SubmitResponse>(
|
||||
'/cuestionario-respondido/submit',
|
||||
payload
|
||||
);
|
||||
toast(
|
||||
() => (
|
||||
<span
|
||||
className="fs-4 pc-3 py-2"
|
||||
style={{
|
||||
maxWidth: '300px',
|
||||
wordWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
}}
|
||||
>
|
||||
{res.data.message.includes('ya ha respondido') ? (
|
||||
<>
|
||||
<i className="bi bi-exclamation-circle-fill text-warning me-2"></i>
|
||||
Respuestas Enviadas Con Éxito
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-check-circle-fill text-success me-2"></i>
|
||||
{res.data.message || '¡Formulario enviado!'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
{
|
||||
duration: 6000,
|
||||
position: 'bottom-center',
|
||||
style: {
|
||||
maxWidth: '425px',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
handleSubmitFormulario(res.data);
|
||||
} catch (error) {
|
||||
console.log(getAxiosError(error));
|
||||
toast.error('Hubo un error al enviar el formulario', {
|
||||
duration: 5000,
|
||||
});
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------------
|
||||
// Condición para mostrar el formulario
|
||||
// ------------------------
|
||||
const mostrarFormularioRestante =
|
||||
!preguntaComunidad || // Si no hay pregunta de comunidad, mostrar el formulario
|
||||
(isComunidad &&
|
||||
confirmativeOption.some((opt) =>
|
||||
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
|
||||
) &&
|
||||
!!cuentaInfo) ||
|
||||
(isComunidad &&
|
||||
negativeOption.some((opt) =>
|
||||
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
|
||||
));
|
||||
|
||||
if (error) {
|
||||
console.error('Error al cargar el cuestionario:', error);
|
||||
return <div>Error al cargar el cuestionario</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{preguntaComunidad && (
|
||||
<div className="my-4">
|
||||
<label
|
||||
className={`form-label ${
|
||||
preguntaComunidad.obligatoria ? 'required' : ''
|
||||
}`}
|
||||
>
|
||||
{preguntaComunidad.pregunta}
|
||||
</label>
|
||||
<RadioOptionGroup
|
||||
name="comunidad"
|
||||
options={preguntaComunidad.opciones.map((op) => ({
|
||||
label: op.opcion.opcion,
|
||||
value: op.id_opcion,
|
||||
}))}
|
||||
selectedValue={isComunidad?.value}
|
||||
onChange={(opt) => {
|
||||
console.log('Opción comunidad seleccionada:', opt);
|
||||
setIsComunidad(opt);
|
||||
actualizarRespuesta(
|
||||
preguntaComunidad.id_pregunta.toString(),
|
||||
String(opt.value)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isComunidad &&
|
||||
confirmativeOption.some((opt) =>
|
||||
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
|
||||
) &&
|
||||
preguntaCuenta && (
|
||||
<div className="my-4">
|
||||
<label
|
||||
className={`form-label ${
|
||||
preguntaCuenta.obligatoria ? 'required' : ''
|
||||
}`}
|
||||
>
|
||||
{preguntaCuenta.pregunta}
|
||||
</label>
|
||||
<SimpleInput
|
||||
type="text"
|
||||
name="cuenta"
|
||||
placeholder={
|
||||
preguntaCuenta.validacion === 'cuenta_alumno'
|
||||
? 'Ingrese su cuenta'
|
||||
: preguntaCuenta?.validacion === 'rfc'
|
||||
? 'Ingrese su RFC sin homoclave'
|
||||
: preguntaCuenta?.validacion === 'cuenta_trabajador'
|
||||
? 'Ingrese su numero de trabajador'
|
||||
: 'Ingrese su ' + preguntaCuenta.pregunta
|
||||
}
|
||||
maxLength={10}
|
||||
value={respuestas[preguntaCuenta.id_pregunta] || ''}
|
||||
onChange={(e) =>
|
||||
setRespuestas((prev) => ({
|
||||
...prev,
|
||||
[preguntaCuenta.id_pregunta]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mostrarFormularioRestante &&
|
||||
data?.cuestionario.secciones.map((seccion, i) => (
|
||||
<div key={i}>
|
||||
{seccion.preguntas.map((pregunta) => {
|
||||
// Evitar renderizar las preguntas ya manejadas
|
||||
if (
|
||||
pregunta.pregunta.id_pregunta ===
|
||||
preguntaComunidad?.id_pregunta ||
|
||||
pregunta.pregunta.id_pregunta === preguntaCuenta?.id_pregunta
|
||||
)
|
||||
return null;
|
||||
|
||||
if (
|
||||
pregunta.pregunta.tipo_pregunta.tipo_pregunta ===
|
||||
'Abierta (Respuesta corta)'
|
||||
) {
|
||||
return (
|
||||
<PrefetchAbiertaCorta
|
||||
key={pregunta.pregunta.id_pregunta}
|
||||
obligatorio={pregunta.pregunta.obligatoria}
|
||||
idPregunta={pregunta.pregunta.id_pregunta}
|
||||
enunciado={pregunta.pregunta.pregunta}
|
||||
validacion={pregunta.pregunta.validacion}
|
||||
respuestas={respuestas}
|
||||
cuentaInfo={cuentaInfo}
|
||||
isComunidad={isComunidad?.label}
|
||||
onChange={actualizarRespuesta}
|
||||
preguntaComunidad={preguntaComunidad?.validacion ?? ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
pregunta.pregunta.tipo_pregunta.tipo_pregunta ===
|
||||
'Abierta (Parrafo)'
|
||||
) {
|
||||
return (
|
||||
<div key={pregunta.pregunta.id_pregunta} className="my-4">
|
||||
<label
|
||||
className={`form-label ${
|
||||
pregunta.pregunta.obligatoria ? 'required' : ''
|
||||
}`}
|
||||
>
|
||||
{pregunta.pregunta.pregunta}
|
||||
</label>
|
||||
<SimpleInput
|
||||
type="textarea"
|
||||
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
|
||||
placeholder="Ingrese una respuesta"
|
||||
value={respuestas[pregunta.pregunta.id_pregunta] || ''}
|
||||
onChange={(e) =>
|
||||
setRespuestas((prev) => ({
|
||||
...prev,
|
||||
[pregunta.pregunta.id_pregunta]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Cerrada') {
|
||||
const opciones: RadioOption<number>[] =
|
||||
pregunta.pregunta.opciones.map((op) => ({
|
||||
label: op.opcion.opcion,
|
||||
value: op.id_opcion,
|
||||
}));
|
||||
let respuestaActual =
|
||||
respuestas[`${pregunta.pregunta.id_pregunta}`];
|
||||
|
||||
if (
|
||||
(!preguntaComunidad ||
|
||||
(confirmativeOption.includes(isComunidad?.label || '') &&
|
||||
cuentaInfo)) &&
|
||||
respuestaActual === undefined // solo si no ha respondido
|
||||
) {
|
||||
if (pregunta.pregunta.validacion === 'genero' && cuentaInfo) {
|
||||
const generoTexto =
|
||||
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
|
||||
|
||||
const opcionGenero = pregunta.pregunta.opciones.find(
|
||||
(op) =>
|
||||
op.opcion.opcion.toLowerCase() ===
|
||||
generoTexto.toLowerCase()
|
||||
);
|
||||
if (opcionGenero) {
|
||||
respuestaActual = String(opcionGenero.id_opcion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={pregunta.pregunta.id_pregunta}>
|
||||
<label
|
||||
className={`form-label ${
|
||||
pregunta.pregunta.obligatoria ? 'required' : ''
|
||||
}`}
|
||||
>
|
||||
{pregunta.pregunta.pregunta}
|
||||
</label>
|
||||
<RadioOptionGroup
|
||||
name={`pregunta_${pregunta.pregunta.id_pregunta}`}
|
||||
options={opciones}
|
||||
selectedValue={
|
||||
respuestaActual ? Number(respuestaActual) : undefined
|
||||
}
|
||||
onChange={(opt) => {
|
||||
actualizarRespuesta(
|
||||
`${pregunta.pregunta.id_pregunta}`,
|
||||
String(opt.value)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={pregunta.pregunta.id_pregunta} className="my-4">
|
||||
<label
|
||||
className={`form-label ${
|
||||
pregunta.pregunta.obligatoria ? 'required' : ''
|
||||
}`}
|
||||
>
|
||||
{pregunta.pregunta.pregunta}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
className="btn btn-primary"
|
||||
onClick={handleOnSubmit}
|
||||
disabled={!mostrarFormularioRestante || isSending}
|
||||
>
|
||||
{isSending ? 'Enviando...' : 'Enviar Formulario'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import MarkdownRenderer from '@/components/markdown-render';
|
||||
import { useGetApi } from '@/hooks/use-get-api';
|
||||
import { GetCuestionario } from '@/types/evento';
|
||||
import { SubmitResponse } from '@/types/submit';
|
||||
import SimpleInput from '@/components/input';
|
||||
import RadioOptionGroup, { RadioOption } from '@/components/radio-option-group';
|
||||
import axiosInstance from '@/utils/api-config';
|
||||
import PrefetchAbiertaCorta from './prefetch-abierta-corta';
|
||||
import Button from '@/components/button';
|
||||
import axiosInstance from '@/utils/api-config';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getAxiosError } from '@/utils/errors-utils';
|
||||
|
||||
export type UsuarioDataResponse = {
|
||||
@@ -17,50 +15,48 @@ export type UsuarioDataResponse = {
|
||||
apellidos: string | null;
|
||||
carrera: string | null;
|
||||
genero: string | null;
|
||||
rfc?: string | null; // Solo para trabajadores
|
||||
rfc?: string | null;
|
||||
};
|
||||
|
||||
type UsuarioData = {
|
||||
nombre: string; // alumno o trabajador
|
||||
apellidos: string; // alumno o trabajador
|
||||
correo: string; // alumno o trabajador (en los alumnos se usa el id_ncuenta en trabajadores ellos deben escribirlo)
|
||||
genero: 'M' | 'F'; // alumno o trabajador
|
||||
carrera: string; // solo para alumnos
|
||||
rfc?: string; // solo para trabajadores se usa para buscar información de cuenta
|
||||
nombre: string;
|
||||
apellidos: string;
|
||||
correo: string;
|
||||
genero: 'M' | 'F';
|
||||
carrera: string;
|
||||
rfc?: string;
|
||||
};
|
||||
|
||||
const confirmativeOption = ['Sí', 'Si', 'Si, claro', 'Claro que sí'];
|
||||
const negativeOption = ['No', 'No, gracias', 'No, de momento'];
|
||||
|
||||
type RespuestaFormulario = Record<string, string | number>; // id_pregunta: respuesta
|
||||
type RespuestaFormulario = Record<string, string | number>;
|
||||
|
||||
export default function FormularioRegistro({
|
||||
id_cuestionario,
|
||||
handleSubmitFormulario,
|
||||
}: {
|
||||
evento: string;
|
||||
cuestionario: string;
|
||||
id_evento: number;
|
||||
id_cuestionario: number;
|
||||
handleSubmitFormulario: (data: SubmitResponse) => void;
|
||||
}) {
|
||||
// ------------------------
|
||||
// Estados
|
||||
// ------------------------
|
||||
const [isComunidad, setIsComunidad] = useState<RadioOption<number>>();
|
||||
// Estados principales
|
||||
const [respuestas, setRespuestas] = useState<RespuestaFormulario>({});
|
||||
const [cuentaInfo, setCuentaInfo] = useState<UsuarioData | null>(null);
|
||||
const [busquedaCompletada, setBusquedaCompletada] = useState<boolean>(false);
|
||||
const [cuentaBuscada, setCuentaBuscada] = useState<string>('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// ------------------------
|
||||
|
||||
// Carga del cuestionario
|
||||
// ------------------------
|
||||
const { data, error } = useGetApi<GetCuestionario>(
|
||||
`/cuestionario/${id_cuestionario}/formulario`
|
||||
);
|
||||
// ------------------------
|
||||
// Extracción de preguntas clave
|
||||
// ------------------------
|
||||
|
||||
// Extraer todas las preguntas del cuestionario
|
||||
const preguntas = data?.cuestionario.secciones.flatMap((seccion) =>
|
||||
seccion.preguntas.map((pregunta) => pregunta.pregunta)
|
||||
);
|
||||
|
||||
// Identificar preguntas especiales
|
||||
const preguntaComunidad = preguntas?.find(
|
||||
(pregunta) =>
|
||||
pregunta.validacion === 'comunidad_alumno' ||
|
||||
@@ -74,158 +70,168 @@ export default function FormularioRegistro({
|
||||
pregunta.validacion === 'rfc'
|
||||
);
|
||||
|
||||
// ------------------------
|
||||
// Efecto: buscar información de cuenta
|
||||
// ------------------------
|
||||
// Determinar tipo de comunidad seleccionada
|
||||
const comunidadSeleccionada = preguntaComunidad
|
||||
? respuestas[preguntaComunidad.id_pregunta]
|
||||
: null;
|
||||
|
||||
const esComunidadSi = comunidadSeleccionada &&
|
||||
preguntaComunidad?.opciones.find(op =>
|
||||
op.id_opcion === Number(comunidadSeleccionada) &&
|
||||
op.opcion.opcion.toLowerCase().includes('si')
|
||||
);
|
||||
|
||||
// Efecto para buscar información de cuenta
|
||||
useEffect(() => {
|
||||
const cuenta = respuestas[preguntaCuenta?.id_pregunta ?? ''];
|
||||
|
||||
const esCuentaAlumno = preguntaCuenta?.validacion === 'cuenta_alumno';
|
||||
const esCuentaTrabajador =
|
||||
preguntaCuenta?.validacion === 'cuenta_trabajador' ||
|
||||
preguntaCuenta?.validacion === 'rfc';
|
||||
|
||||
const puedeBuscar =
|
||||
cuenta &&
|
||||
typeof cuenta === 'string' &&
|
||||
((esCuentaAlumno && cuenta.length === 9) ||
|
||||
(esCuentaTrabajador && cuenta.length === 10));
|
||||
|
||||
const fetchCuentaInfo = async () => {
|
||||
try {
|
||||
const endpoint = esCuentaAlumno
|
||||
? `/alumnos/${cuenta}`
|
||||
: `/trabajadores/${cuenta}`;
|
||||
|
||||
const res = await axiosInstance.get<UsuarioDataResponse>(endpoint);
|
||||
|
||||
console.log('Datos de cuenta obtenidos:', res.data);
|
||||
setCuentaInfo({
|
||||
nombre: res.data.nombre ? res.data.nombre : '',
|
||||
apellidos: res.data.apellidos ? res.data.apellidos : '',
|
||||
correo: res.data.cuenta
|
||||
? `${res.data.cuenta}@pcpuma.acatlan.unam.mx`
|
||||
: '',
|
||||
genero:
|
||||
res.data.genero === 'M' || res.data.genero === 'F'
|
||||
? res.data.genero
|
||||
: 'M',
|
||||
carrera: res.data.carrera ? res.data.carrera : '',
|
||||
});
|
||||
// Usar datos fake para pruebas
|
||||
/* setCuentaInfo({
|
||||
nombre: 'Juan',
|
||||
apellidos: 'Pérez',
|
||||
correo: 'juan.perez@pcpuma.acatlan.unam.mx',
|
||||
genero: 'M',
|
||||
carrera: 'Ingeniería en Computación',
|
||||
}); */
|
||||
} catch (error) {
|
||||
console.error('Error al obtener datos de cuenta:', error);
|
||||
setCuentaInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (puedeBuscar) {
|
||||
fetchCuentaInfo();
|
||||
} else {
|
||||
if (!preguntaCuenta || !esComunidadSi) {
|
||||
setCuentaInfo(null);
|
||||
}
|
||||
}, [respuestas[preguntaCuenta?.id_pregunta ?? '']]);
|
||||
|
||||
// ------------------------
|
||||
// Efecto: precargar respuestas si hay cuentaInfo
|
||||
// ------------------------
|
||||
useEffect(() => {
|
||||
if (!cuentaInfo) {
|
||||
if (preguntas) {
|
||||
const idsPrefetch = preguntas
|
||||
.filter((pregunta) =>
|
||||
[
|
||||
'nombre',
|
||||
'apellidos',
|
||||
'correo',
|
||||
'institucion',
|
||||
'carrera',
|
||||
'genero',
|
||||
].includes(pregunta.validacion)
|
||||
)
|
||||
.map((pregunta) => pregunta.id_pregunta);
|
||||
|
||||
setRespuestas((prev) => {
|
||||
const nuevas = { ...prev };
|
||||
idsPrefetch.forEach((id) => {
|
||||
delete nuevas[id];
|
||||
});
|
||||
return nuevas;
|
||||
});
|
||||
}
|
||||
setBusquedaCompletada(false);
|
||||
setCuentaBuscada('');
|
||||
return;
|
||||
}
|
||||
|
||||
const cuenta = respuestas[preguntaCuenta.id_pregunta];
|
||||
|
||||
if (!cuenta || typeof cuenta !== 'string') {
|
||||
setCuentaInfo(null);
|
||||
setBusquedaCompletada(false);
|
||||
setCuentaBuscada('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determinar longitudes válidas según el tipo
|
||||
const longitudesValidas = {
|
||||
'cuenta_alumno': 9,
|
||||
'cuenta_trabajador': 6,
|
||||
'rfc': 10
|
||||
};
|
||||
|
||||
const longitudRequerida = longitudesValidas[preguntaCuenta.validacion as keyof typeof longitudesValidas];
|
||||
const puedeBuscar = cuenta.length === longitudRequerida;
|
||||
|
||||
// Solo buscar si no se ha buscado ya esta cuenta
|
||||
if (puedeBuscar && cuentaBuscada !== cuenta) {
|
||||
setBusquedaCompletada(false);
|
||||
|
||||
const fetchCuentaInfo = async () => {
|
||||
try {
|
||||
let endpoint: string;
|
||||
|
||||
switch (preguntaCuenta.validacion) {
|
||||
case 'cuenta_alumno':
|
||||
endpoint = `/alumnos/${cuenta}`;
|
||||
break;
|
||||
case 'cuenta_trabajador':
|
||||
endpoint = `/trabajadores/${cuenta}`;
|
||||
break;
|
||||
case 'rfc':
|
||||
endpoint = `/trabajadores/${cuenta}`;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Tipo de validación no reconocido');
|
||||
}
|
||||
|
||||
const res = await axiosInstance.get<UsuarioDataResponse>(endpoint);
|
||||
console.log('Datos de cuenta obtenidos:', res.data);
|
||||
|
||||
// Verificar si hay datos válidos
|
||||
const hayDatosValidos = res.data.nombre || res.data.apellidos ||
|
||||
res.data.carrera || res.data.genero;
|
||||
|
||||
if (hayDatosValidos) {
|
||||
setCuentaInfo({
|
||||
nombre: res.data.nombre || '',
|
||||
apellidos: res.data.apellidos || '',
|
||||
correo: res.data.cuenta
|
||||
? `${res.data.cuenta}@pcpuma.acatlan.unam.mx`
|
||||
: '',
|
||||
genero: (res.data.genero === 'M' || res.data.genero === 'F')
|
||||
? res.data.genero
|
||||
: 'M',
|
||||
carrera: res.data.carrera || '',
|
||||
rfc: res.data.rfc || undefined,
|
||||
});
|
||||
} else {
|
||||
setCuentaInfo(null);
|
||||
}
|
||||
|
||||
setCuentaBuscada(cuenta);
|
||||
setBusquedaCompletada(true);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error al obtener datos de cuenta:', error);
|
||||
setCuentaInfo(null);
|
||||
setCuentaBuscada(cuenta);
|
||||
setBusquedaCompletada(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCuentaInfo();
|
||||
} else if (!puedeBuscar) {
|
||||
setCuentaInfo(null);
|
||||
setBusquedaCompletada(false);
|
||||
setCuentaBuscada('');
|
||||
}
|
||||
}, [respuestas, preguntaCuenta, esComunidadSi, cuentaBuscada]);
|
||||
|
||||
// Efecto para prellenar respuestas cuando se obtiene información de cuenta
|
||||
useEffect(() => {
|
||||
if (!cuentaInfo || !data?.cuestionario.secciones) return;
|
||||
|
||||
const nuevasRespuestas: RespuestaFormulario = {};
|
||||
|
||||
if (preguntas) {
|
||||
for (const pregunta of preguntas) {
|
||||
const id = pregunta.id_pregunta;
|
||||
switch (pregunta.validacion) {
|
||||
case 'nombre':
|
||||
nuevasRespuestas[id] = cuentaInfo.nombre;
|
||||
break;
|
||||
case 'apellidos':
|
||||
nuevasRespuestas[id] = cuentaInfo.apellidos;
|
||||
break;
|
||||
case 'correo':
|
||||
nuevasRespuestas[id] = cuentaInfo.correo;
|
||||
break;
|
||||
case 'institucion':
|
||||
nuevasRespuestas[id] = 'FES Acatlán';
|
||||
break;
|
||||
case 'carrera':
|
||||
nuevasRespuestas[id] = cuentaInfo.carrera;
|
||||
break;
|
||||
case 'genero':
|
||||
const generoTexto =
|
||||
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
|
||||
const opcion = pregunta.opciones?.find(
|
||||
(op) =>
|
||||
op.opcion.opcion.toLowerCase() === generoTexto.toLowerCase()
|
||||
);
|
||||
if (opcion) {
|
||||
nuevasRespuestas[id] = String(opcion.id_opcion);
|
||||
}
|
||||
break;
|
||||
}
|
||||
preguntas?.forEach((pregunta) => {
|
||||
const id = pregunta.id_pregunta;
|
||||
|
||||
switch (pregunta.validacion) {
|
||||
case 'nombre':
|
||||
nuevasRespuestas[id] = cuentaInfo.nombre;
|
||||
break;
|
||||
case 'apellidos':
|
||||
nuevasRespuestas[id] = cuentaInfo.apellidos;
|
||||
break;
|
||||
case 'correo':
|
||||
nuevasRespuestas[id] = cuentaInfo.correo;
|
||||
break;
|
||||
case 'carrera':
|
||||
nuevasRespuestas[id] = cuentaInfo.carrera;
|
||||
break;
|
||||
case 'genero':
|
||||
const generoTexto = cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
|
||||
const opcionGenero = pregunta.opciones?.find(
|
||||
(op) => op.opcion.opcion.toLowerCase() === generoTexto.toLowerCase()
|
||||
);
|
||||
if (opcionGenero) {
|
||||
nuevasRespuestas[id] = String(opcionGenero.id_opcion);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
setRespuestas((prev) => ({
|
||||
...prev,
|
||||
...nuevasRespuestas,
|
||||
}));
|
||||
}
|
||||
}, [cuentaInfo]);
|
||||
setRespuestas(prev => ({ ...prev, ...nuevasRespuestas }));
|
||||
}, [cuentaInfo, data?.cuestionario.secciones, preguntas]);
|
||||
|
||||
// ------------------------
|
||||
// Funciones auxiliares
|
||||
// ------------------------
|
||||
// Función para actualizar respuestas
|
||||
const actualizarRespuesta = (idPregunta: string, valor: string) => {
|
||||
setRespuestas((prev) => ({ ...prev, [idPregunta]: valor }));
|
||||
setRespuestas(prev => ({ ...prev, [idPregunta]: valor }));
|
||||
};
|
||||
|
||||
// Función para enviar formulario
|
||||
const handleOnSubmit = async () => {
|
||||
console.log('Enviando respuestas:', respuestas);
|
||||
setIsSending(true);
|
||||
|
||||
if (!data?.cuestionario.id_cuestionario) {
|
||||
alert('No se ha cargado correctamente el formulario');
|
||||
toast.error('No se ha cargado correctamente el formulario');
|
||||
setIsSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const preguntasObligatorias =
|
||||
data?.cuestionario.secciones.flatMap((seccion) =>
|
||||
seccion.preguntas
|
||||
.filter((pregunta) => pregunta.pregunta.obligatoria)
|
||||
.map((pregunta) => pregunta.pregunta)
|
||||
) || [];
|
||||
// Validar preguntas obligatorias
|
||||
const preguntasObligatorias = data.cuestionario.secciones.flatMap((seccion) =>
|
||||
seccion.preguntas
|
||||
.filter((pregunta) => pregunta.pregunta.obligatoria)
|
||||
.map((pregunta) => pregunta.pregunta)
|
||||
);
|
||||
|
||||
const faltantes = preguntasObligatorias.filter(
|
||||
(pregunta) =>
|
||||
@@ -234,26 +240,22 @@ export default function FormularioRegistro({
|
||||
respuestas[pregunta.id_pregunta] === null
|
||||
);
|
||||
|
||||
const preguntaCorreo = preguntasObligatorias.find(
|
||||
(pregunta) =>
|
||||
pregunta.validacion === 'correo' ||
|
||||
pregunta.validacion === 'correo_institucional'
|
||||
);
|
||||
|
||||
if (faltantes.length > 0) {
|
||||
toast.error('Por favor responde todas las preguntas obligatorias.');
|
||||
setIsSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Buscar un posible correo en las respuestas si no hay cuentaInfo
|
||||
let correo =
|
||||
cuentaInfo?.correo === respuestas[preguntaCorreo?.id_pregunta ?? '']
|
||||
? cuentaInfo?.correo
|
||||
: respuestas[preguntaCorreo?.id_pregunta ?? ''];
|
||||
// Buscar correo
|
||||
const preguntaCorreo = preguntas?.find(p =>
|
||||
p.validacion === 'correo' || p.validacion === 'correo_institucional'
|
||||
);
|
||||
|
||||
let correo = preguntaCorreo
|
||||
? String(respuestas[preguntaCorreo.id_pregunta] || '')
|
||||
: '';
|
||||
|
||||
if (!correo) {
|
||||
// Buscar en las respuestas una que parezca correo
|
||||
const correoRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
for (const valor of Object.values(respuestas)) {
|
||||
if (typeof valor === 'string' && correoRegex.test(valor)) {
|
||||
@@ -265,7 +267,7 @@ export default function FormularioRegistro({
|
||||
|
||||
const payload = {
|
||||
id_cuestionario: id_cuestionario,
|
||||
correo: correo || '',
|
||||
correo: correo,
|
||||
fecha_envio: new Date().toISOString(),
|
||||
respuestas: Object.entries(respuestas).map(([id_pregunta, valor]) => ({
|
||||
id_pregunta: Number(id_pregunta),
|
||||
@@ -274,86 +276,42 @@ export default function FormularioRegistro({
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await axiosInstance.post(
|
||||
const res = await axiosInstance.post<SubmitResponse>(
|
||||
'/cuestionario-respondido/submit',
|
||||
payload
|
||||
);
|
||||
toast(
|
||||
() => (
|
||||
<span
|
||||
className="fs-4 pc-3 py-2"
|
||||
style={{
|
||||
maxWidth: '400px',
|
||||
wordWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
}}
|
||||
>
|
||||
{res.data.message.includes('ya ha respondido') ? (
|
||||
<>
|
||||
<i className="bi bi-exclamation-circle-fill text-warning me-2"></i>
|
||||
{res.data.message}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-check-circle-fill text-success me-2"></i>
|
||||
{res.data.message || '¡Formulario enviado!'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
{
|
||||
duration: 6000,
|
||||
position: 'bottom-center',
|
||||
style: {
|
||||
maxWidth: '425px',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
toast.success(res.data.message || '¡Formulario enviado exitosamente!');
|
||||
handleSubmitFormulario(res.data);
|
||||
} catch (error) {
|
||||
console.log(getAxiosError(error));
|
||||
toast.error('Hubo un error al enviar el formulario', {
|
||||
duration: 5000,
|
||||
});
|
||||
console.error('Error al enviar formulario:', getAxiosError(error));
|
||||
toast.error('Hubo un error al enviar el formulario');
|
||||
} finally {
|
||||
// Redirigir al usuario después de enviar el formulario
|
||||
router.push('/');
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------------
|
||||
// Condición para mostrar el formulario
|
||||
// ------------------------
|
||||
const mostrarFormularioRestante =
|
||||
(isComunidad &&
|
||||
confirmativeOption.some((opt) =>
|
||||
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
|
||||
) &&
|
||||
!!cuentaInfo) ||
|
||||
(isComunidad &&
|
||||
negativeOption.some((opt) =>
|
||||
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
|
||||
));
|
||||
// Condición para mostrar el resto del formulario
|
||||
const mostrarFormularioCompleto =
|
||||
!preguntaComunidad || // Sin pregunta de comunidad
|
||||
(esComunidadSi && busquedaCompletada) || // Con comunidad "Sí" y búsqueda completada
|
||||
(!esComunidadSi && comunidadSeleccionada); // Con comunidad "No"
|
||||
|
||||
if (error) {
|
||||
console.error('Error al cargar el cuestionario:', error);
|
||||
return <div>Error al cargar el cuestionario</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="preguntas">
|
||||
<hr />
|
||||
<h2 className="text-xl font-bold">{data?.cuestionario.nombre_form}</h2>
|
||||
{data?.cuestionario.descripcion && (
|
||||
<MarkdownRenderer markdown={data.cuestionario.descripcion} />
|
||||
)}
|
||||
if (!data) {
|
||||
return <div>Cargando...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Pregunta de comunidad */}
|
||||
{preguntaComunidad && (
|
||||
<div className="my-4">
|
||||
<label
|
||||
className={`form-label ${
|
||||
preguntaComunidad.obligatoria ? 'required' : ''
|
||||
}`}
|
||||
>
|
||||
<label className={`form-label ${preguntaComunidad.obligatoria ? 'required' : ''}`}>
|
||||
{preguntaComunidad.pregunta}
|
||||
</label>
|
||||
<RadioOptionGroup
|
||||
@@ -362,194 +320,152 @@ export default function FormularioRegistro({
|
||||
label: op.opcion.opcion,
|
||||
value: op.id_opcion,
|
||||
}))}
|
||||
selectedValue={isComunidad?.value}
|
||||
selectedValue={comunidadSeleccionada ? Number(comunidadSeleccionada) : undefined}
|
||||
onChange={(opt) => {
|
||||
console.log('Opción comunidad seleccionada:', opt);
|
||||
setIsComunidad(opt);
|
||||
actualizarRespuesta(
|
||||
preguntaComunidad.id_pregunta.toString(),
|
||||
String(opt.value)
|
||||
);
|
||||
actualizarRespuesta(preguntaComunidad.id_pregunta.toString(), String(opt.value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isComunidad &&
|
||||
confirmativeOption.some((opt) =>
|
||||
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
|
||||
) &&
|
||||
preguntaCuenta && (
|
||||
<div className="my-4">
|
||||
<label
|
||||
className={`form-label ${
|
||||
preguntaCuenta.obligatoria ? 'required' : ''
|
||||
}`}
|
||||
>
|
||||
{preguntaCuenta.pregunta}
|
||||
</label>
|
||||
<SimpleInput
|
||||
type="text"
|
||||
name="cuenta"
|
||||
placeholder={
|
||||
preguntaCuenta.validacion === 'cuenta_alumno'
|
||||
? 'Ingrese su cuenta'
|
||||
: preguntaCuenta?.validacion === 'rfc'
|
||||
? 'Ingrese su RFC sin homoclave'
|
||||
: preguntaCuenta?.validacion === 'cuenta_trabajador'
|
||||
? 'Ingrese su numero de trabajador'
|
||||
: 'Ingrese su ' + preguntaCuenta.pregunta
|
||||
}
|
||||
maxLength={10}
|
||||
value={respuestas[preguntaCuenta.id_pregunta] || ''}
|
||||
onChange={(e) =>
|
||||
setRespuestas((prev) => ({
|
||||
...prev,
|
||||
[preguntaCuenta.id_pregunta]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Pregunta de cuenta (si respondió "Sí" a comunidad) */}
|
||||
{esComunidadSi && preguntaCuenta && (
|
||||
<div className="my-4">
|
||||
<label className={`form-label ${preguntaCuenta.obligatoria ? 'required' : ''}`}>
|
||||
{preguntaCuenta.pregunta}
|
||||
</label>
|
||||
<SimpleInput
|
||||
type="text"
|
||||
name="cuenta"
|
||||
placeholder={
|
||||
preguntaCuenta.validacion === 'cuenta_alumno'
|
||||
? 'Ingrese su número de cuenta (9 dígitos)'
|
||||
: preguntaCuenta.validacion === 'cuenta_trabajador'
|
||||
? 'Ingrese su número de trabajador (6 dígitos)'
|
||||
: preguntaCuenta.validacion === 'rfc'
|
||||
? 'Ingrese su RFC sin homoclave (10 caracteres)'
|
||||
: `Ingrese su ${preguntaCuenta.pregunta}`
|
||||
}
|
||||
maxLength={preguntaCuenta.validacion === 'rfc' ? 10 :
|
||||
preguntaCuenta.validacion === 'cuenta_alumno' ? 9 : 6}
|
||||
value={respuestas[preguntaCuenta.id_pregunta] || ''}
|
||||
onChange={(e) => actualizarRespuesta(
|
||||
preguntaCuenta.id_pregunta.toString(),
|
||||
e.target.value
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mostrarFormularioRestante &&
|
||||
data?.cuestionario.secciones.map((seccion, i) => (
|
||||
<div key={i}>
|
||||
{seccion.preguntas.map((pregunta) => {
|
||||
// Evitar renderizar las preguntas ya manejadas
|
||||
if (
|
||||
pregunta.pregunta.id_pregunta ===
|
||||
preguntaComunidad?.id_pregunta ||
|
||||
pregunta.pregunta.id_pregunta === preguntaCuenta?.id_pregunta
|
||||
)
|
||||
return null;
|
||||
{/* Resto del formulario */}
|
||||
{mostrarFormularioCompleto && data.cuestionario.secciones.map((seccion, i) => (
|
||||
<div key={i}>
|
||||
{seccion.preguntas.map((pregunta) => {
|
||||
// No renderizar preguntas ya manejadas
|
||||
if (
|
||||
pregunta.pregunta.id_pregunta === preguntaComunidad?.id_pregunta ||
|
||||
pregunta.pregunta.id_pregunta === preguntaCuenta?.id_pregunta
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
pregunta.pregunta.tipo_pregunta.tipo_pregunta ===
|
||||
'Abierta (Respuesta corta)'
|
||||
) {
|
||||
return (
|
||||
<PrefetchAbiertaCorta
|
||||
key={pregunta.pregunta.id_pregunta}
|
||||
obligatorio={pregunta.pregunta.obligatoria}
|
||||
idPregunta={pregunta.pregunta.id_pregunta}
|
||||
enunciado={pregunta.pregunta.pregunta}
|
||||
validacion={pregunta.pregunta.validacion}
|
||||
respuestas={respuestas}
|
||||
cuentaInfo={cuentaInfo}
|
||||
isComunidad={isComunidad?.label}
|
||||
onChange={actualizarRespuesta}
|
||||
preguntaComunidad={preguntaComunidad?.validacion ?? ''}
|
||||
const valor = respuestas[pregunta.pregunta.id_pregunta] || '';
|
||||
|
||||
// Pregunta abierta corta
|
||||
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Abierta (Respuesta corta)') {
|
||||
return (
|
||||
<div key={pregunta.pregunta.id_pregunta} className="my-4">
|
||||
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
|
||||
{pregunta.pregunta.pregunta}
|
||||
</label>
|
||||
<SimpleInput
|
||||
type="text"
|
||||
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
|
||||
placeholder="Ingrese una respuesta"
|
||||
value={valor}
|
||||
onChange={(e) => actualizarRespuesta(
|
||||
pregunta.pregunta.id_pregunta.toString(),
|
||||
e.target.value
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
pregunta.pregunta.tipo_pregunta.tipo_pregunta ===
|
||||
'Abierta (Parrafo)'
|
||||
) {
|
||||
return (
|
||||
<div key={pregunta.pregunta.id_pregunta} className="my-4">
|
||||
<label
|
||||
className={`form-label ${
|
||||
pregunta.pregunta.obligatoria ? 'required' : ''
|
||||
}`}
|
||||
>
|
||||
{pregunta.pregunta.pregunta}
|
||||
</label>
|
||||
<SimpleInput
|
||||
type="textarea"
|
||||
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
|
||||
placeholder="Ingrese una respuesta"
|
||||
value={respuestas[pregunta.pregunta.id_pregunta] || ''}
|
||||
onChange={(e) =>
|
||||
setRespuestas((prev) => ({
|
||||
...prev,
|
||||
[pregunta.pregunta.id_pregunta]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Pregunta abierta párrafo
|
||||
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Abierta (Parrafo)') {
|
||||
return (
|
||||
<div key={pregunta.pregunta.id_pregunta} className="my-4">
|
||||
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
|
||||
{pregunta.pregunta.pregunta}
|
||||
</label>
|
||||
<SimpleInput
|
||||
type="textarea"
|
||||
name={`respuesta_${pregunta.pregunta.id_pregunta}`}
|
||||
placeholder="Ingrese una respuesta"
|
||||
value={valor}
|
||||
onChange={(e) => actualizarRespuesta(
|
||||
pregunta.pregunta.id_pregunta.toString(),
|
||||
e.target.value
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Cerrada') {
|
||||
const opciones: RadioOption<number>[] =
|
||||
pregunta.pregunta.opciones.map((op) => ({
|
||||
label: op.opcion.opcion,
|
||||
value: op.id_opcion,
|
||||
}));
|
||||
let respuestaActual =
|
||||
respuestas[`${pregunta.pregunta.id_pregunta}`];
|
||||
|
||||
if (
|
||||
confirmativeOption.includes(isComunidad?.label) &&
|
||||
cuentaInfo &&
|
||||
respuestaActual === undefined // solo si no ha respondido
|
||||
) {
|
||||
if (pregunta.pregunta.validacion === 'genero') {
|
||||
const generoTexto =
|
||||
cuentaInfo.genero === 'M' ? 'Masculino' : 'Femenino';
|
||||
|
||||
const opcionGenero = pregunta.pregunta.opciones.find(
|
||||
(op) =>
|
||||
op.opcion.opcion.toLowerCase() ===
|
||||
generoTexto.toLowerCase()
|
||||
);
|
||||
if (opcionGenero) {
|
||||
respuestaActual = String(opcionGenero.id_opcion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={pregunta.pregunta.id_pregunta}>
|
||||
<label
|
||||
className={`form-label ${
|
||||
pregunta.pregunta.obligatoria ? 'required' : ''
|
||||
}`}
|
||||
>
|
||||
{pregunta.pregunta.pregunta}
|
||||
</label>
|
||||
<RadioOptionGroup
|
||||
name={`pregunta_${pregunta.pregunta.id_pregunta}`}
|
||||
options={opciones}
|
||||
selectedValue={
|
||||
respuestaActual ? Number(respuestaActual) : undefined
|
||||
}
|
||||
onChange={(opt) => {
|
||||
actualizarRespuesta(
|
||||
`${pregunta.pregunta.id_pregunta}`,
|
||||
String(opt.value)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Pregunta cerrada
|
||||
if (pregunta.pregunta.tipo_pregunta.tipo_pregunta === 'Cerrada') {
|
||||
const opciones: RadioOption<number>[] = pregunta.pregunta.opciones.map((op) => ({
|
||||
label: op.opcion.opcion,
|
||||
value: op.id_opcion,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div key={pregunta.pregunta.id_pregunta} className="my-4">
|
||||
<label
|
||||
className={`form-label ${
|
||||
pregunta.pregunta.obligatoria ? 'required' : ''
|
||||
}`}
|
||||
>
|
||||
<label className={`form-label ${pregunta.pregunta.obligatoria ? 'required' : ''}`}>
|
||||
{pregunta.pregunta.pregunta}
|
||||
</label>
|
||||
<RadioOptionGroup
|
||||
name={`pregunta_${pregunta.pregunta.id_pregunta}`}
|
||||
options={opciones}
|
||||
selectedValue={valor ? Number(valor) : undefined}
|
||||
onChange={(opt) => actualizarRespuesta(
|
||||
pregunta.pregunta.id_pregunta.toString(),
|
||||
String(opt.value)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
}
|
||||
|
||||
<Button
|
||||
className="btn btn-primary"
|
||||
onClick={handleOnSubmit}
|
||||
disabled={!mostrarFormularioRestante || isSending}
|
||||
>
|
||||
{isSending ? 'Enviando...' : 'Enviar Formulario'}
|
||||
</Button>
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Botón de envío */}
|
||||
{mostrarFormularioCompleto && (
|
||||
<Button
|
||||
className="btn btn-primary"
|
||||
onClick={handleOnSubmit}
|
||||
disabled={isSending}
|
||||
>
|
||||
{isSending ? 'Enviando...' : 'Enviar Formulario'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
curl -X 'POST' \
|
||||
'https://venus.acatlan.unam.mx/registro_test/administrador' \
|
||||
-H 'accept: application/json' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"nombre_usuario": "staffmike",
|
||||
"correo": "staffmike@ejemplo.com",
|
||||
"password": "password",
|
||||
"id_tipo_user": 2
|
||||
}'
|
||||
*/
|
||||
@@ -2,10 +2,12 @@
|
||||
import MarkdownRenderer from '@/components/markdown-render';
|
||||
import { useGetApi } from '@/hooks/use-get-api';
|
||||
import Image from 'next/image';
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import FormularioRegistro from '../formulario/formulario-registro';
|
||||
import { GetEventoWithCuestionario } from '@/types/evento';
|
||||
import { formatFecha } from '@/utils/date-utils';
|
||||
import { formatearRangoFechas } from '@/utils/date-utils';
|
||||
import { SubmitResponse } from '@/types/submit';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function FormularioPage({
|
||||
id_evento,
|
||||
@@ -14,55 +16,354 @@ export default function FormularioPage({
|
||||
id_evento: string;
|
||||
id_cuestionario: string;
|
||||
}) {
|
||||
const [response, setResponse] = useState<SubmitResponse | null>(null);
|
||||
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
|
||||
const { data, error } = useGetApi<GetEventoWithCuestionario>(
|
||||
`/evento/${id_evento}/cuestionario/${id_cuestionario}`
|
||||
);
|
||||
|
||||
// Procesar el QR buffer cuando se reciba la respuesta
|
||||
useEffect(() => {
|
||||
if (!response?.qr_buffer) {
|
||||
setQrImageUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const binaryString = atob(response.qr_buffer);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([bytes], { type: 'image/png' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setQrImageUrl(url);
|
||||
} catch (error) {
|
||||
console.error('Error al procesar el QR buffer:', error);
|
||||
setQrImageUrl(null);
|
||||
}
|
||||
}, [response?.qr_buffer]);
|
||||
|
||||
// Cleanup del QR cuando el componente se desmonte o cambie la respuesta
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (qrImageUrl) {
|
||||
URL.revokeObjectURL(qrImageUrl);
|
||||
}
|
||||
};
|
||||
}, [qrImageUrl]);
|
||||
|
||||
if (error) {
|
||||
return <div>Error al cargar el cuestionario: {error.message}</div>;
|
||||
}
|
||||
|
||||
const onSubmitFormulario = async (res: SubmitResponse) => {
|
||||
setResponse(res);
|
||||
};
|
||||
|
||||
// Si hay una respuesta exitosa, mostrar la vista de éxito
|
||||
if (response && response.success) {
|
||||
return (
|
||||
<div className="container my-5">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-12 col-lg-10 col-xl-8">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-body">
|
||||
<div className="row g-4">
|
||||
<div className="col-md-6">
|
||||
<div className="h-100 d-flex flex-column justify-content-center">
|
||||
<div className="text-center mb-4">
|
||||
<div className="mb-3">
|
||||
<div
|
||||
className="d-inline-flex align-items-center justify-content-center bg-success rounded-circle"
|
||||
style={{ width: '60px', height: '60px' }}
|
||||
>
|
||||
<i
|
||||
className="bi bi-check-lg text-white"
|
||||
style={{ fontSize: '2rem' }}
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="fw-bold text-success mb-2">
|
||||
¡Felicidades!
|
||||
</h2>
|
||||
<p className="text-muted mb-0">
|
||||
Has completado exitosamente el formulario
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="d-flex align-items-center mb-2">
|
||||
<i
|
||||
className="bi bi-calendar-event text-primary me-3"
|
||||
style={{ fontSize: '1.3rem' }}
|
||||
></i>
|
||||
<h6 className="mb-0 text-primary">Evento</h6>
|
||||
</div>
|
||||
<p className="mb-0 fs-6 fw-semibold ms-5">
|
||||
{data?.nombre_evento}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="d-flex align-items-center mb-2">
|
||||
<i
|
||||
className="bi bi-file-text text-info me-3"
|
||||
style={{ fontSize: '1.3rem' }}
|
||||
></i>
|
||||
<h6 className="mb-0 text-info">Formulario</h6>
|
||||
</div>
|
||||
<p className="mb-0 fs-6 fw-semibold ms-5">
|
||||
{data?.cuestionario.nombre_form}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{response.message && (
|
||||
<div className="mb-3">
|
||||
<div className="alert alert-info" role="alert">
|
||||
<div className="d-flex align-items-start">
|
||||
<i className="bi bi-info-circle me-2 mt-1"></i>
|
||||
<div>
|
||||
<strong>Mensaje:</strong>
|
||||
<p className="mb-0 mt-1">{response.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="h-100 d-flex flex-column justify-content-center align-items-center">
|
||||
{qrImageUrl ? (
|
||||
<div className="text-center">
|
||||
<h5 className="mb-3 text-secondary">
|
||||
<i className="bi bi-qr-code me-2"></i>
|
||||
Código QR
|
||||
</h5>
|
||||
<div className="p-3 bg-white rounded shadow-sm">
|
||||
<Image
|
||||
src={qrImageUrl}
|
||||
alt="Código QR"
|
||||
width={250}
|
||||
height={250}
|
||||
className="rounded"
|
||||
style={{ maxWidth: '100%', height: 'auto' }}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted">
|
||||
<i
|
||||
className="bi bi-qr-code-scan"
|
||||
style={{ fontSize: '4rem' }}
|
||||
></i>
|
||||
<p className="mt-3 mb-0">
|
||||
No hay código QR disponible
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-4">
|
||||
<Link href="/" className="btn btn-primary btn-lg me-3">
|
||||
Ir al inicio
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container my-5">
|
||||
{/* Sección de banners mejorada */}
|
||||
<div className="text-center mb-4">
|
||||
{data?.banner && (
|
||||
<Image
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`}
|
||||
alt={data?.nombre_evento || ''}
|
||||
width={800}
|
||||
height={400}
|
||||
className="img-fluid rounded"
|
||||
/>
|
||||
)}
|
||||
{data?.banner && data?.cuestionario.banner ? (
|
||||
/* Ambos banners disponibles */
|
||||
<div className="d-flex align-items-center justify-content-center gap-3 flex-wrap">
|
||||
<div className="position-relative">
|
||||
<Image
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`}
|
||||
alt={`Banner del evento: ${data.nombre_evento}`}
|
||||
width={350}
|
||||
height={200}
|
||||
className="img-fluid rounded shadow-sm"
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
<div className="position-absolute bottom-0 start-0 m-2">
|
||||
<span className="badge bg-primary bg-opacity-90">
|
||||
<i className="bi bi-calendar-event me-1"></i>
|
||||
Evento
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center justify-content-center">
|
||||
<div
|
||||
className="rounded-circle bg-light border d-flex align-items-center justify-content-center"
|
||||
style={{ width: '50px', height: '50px' }}
|
||||
>
|
||||
<i className="bi bi-x-lg fs-4 text-muted"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="position-relative">
|
||||
<Image
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.cuestionario.banner}`}
|
||||
alt={`Banner del formulario: ${data.cuestionario.nombre_form}`}
|
||||
width={350}
|
||||
height={200}
|
||||
className="img-fluid rounded shadow-sm"
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
<div className="position-absolute bottom-0 start-0 m-2">
|
||||
<span className="badge bg-success bg-opacity-90">
|
||||
<i className="bi bi-file-earmark-text me-1"></i>
|
||||
Formulario
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : data?.banner ? (
|
||||
/* Solo banner del evento */
|
||||
<div>
|
||||
<Image
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.banner}`}
|
||||
alt={data?.nombre_evento || ''}
|
||||
width={800}
|
||||
height={400}
|
||||
className="img-fluid rounded shadow"
|
||||
/>
|
||||
<p className="text-muted mt-2 small">
|
||||
<i className="bi bi-calendar-event me-1"></i>
|
||||
Banner del evento
|
||||
</p>
|
||||
</div>
|
||||
) : data?.cuestionario.banner ? (
|
||||
/* Solo banner del formulario */
|
||||
<div>
|
||||
<Image
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/banners/${data.cuestionario.banner}`}
|
||||
alt={data?.cuestionario.nombre_form || ''}
|
||||
width={800}
|
||||
height={400}
|
||||
className="img-fluid rounded shadow"
|
||||
/>
|
||||
<p className="text-muted mt-2 small">
|
||||
<i className="bi bi-file-earmark-text me-1"></i>
|
||||
Banner del formulario
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<h1>{data?.nombre_evento}</h1>
|
||||
{data?.descripcion_evento && (
|
||||
<MarkdownRenderer markdown={data?.descripcion_evento} />
|
||||
)}
|
||||
|
||||
{data?.fecha_fin && data?.fecha_inicio && (
|
||||
<>
|
||||
<p className="mb-0 mt-2 text-muted">Horario:</p>
|
||||
<p className="text-muted">
|
||||
<i className="bi bi-clock me-1 text-primary"></i>
|
||||
{formatFecha(data.fecha_inicio!, {
|
||||
horas: true,
|
||||
minutos: true,
|
||||
})}{' '}
|
||||
al{' '}
|
||||
{formatFecha(data.fecha_fin!, {
|
||||
horas: true,
|
||||
minutos: true,
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{/* Layout de dos columnas */}
|
||||
<div className="row">
|
||||
{/* Columna izquierda - Información */}
|
||||
<div className="col-lg-5">
|
||||
{/* Tarjeta: Evento principal */}
|
||||
<section aria-labelledby="evento-heading" className="mb-1">
|
||||
<div className="card border-0 shadow-sm">
|
||||
<div className="card-body">
|
||||
<h2
|
||||
id="evento-heading"
|
||||
className="h4 mb-2 d-flex align-items-center gap-2"
|
||||
>
|
||||
<i className="bi bi-calendar-event text-primary"></i>
|
||||
Evento
|
||||
</h2>
|
||||
|
||||
{data && (
|
||||
<FormularioRegistro
|
||||
id_cuestionario={data?.cuestionario.id_cuestionario}
|
||||
/>
|
||||
)}
|
||||
<h3 className="h3 mb-2">{data?.nombre_evento}</h3>
|
||||
|
||||
{data?.descripcion_evento && (
|
||||
<div className="text-muted mb-3">
|
||||
<MarkdownRenderer markdown={data?.descripcion_evento} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.fecha_inicio && data?.fecha_fin && (
|
||||
<p className="text-muted mb-0">
|
||||
<i
|
||||
className="bi bi-calendar-date me-1 text-primary"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
{formatearRangoFechas(data.fecha_inicio, data.fecha_fin)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tarjeta: Sub-evento (Cuestionario) */}
|
||||
{data?.cuestionario && (
|
||||
<section aria-labelledby="cuestionario-heading">
|
||||
<div className="card border-0 shadow-sm">
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||
<h2
|
||||
id="cuestionario-heading"
|
||||
className="h4 mb-0 d-flex align-items-center gap-2"
|
||||
>
|
||||
<i className="bi bi-ui-checks-grid text-success"></i>
|
||||
Registro a cuestionario
|
||||
</h2>
|
||||
<span className="badge bg-success-subtle text-success border border-success-subtle">
|
||||
Sub-evento
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="h3 mb-1">{data.cuestionario.nombre_form}</h3>
|
||||
|
||||
{data.cuestionario.descripcion && (
|
||||
<div className="text-muted mb-3">
|
||||
<MarkdownRenderer
|
||||
markdown={data.cuestionario.descripcion}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.cuestionario.fecha_inicio &&
|
||||
data.cuestionario.fecha_fin && (
|
||||
<p className="text-muted mb-0 fs-5 fw-bold text-decoration-underline">
|
||||
<i
|
||||
className="bi bi-clock me-1 text-primary"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
{formatearRangoFechas(
|
||||
data.cuestionario.fecha_inicio,
|
||||
data.cuestionario.fecha_fin
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Columna derecha - Formulario */}
|
||||
<div className="col-lg-7">
|
||||
<div className="ps-lg-4">
|
||||
{data && (
|
||||
<FormularioRegistro
|
||||
evento={data.nombre_evento}
|
||||
cuestionario={data?.cuestionario.nombre_form}
|
||||
id_evento={data?.id_evento}
|
||||
id_cuestionario={data?.cuestionario.id_cuestionario}
|
||||
handleSubmitFormulario={onSubmitFormulario}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
import { GetEventoWithCuestionariosWithCupos } from '@/types/evento';
|
||||
import axiosInstance from '@/utils/api-config';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
interface EventoContextType {
|
||||
evento: GetEventoWithCuestionariosWithCupos | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
updateEvento: (
|
||||
eventoData: Partial<GetEventoWithCuestionariosWithCupos>
|
||||
) => void;
|
||||
setEventoData: (evento: GetEventoWithCuestionariosWithCupos) => void;
|
||||
}
|
||||
|
||||
const EventoContext = createContext<EventoContextType | undefined>(undefined);
|
||||
|
||||
interface EventoProviderProps {
|
||||
children: ReactNode;
|
||||
id_evento: string;
|
||||
}
|
||||
|
||||
export function EventoProvider({ children, id_evento }: EventoProviderProps) {
|
||||
const [evento, setEvento] =
|
||||
useState<GetEventoWithCuestionariosWithCupos | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchEvento = useCallback(async () => {
|
||||
console.log('Fetching evento with ID:', id_evento);
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response =
|
||||
await axiosInstance.get<GetEventoWithCuestionariosWithCupos>(
|
||||
`/evento/${id_evento}/cuestionarios`
|
||||
);
|
||||
setEvento(response.data);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Error al cargar el evento';
|
||||
setError(errorMessage);
|
||||
console.error('Error fetching evento:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id_evento]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id_evento) {
|
||||
fetchEvento();
|
||||
}
|
||||
}, [id_evento, fetchEvento]);
|
||||
|
||||
const refetch = () => {
|
||||
fetchEvento();
|
||||
};
|
||||
|
||||
const updateEvento = useCallback(
|
||||
(eventoData: Partial<GetEventoWithCuestionariosWithCupos>) => {
|
||||
setEvento((prev) => (prev ? { ...prev, ...eventoData } : null));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const setEventoData = useCallback(
|
||||
(newEvento: GetEventoWithCuestionariosWithCupos) => {
|
||||
setEvento(newEvento);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const value: EventoContextType = {
|
||||
evento,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
updateEvento,
|
||||
setEventoData,
|
||||
};
|
||||
|
||||
return (
|
||||
<EventoContext.Provider value={value}>{children}</EventoContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useEvento() {
|
||||
const context = useContext(EventoContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useEvento must be used within an EventoProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './evento-context';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './evento';
|
||||
@@ -154,6 +154,57 @@ export const plantillasDisponibles: Plantilla[] = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
imagen: '/plantilla_general.png',
|
||||
nombre: 'Registro General',
|
||||
id: 'registro-general',
|
||||
datos: {
|
||||
nombre_form: 'Registro Para Asistencia General',
|
||||
descripcion:
|
||||
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
|
||||
fecha_inicio: '2025-08-01T08:00:00',
|
||||
fecha_fin: '2025-08-10T23:59:59',
|
||||
id_tipo_cuestionario: 1,
|
||||
secciones: [
|
||||
{
|
||||
titulo: 'Información Personal',
|
||||
descripcion:
|
||||
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
|
||||
preguntas: [
|
||||
{
|
||||
titulo: 'Numero de cuenta',
|
||||
tipo: 'Abierta (Respuesta corta)',
|
||||
obligatoria: false,
|
||||
},
|
||||
{
|
||||
titulo: 'Correo electrónico',
|
||||
tipo: 'Abierta (Respuesta corta)',
|
||||
obligatoria: true,
|
||||
validacion: 'correo',
|
||||
},
|
||||
{
|
||||
titulo: 'Nombre(s)',
|
||||
tipo: 'Abierta (Respuesta corta)',
|
||||
obligatoria: true,
|
||||
validacion: 'nombre',
|
||||
},
|
||||
{
|
||||
titulo: 'Apellidos',
|
||||
tipo: 'Abierta (Respuesta corta)',
|
||||
obligatoria: true,
|
||||
validacion: 'apellidos',
|
||||
},
|
||||
{
|
||||
titulo: 'Tipo de Asistente',
|
||||
tipo: 'Cerrada',
|
||||
obligatoria: true,
|
||||
opciones: [{ valor: 'Alumno' }, { valor: 'Profesor' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/*
|
||||
|
||||
Vendored
+4
@@ -174,3 +174,7 @@ input {
|
||||
margin-inline: 14rem;
|
||||
}
|
||||
}
|
||||
|
||||
.table-white {
|
||||
--bs-table-bg: #fff;
|
||||
}
|
||||
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
export interface Administrador {
|
||||
id_administrador: number;
|
||||
nombre_usuario: string;
|
||||
correo: string;
|
||||
tipoUser: {
|
||||
id: number;
|
||||
tipo: string;
|
||||
};
|
||||
}
|
||||
Vendored
+21
-20
@@ -1,25 +1,25 @@
|
||||
export type TiposValidacion =
|
||||
| 'correo'
|
||||
| 'correo_institucional'
|
||||
| 'telefono'
|
||||
| 'nombre'
|
||||
| 'entero'
|
||||
| 'apellidos'
|
||||
| 'decimal'
|
||||
| 'comunidad_alumno'
|
||||
| 'cuenta_alumno'
|
||||
| 'comunidad_trabajador'
|
||||
| 'cuenta_trabajador'
|
||||
| 'genero'
|
||||
| 'carrera'
|
||||
| 'institucion'
|
||||
| 'rfc'
|
||||
| 'correo'
|
||||
| 'correo_institucional'
|
||||
| 'telefono'
|
||||
| 'nombre'
|
||||
| 'entero'
|
||||
| 'apellidos'
|
||||
| 'decimal'
|
||||
| 'comunidad_alumno'
|
||||
| 'cuenta_alumno'
|
||||
| 'comunidad_trabajador'
|
||||
| 'cuenta_trabajador'
|
||||
| 'genero'
|
||||
| 'carrera'
|
||||
| 'institucion'
|
||||
| 'rfc';
|
||||
|
||||
export type TipoPregunta =
|
||||
| 'Abierta (Respuesta corta)'
|
||||
| 'Abierta (Parrafo)'
|
||||
| 'Cerrada'
|
||||
| 'Multiple';
|
||||
export type TipoPregunta =
|
||||
| 'Abierta (Respuesta corta)'
|
||||
| 'Abierta (Parrafo)'
|
||||
| 'Cerrada'
|
||||
| 'Multiple';
|
||||
|
||||
export interface FormularioCreacion {
|
||||
nombre_form: string;
|
||||
@@ -27,6 +27,7 @@ export interface FormularioCreacion {
|
||||
fecha_inicio: string; // ISO 8601 date string
|
||||
fecha_fin: string;
|
||||
id_tipo_cuestionario: number;
|
||||
id_tipo_evento?: number;
|
||||
cupo_maximo?: number; // Opcional, si no se requiere un cupo máximo
|
||||
secciones: SeccionFormulario[];
|
||||
}
|
||||
|
||||
Vendored
+17
-4
@@ -1,4 +1,4 @@
|
||||
import { TipoPregunta, TiposValidacion } from "./create-formulario";
|
||||
import { TipoPregunta, TiposValidacion } from './create-formulario';
|
||||
|
||||
export interface GetEvento {
|
||||
id_evento: number;
|
||||
@@ -9,7 +9,7 @@ export interface GetEvento {
|
||||
fecha_fin: string; // formato ISO 8601
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
asistencias: any | null;
|
||||
banner: string | null;
|
||||
banner: string | null; // banner-1755043351827-446638467.jpeg
|
||||
}
|
||||
|
||||
export interface Cuestionario {
|
||||
@@ -28,13 +28,16 @@ export interface Cuestionario {
|
||||
id_tipo_cuestionario: number;
|
||||
tipo_cuestionario: string;
|
||||
};
|
||||
tipoEvento: {
|
||||
id_tipo_evento: number;
|
||||
tipo_evento: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface CuestionarioWithCupo extends Cuestionario {
|
||||
cupos_usados: number;
|
||||
cupos_disponibles: number;
|
||||
}
|
||||
|
||||
export interface GetCuestionario {
|
||||
tipo_cuestionario: {
|
||||
id_tipo_cuestionario: number;
|
||||
@@ -82,10 +85,20 @@ export interface CuestionarioWithSecciones extends Cuestionario {
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface GetEventoWithCuestionarios extends GetEvento {
|
||||
export interface GetEventoWithCuestionariosWithCupos extends GetEvento {
|
||||
cuestionarios: CuestionarioWithCupo[];
|
||||
}
|
||||
|
||||
export interface GetEventoWithCuestionario extends GetEvento {
|
||||
cuestionario: Cuestionario;
|
||||
}
|
||||
|
||||
export interface GetEventoWithCuestionarios extends GetEvento {
|
||||
cuestionarios: Cuestionario[];
|
||||
total_cuestionarios: number;
|
||||
}
|
||||
|
||||
export interface TipoEvento {
|
||||
id_tipo_evento: number;
|
||||
tipo_evento: string;
|
||||
}
|
||||
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
export interface SubmitResponse {
|
||||
success: boolean;
|
||||
registrado: boolean;
|
||||
message: string;
|
||||
qr_token: string;
|
||||
qr_buffer: string;
|
||||
}
|
||||
@@ -14,4 +14,46 @@ const axiosInstance = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor para agregar el token de autenticación
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
// Obtener el token del sessionStorage
|
||||
const token = sessionStorage.getItem('token');
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor para manejar errores de autenticación
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
// Si el token ha expirado o es inválido (401)
|
||||
if (error.response?.status === 401) {
|
||||
// Limpiar el sessionStorage
|
||||
sessionStorage.removeItem('token');
|
||||
sessionStorage.removeItem('user');
|
||||
|
||||
// Redireccionar al login solo si no estamos ya en la página de login
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
!window.location.pathname.includes('/login')
|
||||
) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default axiosInstance;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const tipoEventos = [
|
||||
{ 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' },
|
||||
];
|
||||
+225
-1
@@ -28,7 +28,6 @@
|
||||
export function formatFecha(
|
||||
fechaStr: string,
|
||||
config: { horas?: boolean; minutos?: boolean; diaSemana?: boolean } = {
|
||||
|
||||
horas: false,
|
||||
minutos: false,
|
||||
diaSemana: false,
|
||||
@@ -104,3 +103,228 @@ export function formatDateLocal(date: Date): string {
|
||||
|
||||
return `${yyyy}-${MM}-${dd}T${hh}:${mm}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea un rango de fechas en español, optimizando la salida según si coinciden año, mes y día.
|
||||
*
|
||||
* @param {string} fechaInicio - Fecha de inicio en formato ISO 8601 (ej: '2025-08-13T18:44:00.000Z').
|
||||
* @param {string} fechaFin - Fecha de fin en formato ISO 8601 (ej: '2025-08-15T18:44:00.000Z').
|
||||
* @returns {string} Rango de fechas formateado en español.
|
||||
* @throws {Error} Lanza un error si alguna de las fechas proporcionadas no es válida.
|
||||
*
|
||||
* @example
|
||||
* formatearRangoFechas('2025-08-13T12:00:00.000Z', '2025-08-13T18:00:00.000Z');
|
||||
* // Retorna: "el 13 de agosto del 2025 de 12:00 a 18:00"
|
||||
*
|
||||
* @example
|
||||
* formatearRangoFechas('2025-08-13T18:44:00.000Z', '2025-08-15T18:44:00.000Z');
|
||||
* // Retorna: "del 13 al 15 de agosto del 2025"
|
||||
*
|
||||
* @example
|
||||
* formatearRangoFechas('2025-08-13T18:44:00.000Z', '2025-09-13T18:44:00.000Z');
|
||||
* // Retorna: "del 13 de agosto al 13 de septiembre del 2025"
|
||||
*
|
||||
* @example
|
||||
* formatearRangoFechas('2025-08-13T18:44:00.000Z', '2026-01-13T18:44:00.000Z');
|
||||
* // Retorna: "del 13 de agosto del 2025 al 13 de enero del 2026"
|
||||
*/
|
||||
export function formatearRangoFechas(
|
||||
fechaInicio: string,
|
||||
fechaFin: string
|
||||
): string {
|
||||
const inicio = new Date(fechaInicio);
|
||||
const fin = new Date(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 meses = [
|
||||
'enero',
|
||||
'febrero',
|
||||
'marzo',
|
||||
'abril',
|
||||
'mayo',
|
||||
'junio',
|
||||
'julio',
|
||||
'agosto',
|
||||
'septiembre',
|
||||
'octubre',
|
||||
'noviembre',
|
||||
'diciembre',
|
||||
];
|
||||
|
||||
const diaInicio = inicio.getUTCDate();
|
||||
const mesInicio = meses[inicio.getUTCMonth()];
|
||||
const añoInicio = inicio.getUTCFullYear();
|
||||
|
||||
const diaFin = fin.getUTCDate();
|
||||
const mesFin = meses[fin.getUTCMonth()];
|
||||
const añoFin = fin.getUTCFullYear();
|
||||
|
||||
// Si es el mismo día (año, mes y día iguales)
|
||||
if (
|
||||
añoInicio === añoFin &&
|
||||
inicio.getUTCMonth() === fin.getUTCMonth() &&
|
||||
diaInicio === diaFin
|
||||
) {
|
||||
const horaInicio = (inicio.getUTCHours() - 6 + 24) % 24;
|
||||
const minutosInicio = inicio.getUTCMinutes().toString().padStart(2, '0');
|
||||
const horaFin = (fin.getUTCHours() - 6 + 24) % 24;
|
||||
const minutosFin = fin.getUTCMinutes().toString().padStart(2, '0');
|
||||
|
||||
return `el ${diaInicio} de ${mesInicio} del ${añoInicio} de ${horaInicio}:${minutosInicio} a ${horaFin}:${minutosFin}`;
|
||||
}
|
||||
|
||||
// Si el año y mes son iguales pero días diferentes
|
||||
if (añoInicio === añoFin && inicio.getUTCMonth() === fin.getUTCMonth()) {
|
||||
return `del ${diaInicio} al ${diaFin} de ${mesInicio} del ${añoInicio}`;
|
||||
}
|
||||
|
||||
// Si el año es igual pero el mes es diferente
|
||||
if (añoInicio === añoFin) {
|
||||
return `del ${diaInicio} de ${mesInicio} al ${diaFin} de ${mesFin} del ${añoInicio}`;
|
||||
}
|
||||
|
||||
// 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae y formatea el horario entre dos fechas.
|
||||
* Retorna el rango de horas en formato "HH:MM a HH:MM Hrs".
|
||||
*
|
||||
* @param {string | Date} fechaInicio - Fecha de inicio en formato ISO 8601 o objeto Date.
|
||||
* @param {string | Date} fechaFin - Fecha de fin en formato ISO 8601 o objeto Date.
|
||||
* @returns {string} Horario formateado en formato "HH:MM a HH:MM Hrs".
|
||||
* @throws {Error} Lanza un error si alguna de las fechas proporcionadas no es válida.
|
||||
*
|
||||
* @example
|
||||
* formatearHorario('2025-08-13T13:00:00.000Z', '2025-08-13T14:00:00.000Z');
|
||||
* // Retorna: "07:00 a 08:00 Hrs" (ajustado a UTC-6)
|
||||
*
|
||||
* @example
|
||||
* formatearHorario(new Date('2025-08-13T19:30:00.000Z'), new Date('2025-08-13T21:45:00.000Z'));
|
||||
* // Retorna: "13:30 a 15:45 Hrs"
|
||||
*
|
||||
* @example
|
||||
* formatearHorario('2025-08-13T13:00:00.000Z', '2025-08-13T13:00:00.000Z');
|
||||
* // Retorna: "07:00 Hrs" (misma hora)
|
||||
*/
|
||||
export function formatearHorario(
|
||||
fechaInicio: string | Date,
|
||||
fechaFin: string | Date
|
||||
): 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}`);
|
||||
}
|
||||
|
||||
// Ajustar a zona horaria UTC-6 (México)
|
||||
const horaInicio = (inicio.getUTCHours() - 6 + 24) % 24;
|
||||
const minutosInicio = inicio.getUTCMinutes().toString().padStart(2, '0');
|
||||
|
||||
const horaFin = (fin.getUTCHours() - 6 + 24) % 24;
|
||||
const minutosFin = fin.getUTCMinutes().toString().padStart(2, '0');
|
||||
|
||||
const horaInicioFormateada = `${horaInicio
|
||||
.toString()
|
||||
.padStart(2, '0')}:${minutosInicio}`;
|
||||
const horaFinFormateada = `${horaFin
|
||||
.toString()
|
||||
.padStart(2, '0')}:${minutosFin}`;
|
||||
|
||||
// Si es la misma hora, solo mostrar una vez
|
||||
if (horaInicioFormateada === horaFinFormateada) {
|
||||
return `${horaInicioFormateada} Hrs`;
|
||||
}
|
||||
|
||||
return `${horaInicioFormateada} a ${horaFinFormateada}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// slugify.ts
|
||||
export function slugify(input: string, maxLen = 60) {
|
||||
return input
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, maxLen);
|
||||
}
|
||||
|
||||
// Combina slug + id (id al final facilita parsear con "último -")
|
||||
export function toParam(name: string, id: string | number) {
|
||||
return `${slugify(name)}-${id}`;
|
||||
}
|
||||
|
||||
// Extrae { id, slug } de un param con formato "slug-123"
|
||||
export function fromParam(param: string) {
|
||||
const lastDash = param.lastIndexOf('-');
|
||||
if (lastDash === -1) return { slug: param, id: null };
|
||||
const slug = param.slice(0, lastDash);
|
||||
const idStr = param.slice(lastDash + 1);
|
||||
const id = /^\d+$/.test(idStr) ? Number(idStr) : null;
|
||||
return { slug, id };
|
||||
}
|
||||
Reference in New Issue
Block a user