feat: add admin management page and types

- Implemented the admin management page with a table displaying admin details.
- Created the Administrador type definition for better type safety.
- Added SubmitResponse type for handling submission responses.
- Included placeholder components for form card previews.
This commit is contained in:
miguel
2025-08-19 10:26:02 -06:00
parent 24642d1c3b
commit 99bfb46b8b
20 changed files with 610 additions and 207 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -12,6 +12,8 @@ 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;
@@ -23,6 +25,7 @@ export default function Page() {
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(
@@ -223,6 +226,10 @@ export default function Page() {
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();
})()}
+45
View File
@@ -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>
);
}
+43 -41
View File
@@ -3,28 +3,13 @@ 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();
// Fake user data
const fakeUsers = [
{
username: 'user-admin',
password: '@dm1nP@ss',
role: 'administrator',
redirectPath: '/user/eventos',
},
{
username: 'staff1',
password: 'st@ffP@ss',
role: 'staff',
redirectPath: '/user/eventos',
},
];
const [loginData, setLoginData] = React.useState({
username: '',
nombre_usuario: '',
password: '',
});
@@ -38,36 +23,53 @@ export default function LoginForm() {
if (error) setError('');
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
// Simulate API call delay
setTimeout(() => {
const user = fakeUsers.find(
(u) =>
u.username === loginData.username && u.password === loginData.password
);
try {
const response = await axiosInstance.post('/administrador/login', {
nombre_usuario: loginData.nombre_usuario,
password: loginData.password,
});
if (user) {
// Store user data in localStorage (in a real app, you'd use proper session management)
localStorage.setItem(
'user',
JSON.stringify({
username: user.username,
role: user.role,
})
);
// Guardar el token en sessionStorage
if (response.data.token) {
sessionStorage.setItem('token', response.data.token);
// Redirect to appropriate dashboard
router.push(user.redirectPath);
// Opcional: guardar información adicional del usuario si viene en la respuesta
if (response.data.user) {
sessionStorage.setItem('user', JSON.stringify(response.data.user));
}
// Redireccionar al dashboard
router.push('/user/eventos');
} else {
setError('Usuario o contraseña incorrectos');
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);
}, 1000);
}
};
return (
@@ -88,7 +90,7 @@ export default function LoginForm() {
<span className="text-dorado">Sistema de Eventos</span>
</h6>
<p className="text-muted mt-2 mb-4">
Ingresa tu nombre de usuario y tu contraseña para acceder
Ingresa tu correo electrónico y tu contraseña para acceder
a tu cuenta.
</p>
@@ -101,9 +103,9 @@ export default function LoginForm() {
<SimpleInput
label="Nombre de usuario"
type="text"
id="username"
name="username"
value={loginData.username}
id="nombre_usuario"
name="nombre_usuario"
value={loginData.nombre_usuario}
onChange={handleChange}
required
/>
+1 -1
View File
@@ -105,7 +105,7 @@ export default function EventoCardAdmin({
Formularios disponibles:
</h6>
<div className="row row-cols-1 g-2">
{evento.cuestionarios.map((cuestionario) => (
{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">
@@ -5,6 +5,7 @@ import Link from 'next/link';
import React, { useState } from 'react';
import MarkdownRenderer from '../markdown-render';
import { toParam } from '@/utils/slugify';
import { formatearFechaCard, formatearHorario } from '@/utils/date-utils';
import Button from '../button';
export default function FormularioCardUser({
@@ -22,6 +23,22 @@ export default function FormularioCardUser({
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)}...`
@@ -78,7 +95,7 @@ export default function FormularioCardUser({
{/* 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}
{formulario.tipoCuestionario.tipo_cuestionario}{' '}
</span>
</div>
</div>
@@ -87,34 +104,24 @@ export default function FormularioCardUser({
{/* Fecha del evento */}
<div className="d-flex align-items-center mb-2">
<div className="me-3">
<div className="text-muted text-center small">
{new Date(formulario.fecha_inicio)
.toLocaleDateString('es-ES', { month: 'short' })
.toUpperCase()}
</div>
<div className="text-muted text-center small">{mesTexto}</div>
<div
className="fw-bold h4 mb-0"
style={{ lineHeight: 1, whiteSpace: 'nowrap' }}
>
{(() => {
const fechaInicio = new Date(formulario.fecha_inicio);
const fechaFin = new Date(formulario.fecha_fin);
const diaInicio = fechaInicio.getDate();
const diaFin = fechaFin.getDate();
const esElMismoDia =
fechaInicio.toDateString() === fechaFin.toDateString();
return esElMismoDia ? diaInicio : `${diaInicio} - ${diaFin}`;
})()}
{diaTexto}
</div>
</div>
<div>
<span className="small text-muted fw-bold">
{evento.nombre_evento}
</span>
<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>
@@ -133,27 +140,50 @@ export default function FormularioCardUser({
</div>
{/* Información de cupos y estadísticas */}
<div className="mb-3">
<div
className={`py-2 px-3 rounded ${
sinCupos ? 'bg-primary-subtle' : 'bg-light'
}`}
>
<div className="d-flex align-items-center">
<i className={`bi bi-people-fill me-2 text-primary`}></i>
<div>
<small className="text-muted d-block">Cupos</small>
<span className={`fw-semibold text-primary`}>
{formulario.cupo_maximo !== null
? `${formulario.cupos_usados} / ${formulario.cupo_maximo}`
: 'Sin límite'}
</span>
{sinCupos && (
<div className="small text-primary mt-1">
<i className="bi bi-exclamation-circle me-1"></i>
Cupos agotados
<div 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>
+1
View File
@@ -11,6 +11,7 @@ const Navbar: React.FC = () => {
{ 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: '/signout', label: 'Cerrar sesión' },
];
+5 -2
View File
@@ -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
+15 -8
View File
@@ -2,8 +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 { tipoEventos } from '@/utils/arrays';
import { TipoEvento } from '@/types/evento';
import { formatDateLocal } from '@/utils/date-utils';
import { commands } from '@uiw/react-md-editor';
import dynamic from 'next/dynamic';
@@ -24,6 +25,7 @@ export default function CreateEvento({
handleChange,
handleBannerChange,
}: CreateEventoProps) {
const { data: tipo_eventos } = useGetApi<TipoEvento[]>('tipo-evento');
return (
<div className="mt-2">
<h4 className="mb-3">Detalles del Evento</h4>
@@ -75,13 +77,18 @@ export default function CreateEvento({
</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}
/>
{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 className="row">
<div className="col-md-6">
+27 -10
View File
@@ -5,7 +5,7 @@ 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';
@@ -14,6 +14,7 @@ interface Props {
evento?: GetEventoWithCuestionariosWithCupos;
formulario: FormularioCreacion;
onChange: (formularioActualizado: FormularioCreacion) => void;
tipo_eventos?: Option[];
}
const tipoPreguntaOpciones: TipoPregunta[] = [
@@ -56,6 +57,7 @@ export default function FormularioEditor({
formulario,
onChange,
evento,
tipo_eventos,
}: Props) {
function actualizarCampo<K extends keyof FormularioCreacion>(
campo: K,
@@ -212,15 +214,30 @@ export default function FormularioEditor({
</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"
/>
<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>
);
@@ -9,6 +9,7 @@ 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;
@@ -35,8 +36,13 @@ type RespuestaFormulario = Record<string, string | number>; // id_pregunta: resp
export default function FormularioRegistro({
id_cuestionario,
handleSubmitFormulario,
}: {
evento: string;
cuestionario: string;
id_evento: number;
id_cuestionario: number;
handleSubmitFormulario: (data: SubmitResponse) => void;
}) {
// ------------------------
// Estados
@@ -77,7 +83,8 @@ export default function FormularioRegistro({
// Efecto: buscar información de cuenta
// ------------------------
useEffect(() => {
const cuenta = respuestas[preguntaCuenta?.id_pregunta ?? ''];
const cuentaId = preguntaCuenta?.id_pregunta ?? '';
const cuenta = respuestas[cuentaId];
const esCuentaAlumno = preguntaCuenta?.validacion === 'cuenta_alumno';
const esCuentaTrabajador =
@@ -130,7 +137,7 @@ export default function FormularioRegistro({
} else {
setCuentaInfo(null);
}
}, [respuestas[preguntaCuenta?.id_pregunta ?? '']]);
}, [respuestas, preguntaCuenta?.id_pregunta, preguntaCuenta?.validacion]);
// ------------------------
// Efecto: precargar respuestas si hay cuentaInfo
@@ -212,7 +219,6 @@ export default function FormularioRegistro({
};
const handleOnSubmit = async () => {
console.log('Enviando respuestas:', respuestas);
setIsSending(true);
if (!data?.cuestionario.id_cuestionario) {
alert('No se ha cargado correctamente el formulario');
@@ -273,7 +279,7 @@ export default function FormularioRegistro({
};
try {
const res = await axiosInstance.post(
const res = await axiosInstance.post<SubmitResponse>(
'/cuestionario-respondido/submit',
payload
);
@@ -282,7 +288,7 @@ export default function FormularioRegistro({
<span
className="fs-4 pc-3 py-2"
style={{
maxWidth: '400px',
maxWidth: '300px',
wordWrap: 'break-word',
whiteSpace: 'normal',
}}
@@ -290,7 +296,7 @@ export default function FormularioRegistro({
{res.data.message.includes('ya ha respondido') ? (
<>
<i className="bi bi-exclamation-circle-fill text-warning me-2"></i>
{res.data.message}
Respuestas Enviadas Con Éxito
</>
) : (
<>
@@ -308,13 +314,13 @@ export default function FormularioRegistro({
},
}
);
handleSubmitFormulario(res.data);
} catch (error) {
console.log(getAxiosError(error));
toast.error('Hubo un error al enviar el formulario', {
duration: 5000,
});
} finally {
// Redirigir al usuario después de enviar el formulario
router.push('/');
}
};
@@ -367,7 +373,6 @@ export default function FormularioRegistro({
/>
</div>
)}
{isComunidad &&
confirmativeOption.some((opt) =>
isComunidad.label?.toLowerCase().includes(opt.toLowerCase())
@@ -404,7 +409,6 @@ export default function FormularioRegistro({
/>
</div>
)}
{mostrarFormularioRestante &&
data?.cuestionario.secciones.map((seccion, i) => (
<div key={i}>
@@ -535,7 +539,6 @@ export default function FormularioRegistro({
})}
</div>
))}
<Button
className="btn btn-primary"
onClick={handleOnSubmit}
+235 -75
View File
@@ -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,14 +16,172 @@ 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 */}
@@ -109,87 +269,83 @@ export default function FormularioPage({
<div className="row">
{/* Columna izquierda - Información */}
<div className="col-lg-5">
{/* Información del evento */}
<div className="mb-4">
<h1 className="mb-3">{data?.nombre_evento}</h1>
{data?.descripcion_evento && (
<div className="mb-3">
<MarkdownRenderer markdown={data?.descripcion_evento} />
</div>
)}
{data?.fecha_fin && data?.fecha_inicio && (
<div className="mb-3">
<p className="mb-1 fw-semibold text-muted">
<i className="bi bi-calendar-event me-2"></i>
Horario del evento:
</p>
<p className="text-muted ps-4">
<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>
</div>
)}
</div>
{/* Información del formulario */}
{data?.cuestionario && (
<div className="card border-0 bg-light">
{/* Tarjeta: Evento principal */}
<section aria-labelledby="evento-heading" className="mb-1">
<div className="card border-0 shadow-sm">
<div className="card-body">
<h5 className="card-title text-primary mb-3">
<i className="bi bi-file-earmark-text me-2"></i>
Registro al formulario
</h5>
<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>
<div className="mb-3">
<h6 className="fw-bold mb-2">Te estás registrando a:</h6>
<p className="mb-1">
<span className="badge bg-primary me-2">Formulario</span>
<strong>{data.cuestionario.nombre_form}</strong>
</p>
<p className="mb-0 text-muted small">
<span className="badge bg-secondary me-2">Evento</span>
{data.nombre_evento}
</p>
</div>
<h3 className="h3 mb-2">{data?.nombre_evento}</h3>
{data.cuestionario.descripcion && (
<div className="mb-3">
<h6 className="fw-bold mb-2">Descripción:</h6>
<MarkdownRenderer
markdown={data.cuestionario.descripcion}
/>
{data?.descripcion_evento && (
<div className="text-muted mb-3">
<MarkdownRenderer markdown={data?.descripcion_evento} />
</div>
)}
{data.cuestionario.fecha_inicio &&
data.cuestionario.fecha_fin && (
<div className="mb-0">
<h6 className="fw-bold mb-2">Horario del formulario:</h6>
<p className="text-muted small mb-0">
<i className="bi bi-clock me-1"></i>
{formatFecha(data.cuestionario.fecha_inicio, {
horas: true,
minutos: true,
})}{' '}
al{' '}
{formatFecha(data.cuestionario.fecha_fin, {
horas: true,
minutos: true,
})}
</p>
</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>
@@ -198,7 +354,11 @@ export default function FormularioPage({
<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>
+9
View File
@@ -0,0 +1,9 @@
export interface Administrador {
id_administrador: number;
nombre_usuario: string;
correo: string;
tipoUser: {
id: number;
tipo: string;
};
}
+21 -20
View File
@@ -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[];
}
+9
View File
@@ -28,6 +28,10 @@ export interface Cuestionario {
id_tipo_cuestionario: number;
tipo_cuestionario: string;
};
tipoEvento: {
id_tipo_evento: number;
tipo_evento: string;
} | null;
}
export interface CuestionarioWithCupo extends Cuestionario {
@@ -93,3 +97,8 @@ export interface GetEventoWithCuestionarios extends GetEvento {
cuestionarios: Cuestionario[];
total_cuestionarios: number;
}
export interface TipoEvento {
id_tipo_evento: number;
tipo_evento: string;
}
+7
View File
@@ -0,0 +1,7 @@
export interface SubmitResponse {
success: boolean;
registrado: boolean;
message: string;
qr_token: string;
qr_buffer: string;
}
+42
View File
@@ -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;
+60
View File
@@ -268,3 +268,63 @@ export function formatearFechaCard(
};
}
}
/**
* 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}`;
}