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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FormularioProvider, useFormulario } from './formulario-context';
|
||||
export type { FormularioState, FormularioAction, RespuestaFormulario } from './formulario-reducer';
|
||||
Reference in New Issue
Block a user