feat: add bulk user upload functionality and form success page

- 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.
This commit is contained in:
miguel
2026-03-08 19:18:23 -06:00
parent f7d7bcfae6
commit d3e1441f87
17 changed files with 1320 additions and 508 deletions
@@ -0,0 +1,330 @@
'use client';
import React, {
createContext,
useContext,
useReducer,
useEffect,
useRef,
useMemo,
useCallback,
ReactNode,
} from 'react';
import axiosInstance from '@/utils/api-config';
import { getAxiosError } from '@/utils/errors-utils';
import { GetCuestionario } from '@/types/evento';
import { UsuarioDataResponse } from '@/containers/formulario/formulario-registro';
import {
formularioReducer,
initialState,
FormularioState,
RespuestaFormulario,
} from './formulario-reducer';
// ── Longitudes requeridas por tipo de validación ─────────────────────────────
const LONGITUDES: Record<string, number> = {
cuenta_alumno: 9,
cuenta_trabajador: 6,
rfc: 10,
};
// ── Tipos del contexto ────────────────────────────────────────────────────────
interface FormularioContextType {
state: FormularioState;
// Datos derivados estables
preguntas: ReturnType<typeof getPreguntasFlat>;
preguntaComunidad: PreguntaFlat | undefined;
preguntaCuenta: PreguntaFlat | undefined;
esComunidadSi: boolean;
esExclusivo: boolean;
mostrarFormularioCompleto: boolean;
// Acciones
setRespuesta: (id: string, valor: string) => void;
submitFormulario: (id_cuestionario: number) => Promise<import('@/types/submit').SubmitResponse>;
}
// Helper para aplanar preguntas
type PreguntaFlat = GetCuestionario['cuestionario']['secciones'][0]['preguntas'][0]['pregunta'];
function getPreguntasFlat(data: GetCuestionario | null): PreguntaFlat[] {
if (!data) return [];
return data.cuestionario.secciones.flatMap((s) =>
s.preguntas.map((p) => p.pregunta)
);
}
// ── Context ───────────────────────────────────────────────────────────────────
const FormularioContext = createContext<FormularioContextType | undefined>(undefined);
interface FormularioProviderProps {
children: ReactNode;
id_evento: number;
id_cuestionario: number;
}
export function FormularioProvider({
children,
id_evento,
id_cuestionario,
}: FormularioProviderProps) {
const [state, dispatch] = useReducer(formularioReducer, initialState);
// Rastrea si ya se limpiaron los campos para el usuarioEncontrado actual
const yaLimpioRef = useRef(false);
// ── Carga del cuestionario ─────────────────────────────────────────────────
useEffect(() => {
dispatch({ type: 'FETCH_DATA_START' });
axiosInstance
.get<GetCuestionario>(`/cuestionario/${id_cuestionario}/formulario`)
.then((res) => dispatch({ type: 'FETCH_DATA_SUCCESS', payload: res.data }))
.catch((err) => {
const { message } = getAxiosError(err);
dispatch({ type: 'FETCH_DATA_ERROR', payload: message });
});
}, [id_cuestionario]);
// ── Derivados estables ─────────────────────────────────────────────────────
const preguntas = useMemo(() => getPreguntasFlat(state.data), [state.data]);
const preguntaComunidad = useMemo(
() =>
preguntas.find(
(p) =>
p.validacion === 'comunidad_alumno' ||
p.validacion === 'comunidad_trabajador'
),
[preguntas]
);
const preguntaCuenta = useMemo(
() =>
preguntas.find(
(p) =>
p.validacion === 'cuenta_alumno' ||
p.validacion === 'cuenta_trabajador' ||
p.validacion === 'rfc'
),
[preguntas]
);
const comunidadSeleccionada = preguntaComunidad
? state.respuestas[preguntaComunidad.id_pregunta]
: null;
const esComunidadSi = useMemo(
() =>
!!(
comunidadSeleccionada &&
preguntaComunidad?.opciones.find(
(op) =>
op.id_opcion === Number(comunidadSeleccionada) &&
op.opcion.opcion.toLowerCase().includes('si')
)
),
[comunidadSeleccionada, preguntaComunidad]
);
const esExclusivo = useMemo(
() => !preguntaComunidad && !!preguntaCuenta,
[preguntaComunidad, preguntaCuenta]
);
const mostrarFormularioCompleto =
(esExclusivo && state.busquedaCompletada) ||
(!preguntaComunidad && !esExclusivo) ||
(esComunidadSi && state.busquedaCompletada) ||
(!!(esComunidadSi === false) && !!comunidadSeleccionada);
// ── Búsqueda de cuenta ─────────────────────────────────────────────────────
useEffect(() => {
const debeIniciarBusqueda = preguntaCuenta && (esExclusivo || esComunidadSi);
if (!debeIniciarBusqueda) {
if (state.cuentaBuscada || state.busquedaCompletada || state.errorBusqueda) {
dispatch({ type: 'BUSQUEDA_RESET' });
}
return;
}
const cuenta = state.respuestas[preguntaCuenta.id_pregunta];
if (!cuenta || typeof cuenta !== 'string') {
if (state.cuentaBuscada || state.busquedaCompletada || state.errorBusqueda) {
dispatch({ type: 'BUSQUEDA_RESET' });
}
return;
}
const longitudRequerida = LONGITUDES[preguntaCuenta.validacion ?? ''];
if (!longitudRequerida || cuenta.length !== longitudRequerida) {
if (state.cuentaBuscada) dispatch({ type: 'BUSQUEDA_RESET' });
return;
}
// No re-buscar la misma cuenta
if (cuenta === state.cuentaBuscada) return;
let endpoint =
preguntaCuenta.validacion === 'cuenta_alumno'
? `/alumnos/${cuenta}`
: `/trabajadores/${cuenta}`;
if (esExclusivo) endpoint += `?id_evento=${id_evento}`;
dispatch({ type: 'BUSQUEDA_START', payload: cuenta });
axiosInstance
.get<UsuarioDataResponse>(endpoint)
.then((res) => {
const d = res.data;
if (d.nombre || d.apellidos || d.carrera || d.genero) {
dispatch({ type: 'BUSQUEDA_SUCCESS', payload: d });
} else {
dispatch({ type: 'BUSQUEDA_ERROR', payload: 'No se encontraron datos para este usuario' });
}
})
.catch((err) => {
const { message } = getAxiosError(err);
dispatch({ type: 'BUSQUEDA_ERROR', payload: message });
});
}, [
state.respuestas,
state.cuentaBuscada,
state.busquedaCompletada,
state.errorBusqueda,
preguntaCuenta,
esComunidadSi,
esExclusivo,
id_evento,
]);
// ── Autorelleno cuando llega usuarioEncontrado ─────────────────────────────
useEffect(() => {
if (!state.data) return;
if (!state.usuarioEncontrado) {
// Solo limpiar si aún no lo hicimos (evita loop infinito)
if (!yaLimpioRef.current) return;
yaLimpioRef.current = false;
const idsCampos = preguntas
.filter((p) =>
['nombre', 'apellidos', 'correo', 'carrera', 'genero'].includes(p.validacion ?? '')
)
.map((p) => p.id_pregunta);
if (idsCampos.length) {
dispatch({ type: 'LIMPIAR_CAMPOS_AUTORELLENO', payload: idsCampos });
}
return;
}
const u = state.usuarioEncontrado;
const bulk: RespuestaFormulario = {};
preguntas.forEach((pregunta) => {
const id = pregunta.id_pregunta;
switch (pregunta.validacion) {
case 'nombre':
bulk[id] = u.nombre ?? '';
break;
case 'apellidos':
bulk[id] = u.apellidos ?? '';
break;
case 'correo':
bulk[id] = u.cuenta ? `${u.cuenta}@pcpuma.acatlan.unam.mx` : '';
break;
case 'carrera':
bulk[id] = u.carrera ?? '';
break;
case 'genero': {
const texto = u.genero === 'M' ? 'Masculino' : 'Femenino';
const opcion = pregunta.opciones?.find(
(op) => op.opcion.opcion.toLowerCase() === texto.toLowerCase()
);
if (opcion) bulk[id] = String(opcion.id_opcion);
break;
}
}
});
if (Object.keys(bulk).length) {
yaLimpioRef.current = true;
dispatch({ type: 'SET_RESPUESTAS_BULK', payload: bulk });
}
}, [state.usuarioEncontrado, state.data, preguntas]);
// ── Acciones expuestas ─────────────────────────────────────────────────────
const setRespuesta = useCallback((id: string, valor: string) => {
dispatch({ type: 'SET_RESPUESTA', payload: { id, valor } });
}, []);
const submitFormulario = useCallback(
async (id_cuestionario: number) => {
dispatch({ type: 'SEND_START' });
try {
const preguntaCorreo = preguntas.find(
(p) => p.validacion === 'correo' || p.validacion === 'correo_institucional'
);
let correo = preguntaCorreo
? String(state.respuestas[preguntaCorreo.id_pregunta] ?? '')
: '';
if (!correo) {
const rx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
for (const v of Object.values(state.respuestas)) {
if (typeof v === 'string' && rx.test(v)) { correo = v; break; }
}
}
const res = await axiosInstance.post<import('@/types/submit').SubmitResponse>(
'/cuestionario-respondido/submit',
{
id_cuestionario,
correo,
fecha_envio: new Date().toISOString(),
respuestas: Object.entries(state.respuestas).map(([id, valor]) => ({
id_pregunta: Number(id),
valor: isNaN(Number(valor)) ? valor : Number(valor),
})),
}
);
return res.data;
} finally {
dispatch({ type: 'SEND_END' });
}
},
[state.respuestas, preguntas]
);
// ── Valor del contexto ─────────────────────────────────────────────────────
const value: FormularioContextType = {
state,
preguntas,
preguntaComunidad,
preguntaCuenta,
esComunidadSi,
esExclusivo,
mostrarFormularioCompleto,
setRespuesta,
submitFormulario,
};
return (
<FormularioContext.Provider value={value}>
{children}
</FormularioContext.Provider>
);
}
export function useFormulario() {
const ctx = useContext(FormularioContext);
if (!ctx) throw new Error('useFormulario debe usarse dentro de FormularioProvider');
return ctx;
}
@@ -0,0 +1,152 @@
import { GetCuestionario } from '@/types/evento';
import { UsuarioDataResponse } from '@/containers/formulario/formulario-registro';
export type RespuestaFormulario = Record<string, string | number>;
// ── Estado ──────────────────────────────────────────────────────────────────
export interface FormularioState {
// Datos del cuestionario cargado desde la API
data: GetCuestionario | null;
loadingData: boolean;
errorData: string | null;
// Respuestas del usuario
respuestas: RespuestaFormulario;
// Búsqueda de cuenta/rfc
cuentaBuscada: string;
busquedaCompletada: boolean;
loadingBusqueda: boolean;
errorBusqueda: string | null;
usuarioEncontrado: UsuarioDataResponse | null;
// Envío
isSending: boolean;
}
export const initialState: FormularioState = {
data: null,
loadingData: true,
errorData: null,
respuestas: {},
cuentaBuscada: '',
busquedaCompletada: false,
loadingBusqueda: false,
errorBusqueda: null,
usuarioEncontrado: null,
isSending: false,
};
// ── Acciones ─────────────────────────────────────────────────────────────────
export type FormularioAction =
// Carga del cuestionario
| { type: 'FETCH_DATA_START' }
| { type: 'FETCH_DATA_SUCCESS'; payload: GetCuestionario }
| { type: 'FETCH_DATA_ERROR'; payload: string }
// Respuestas
| { type: 'SET_RESPUESTA'; payload: { id: string; valor: string } }
| { type: 'SET_RESPUESTAS_BULK'; payload: RespuestaFormulario }
| { type: 'LIMPIAR_CAMPOS_AUTORELLENO'; payload: number[] } // ids de pregunta
// Búsqueda de cuenta
| { type: 'BUSQUEDA_START'; payload: string } // cuenta buscada
| { type: 'BUSQUEDA_SUCCESS'; payload: UsuarioDataResponse }
| { type: 'BUSQUEDA_ERROR'; payload: string }
| { type: 'BUSQUEDA_RESET' }
// Envío
| { type: 'SEND_START' }
| { type: 'SEND_END' };
// ── Reducer ──────────────────────────────────────────────────────────────────
export function formularioReducer(
state: FormularioState,
action: FormularioAction
): FormularioState {
switch (action.type) {
// Carga del cuestionario
case 'FETCH_DATA_START':
return { ...state, loadingData: true, errorData: null };
case 'FETCH_DATA_SUCCESS':
return { ...state, loadingData: false, data: action.payload };
case 'FETCH_DATA_ERROR':
return { ...state, loadingData: false, errorData: action.payload };
// Respuestas
case 'SET_RESPUESTA':
return {
...state,
respuestas: { ...state.respuestas, [action.payload.id]: action.payload.valor },
};
case 'SET_RESPUESTAS_BULK':
return {
...state,
respuestas: { ...state.respuestas, ...action.payload },
};
case 'LIMPIAR_CAMPOS_AUTORELLENO': {
const limpio = { ...state.respuestas };
action.payload.forEach((id) => delete limpio[id]);
return { ...state, respuestas: limpio };
}
// Búsqueda
case 'BUSQUEDA_START':
return {
...state,
loadingBusqueda: true,
errorBusqueda: null,
busquedaCompletada: false,
usuarioEncontrado: null,
cuentaBuscada: action.payload,
};
case 'BUSQUEDA_SUCCESS':
return {
...state,
loadingBusqueda: false,
busquedaCompletada: true,
errorBusqueda: null,
usuarioEncontrado: action.payload,
};
case 'BUSQUEDA_ERROR':
return {
...state,
loadingBusqueda: false,
busquedaCompletada: false,
errorBusqueda: action.payload,
usuarioEncontrado: null,
};
case 'BUSQUEDA_RESET':
return {
...state,
cuentaBuscada: '',
busquedaCompletada: false,
loadingBusqueda: false,
errorBusqueda: null,
usuarioEncontrado: null,
};
// Envío
case 'SEND_START':
return { ...state, isSending: true };
case 'SEND_END':
return { ...state, isSending: false };
default:
return state;
}
}
+2
View File
@@ -0,0 +1,2 @@
export { FormularioProvider, useFormulario } from './formulario-context';
export type { FormularioState, FormularioAction, RespuestaFormulario } from './formulario-reducer';