d3e1441f87
- Implemented bulk upload for students and workers with file validation and error handling. - Added Excel template download feature for user uploads. - Created success page for form submissions with QR code display. - Established context and reducer for form state management, including user search and response handling. - Added mermaid diagrams for event registration documentation.
504 lines
16 KiB
TypeScript
504 lines
16 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
FormularioCreacion,
|
|
TipoPregunta,
|
|
TiposValidacion,
|
|
} from '@/types/create-formulario';
|
|
import SimpleInput from '@/components/input';
|
|
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[] = [
|
|
'Abierta (Respuesta corta)',
|
|
'Abierta (Parrafo)',
|
|
'Cerrada',
|
|
'Multiple',
|
|
];
|
|
|
|
const tiposValidacion: TiposValidacion[] = [
|
|
'correo',
|
|
'correo_institucional',
|
|
'telefono',
|
|
'nombre',
|
|
'apellidos',
|
|
'genero',
|
|
'entero',
|
|
'decimal',
|
|
'comunidad_alumno',
|
|
'cuenta_alumno',
|
|
'comunidad_trabajador',
|
|
'cuenta_trabajador',
|
|
'rfc',
|
|
];
|
|
|
|
type CampoPreguntaSimple =
|
|
| 'titulo'
|
|
| 'tipo'
|
|
| 'validacion'
|
|
| 'obligatoria'
|
|
| 'limite';
|
|
type CampoPreguntaOpciones = 'opciones';
|
|
|
|
const tiposCuestionario = [
|
|
{ label: 'Registro', value: 1 },
|
|
{ label: 'Evaluación', value: 2 },
|
|
];
|
|
|
|
export default function FormularioEditor({
|
|
formulario,
|
|
onChange,
|
|
evento,
|
|
tipo_eventos,
|
|
}: Props) {
|
|
function actualizarCampo<K extends keyof FormularioCreacion>(
|
|
campo: K,
|
|
valor: FormularioCreacion[K]
|
|
): void {
|
|
const actualizado = { ...formulario, [campo]: valor };
|
|
onChange(actualizado);
|
|
}
|
|
|
|
function handleUpdatePregunta(
|
|
seccionIdx: number,
|
|
preguntaIdx: number,
|
|
campo: CampoPreguntaSimple | CampoPreguntaOpciones,
|
|
valor:
|
|
| string
|
|
| boolean
|
|
| TipoPregunta
|
|
| TiposValidacion
|
|
| number
|
|
| { valor: string }[]
|
|
| undefined
|
|
) {
|
|
const actualizado = { ...formulario };
|
|
const pregunta = actualizado.secciones[seccionIdx].preguntas[preguntaIdx];
|
|
|
|
if (campo === 'opciones') {
|
|
pregunta.opciones = valor as { valor: string }[];
|
|
} else if (campo === 'titulo' && typeof valor === 'string') {
|
|
pregunta.titulo = valor;
|
|
} else if (campo === 'tipo' && typeof valor === 'string') {
|
|
pregunta.tipo = valor as TipoPregunta;
|
|
} else if (campo === 'validacion' || valor === undefined) {
|
|
pregunta.validacion = valor as TiposValidacion | undefined;
|
|
} else if (campo === 'obligatoria' && typeof valor === 'boolean') {
|
|
pregunta.obligatoria = valor;
|
|
} else if (campo === 'limite' && typeof valor === 'number') {
|
|
pregunta.limite = valor;
|
|
}
|
|
|
|
onChange(actualizado);
|
|
}
|
|
|
|
const handleUpdateSeccion = (
|
|
index: number,
|
|
campo: 'titulo' | 'descripcion',
|
|
valor: string
|
|
) => {
|
|
const actualizado = { ...formulario };
|
|
actualizado.secciones = actualizado.secciones.map((seccion, idx) =>
|
|
idx === index ? { ...seccion, [campo]: valor } : seccion
|
|
);
|
|
onChange(actualizado);
|
|
};
|
|
|
|
const agregarSeccion = () => {
|
|
const nuevaSeccion = {
|
|
titulo: '',
|
|
descripcion: '',
|
|
preguntas: [],
|
|
};
|
|
onChange({
|
|
...formulario,
|
|
secciones: [...formulario.secciones, nuevaSeccion],
|
|
});
|
|
};
|
|
|
|
const eliminarSeccion = (index: number) => {
|
|
const nuevas = [...formulario.secciones];
|
|
nuevas.splice(index, 1);
|
|
onChange({ ...formulario, secciones: nuevas });
|
|
};
|
|
|
|
const agregarPregunta = (seccionIdx: number) => {
|
|
const nuevaPregunta = {
|
|
titulo: '',
|
|
tipo: 'Abierta (Respuesta corta)' as TipoPregunta,
|
|
obligatoria: false,
|
|
opciones: [],
|
|
};
|
|
const actualizado = { ...formulario };
|
|
actualizado.secciones[seccionIdx].preguntas.push(nuevaPregunta);
|
|
onChange(actualizado);
|
|
};
|
|
|
|
const eliminarPregunta = (seccionIdx: number, preguntaIdx: number) => {
|
|
const actualizado = { ...formulario };
|
|
actualizado.secciones[seccionIdx].preguntas.splice(preguntaIdx, 1);
|
|
onChange(actualizado);
|
|
};
|
|
|
|
// 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="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"
|
|
type="number"
|
|
placeholder="Opcional"
|
|
value={formulario.cupo_maximo || ''}
|
|
onChange={(e) =>
|
|
actualizarCampo('cupo_maximo', Number(e.target.value) || 0)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<SimpleInput
|
|
label="Descripción del Formulario"
|
|
value={formulario.descripcion}
|
|
onChange={(e) => actualizarCampo('descripcion', e.target.value)}
|
|
/>
|
|
|
|
<div className="row">
|
|
<div className="col-md-6">
|
|
<SimpleInput
|
|
label="Fecha de Inicio"
|
|
type="datetime-local"
|
|
value={formulario.fecha_inicio.slice(0, 16)}
|
|
onChange={(e) => actualizarCampo('fecha_inicio', e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<SimpleInput
|
|
label="Fecha de Fin"
|
|
type="datetime-local"
|
|
value={formulario.fecha_fin.slice(0, 16)}
|
|
onChange={(e) => actualizarCampo('fecha_fin', e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{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>
|
|
)}
|
|
|
|
<SimpleInput
|
|
label="Mensaje de correo"
|
|
value={formulario.mensaje_correo || ''}
|
|
onChange={(e) => actualizarCampo('mensaje_correo', e.target.value)}
|
|
placeholder="Mensaje que se enviará al correo del participante al registrarse"
|
|
/>
|
|
|
|
<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>
|
|
|
|
{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">
|
|
<h5 className="mb-0">Sección {seccionIdx + 1}</h5>
|
|
<Button
|
|
variant="danger"
|
|
icon="trash"
|
|
size="sm"
|
|
outline
|
|
onClick={() => eliminarSeccion(seccionIdx)}
|
|
>
|
|
Eliminar sección
|
|
</Button>
|
|
</div>
|
|
|
|
<SimpleInput
|
|
label="Título de la sección"
|
|
value={seccion.titulo}
|
|
onChange={(e) =>
|
|
handleUpdateSeccion(seccionIdx, 'titulo', e.target.value)
|
|
}
|
|
/>
|
|
|
|
<SimpleInput
|
|
label="Descripción de la sección"
|
|
value={seccion.descripcion}
|
|
onChange={(e) =>
|
|
handleUpdateSeccion(seccionIdx, 'descripcion', e.target.value)
|
|
}
|
|
/>
|
|
|
|
{/* Preguntas */}
|
|
{seccion.preguntas.map((pregunta, preguntaIdx) => (
|
|
<div key={preguntaIdx} className="mb-3 p-3 border rounded bg-white">
|
|
<div className="d-flex justify-content-between align-items-center mb-2">
|
|
<h6 className="mb-0">Pregunta {preguntaIdx + 1}</h6>
|
|
<Button
|
|
variant="danger"
|
|
icon="trash"
|
|
size="sm"
|
|
outline
|
|
onClick={() => eliminarPregunta(seccionIdx, preguntaIdx)}
|
|
>
|
|
Eliminar
|
|
</Button>
|
|
</div>
|
|
|
|
<SimpleInput
|
|
label="Título de la pregunta"
|
|
value={pregunta.titulo}
|
|
onChange={(e) =>
|
|
handleUpdatePregunta(
|
|
seccionIdx,
|
|
preguntaIdx,
|
|
'titulo',
|
|
e.target.value
|
|
)
|
|
}
|
|
/>
|
|
|
|
<div className="row">
|
|
<div className="col-md-6">
|
|
<Select
|
|
label="Tipo de pregunta"
|
|
value={pregunta.tipo}
|
|
onChange={(e) => {
|
|
const nuevoTipo = e.target.value as TipoPregunta;
|
|
handleUpdatePregunta(
|
|
seccionIdx,
|
|
preguntaIdx,
|
|
'tipo',
|
|
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,
|
|
}))}
|
|
/>
|
|
</div>
|
|
|
|
<div className="col-md-6">
|
|
<Select
|
|
label="Validación"
|
|
value={pregunta.validacion || ''}
|
|
onChange={(e) =>
|
|
handleUpdatePregunta(
|
|
seccionIdx,
|
|
preguntaIdx,
|
|
'validacion',
|
|
e.target.value || undefined
|
|
)
|
|
}
|
|
options={[
|
|
{ label: 'Ninguna', value: '' },
|
|
...tiposValidacion.map((v) => ({
|
|
label: v,
|
|
value: v,
|
|
})),
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-check mb-2">
|
|
<input
|
|
className="form-check-input"
|
|
type="checkbox"
|
|
checked={pregunta.obligatoria}
|
|
id={`obligatoria-${seccionIdx}-${preguntaIdx}`}
|
|
onChange={(e) =>
|
|
handleUpdatePregunta(
|
|
seccionIdx,
|
|
preguntaIdx,
|
|
'obligatoria',
|
|
e.target.checked
|
|
)
|
|
}
|
|
/>
|
|
<label
|
|
className="form-check-label"
|
|
htmlFor={`obligatoria-${seccionIdx}-${preguntaIdx}`}
|
|
>
|
|
¿Es obligatoria?
|
|
</label>
|
|
</div>
|
|
|
|
{(pregunta.tipo === 'Cerrada' ||
|
|
pregunta.tipo === 'Multiple') && (
|
|
<div className="mb-2">
|
|
<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.push({ valor: '' });
|
|
handleUpdatePregunta(
|
|
seccionIdx,
|
|
preguntaIdx,
|
|
'opciones',
|
|
nuevasOpciones
|
|
);
|
|
}}
|
|
>
|
|
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>
|
|
))}
|
|
|
|
<Button
|
|
outline
|
|
className="mt-2"
|
|
size="sm"
|
|
icon="plus"
|
|
onClick={() => agregarPregunta(seccionIdx)}
|
|
>
|
|
Agregar pregunta
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
return {
|
|
informacionBasica: renderInformacionBasica,
|
|
seccionesYPreguntas: renderSeccionesYPreguntas,
|
|
};
|
|
}
|