SIENEP · del navegador a la base
Generación y exportación de reportes
Un funcionario quiere revisar las incidencias de un grupo durante octubre. En SIENEP elige la familia, filtra, recorre los resultados y descarga el CSV. Este ejemplo muestra qué hace React, qué comprueba la API y de dónde salen las filas y las métricas.
Requisitos funcionales del recorrido
RF30Generación de ReportesRF31Exportación de Reportes
Fuente oficial Requerimientos de Programación de UTEC, 2026 · p. 6
Podés recorrer las cinco etapas, copiar los fragmentos e imprimir la guía. Funciona sin internet.
Abrir Reportes y elegir qué consultar
1. El router abre la página de Reportes
Un funcionario entra a Reportes desde el menú. React Router resuelve /reportes y la ruta comprueba reportes.leer. En la pantalla aparecen Instancias, Incidencias o Recordatorios según los permisos que tenga sobre cada familia.
rutas.tsx · líneas 257–258
element: <RutaConPermiso permiso="reportes.leer" titulo="Reportes" />,
children: [{ path: '/reportes', element: <PaginaReportes />, handle: { titulo: 'Reportes' } }],PaginaReportes.tsx · líneas 57–60
const familias = FAMILIAS.filter((valor) => tiene('reportes.leer', `${valor}.leer`))
const autorizado = !!familia && tiene('reportes.leer', `${familia}.leer`)
const reservadoAutorizado = !resultado?.confidencial || tiene('incidencias.confidencial')
const visible = autorizado && reservadoAutorizado ? resultado : null2. La familia determina las opciones del formulario
Al elegir Incidencias, el frontend pide los criterios de esa familia. El servidor arma el catálogo de grupos y estudiantes a partir de registros visibles para ese funcionario. El formulario usa esos datos para ofrecer ITR, carrera, grupo y estudiante, además del período y el estado.
reportesApi.ts · líneas 6–10
export async function obtenerCriterios(familia: FamiliaReporte, senal: AbortSignal) {
return validarCatalogos(await clienteHttp.get<unknown>(`/api/reportes/${familia}/criterios`, {
parametros: {}, senal, esperar: 'json',
}))
}3. Generar captura los filtros elegidos
El funcionario selecciona el grupo 18 y un período del 1 al 31 de octubre de 2026. Al pulsar Generar reporte, la página valida el formulario, enfoca el primer campo con error si lo hay y arranca la consulta en la primera página. Grupo, período y cantidades de este recorrido son ficticios.
PaginaReportes.tsx · líneas 256–266
function generar() {
if (!familia || !catalogos || !autorizado || ocupado.current || exportacion.estaOcupada()) return
const problemas = validarEdicion(edicion, catalogos)
setErrores(problemas)
const primero = Object.keys(problemas)[0]
if (primero) {
document.getElementById(`reporte-${primero}`)?.focus()
return
}
void ejecutar({ familia, catalogos, criterios: capturarCriterios(edicion, 0, tamano) })
}La API resuelve qué filas puede ver el funcionario
1. React envía la consulta al backend
El cliente HTTP envía los filtros por query string y agrega la credencial de la sesión. La respuesta esperada es JSON. Al llegar, el frontend valida su estructura y el contexto de la consulta antes de usarla.
reportesApi.ts · líneas 12–16
export async function consultarReporte(familia: FamiliaReporte, criterios: CriteriosReporte, confidencial: boolean, senal: AbortSignal) {
return validarResultado(await clienteHttp.get<unknown>(`/api/reportes/${familia}/consulta`, {
parametros: parametrosCriterios(criterios), senal, esperar: 'json',
}), familia, criterios, confidencial)
}GET /api/reportes/incidencias/consulta?desde=2026-10-01&hasta=2026-10-31&estado=TODOS&idGrupo=18&page=0&size=252. El servidor verifica permisos y alcance
Para incidencias, Spring exige reportes.leer y incidencias.leer. El servicio aplica la intersección de ambos alcances y pide los registros visibles al módulo de origen. Un ID enviado a mano en la URL pasa por las mismas comprobaciones.
ReporteConsultaService.java · líneas 77–85
public Dataset dataset(String familia,ReporteFiltrosDto filtros,Authentication auth) {
validarPermisos(familia,auth);filtros.validar();
boolean admin=tiene(auth,"ROLE_Administrador");Integer actor=(Integer)auth.getPrincipal();
Set<Integer> permitidos=scope.getGruposPermitidos(actor,admin,familia+".leer","reportes.leer");
List<Grupo> disponibles=permitidos==null ? grupos.findAll() : grupos.findAllById(permitidos);
validarDimensiones(filtros,disponibles,admin);
List<Registro> registros=registros(familia,actor,admin);
Map<Integer,List<Grupo>> porResponsable=new HashMap<>();
boolean confidencial=tiene(auth,"incidencias.confidencial");En un reporte de incidencias, las observaciones reservadas se proyectan únicamente para quien tiene incidencias.confidencial. El campo queda ausente del JSON cuando no corresponde entregarlo.
3. Después se aplican los filtros y el orden
Las fechas se interpretan en America/Montevideo, incluyendo ambos días. Si hay varios filtros organizacionales, un mismo grupo del contexto debe cumplir la combinación de ITR, carrera y grupo. Se ordena por fecha descendente y, para empates, por ID descendente.
PeriodoConsultaDto.java · líneas 30–35
public boolean coincide(OffsetDateTime fecha, Boolean activo) {
ZoneId zona=ZoneId.of("America/Montevideo");
return (desde==null || !fecha.toInstant().isBefore(desde.atStartOfDay(zona).toInstant()))
&& (hasta==null || fecha.toInstant().isBefore(hasta.plusDays(1).atStartOfDay(zona).toInstant()))
&& (estado==Estado.TODOS || (estado==Estado.ACTIVOS)==Boolean.TRUE.equals(activo));
}ReporteConsultaService.java · líneas 86–90
List<Registro> filtrados=registros.stream().filter(r->filtros.coincide(r.fecha(),r.activo()))
.filter(r->filtros.getIdEstudiante()==null || (r.alumno()!=null && filtros.getIdEstudiante().equals(r.alumno().getIdUsuario())))
.filter(r->!hayDimensiones(filtros) || contexto(r,disponibles,porResponsable).stream().anyMatch(g->coincide(g,filtros)))
.sorted(Comparator.comparing((Registro r)->r.fecha().toInstant()).reversed().thenComparing(Registro::id,Comparator.reverseOrder()))
.toList();Mostrar las filas y el resumen
1. La respuesta separa el total de las filas visibles
Si coinciden 37 incidencias, la primera página muestra 25 y la segunda 12. El total y las métricas consideran las 37. Para recordatorios, la unidad de las métricas son las series, incluso si una de ellas tiene varias ocurrencias.
ReporteConsultaService.java · líneas 96–101
List<Object> items=filtrados.stream().map(r->proyectar(r,confidencial,contexto(r,disponibles,porResponsable))).toList();
long activos=filtrados.stream().filter(r->Boolean.TRUE.equals(r.activo())).count();
Map<String,Long> recurrencias=new TreeMap<>();
if (familia.equals("recordatorios")) filtrados.forEach(r->recurrencias.merge(r.recordatorio().getRecurrencia()==null ? "NINGUNA" : r.recordatorio().getRecurrencia().name(),1L,Long::sum));
return new Dataset(items,new ReporteMetricasDto(familia.equals("recordatorios") ? "series" : familia,items.size(),activos,items.size()-activos,Map.copyOf(recurrencias)),confidencial);
}ReporteConsultaService.java · líneas 69–74
@Transactional(readOnly=true)
public ReporteConsultaResponseDto consultar(String familia,ReporteFiltrosDto filtros,Authentication auth) {
Dataset datos=dataset(familia,filtros,auth);
return new ReporteConsultaResponseDto(familia,filtros.pagina(datos.items()),datos.items().size(),filtros.getPage(),filtros.getSize(),
datos.metricas(),filtros,OffsetDateTime.now(ZoneId.of("America/Montevideo")));
}2. La página siguiente conserva los criterios efectivos
La respuesta incluye las filas, total, página, tamaño, métricas, criterios efectivos y hora de generación. React dibuja el resumen, la tabla y su paginación. Para cambiar de página usa los filtros de la respuesta y solo ajusta página y tamaño.
PaginaReportes.tsx · líneas 268–273
function paginar(page: number, size: number) {
if (!visible || desactualizado || ocupado.current || exportacion.estaOcupada()) return
setTamano(size)
void ejecutar({ familia: visible.datos.familia, catalogos: visible.catalogos,
criterios: Object.freeze({ ...visible.datos.criteriosEfectivos, page, size }) }, true)
}3. Editar el formulario deja desactualizado el resultado
Si el funcionario cambia el grupo o las fechas, la página avisa que el resultado está desactualizado. Generar de nuevo habilita la paginación y la exportación con los filtros nuevos.
PaginaReportes.tsx · líneas 62–68
const desactualizado = !!visible && (requiereGenerar || !mismosFiltros(capturarCriterios(edicion), visible.datos.criteriosEfectivos))
const motivoExportacion = generando ? 'Esperá a que termine la generación o la consulta de página.' :
estado === 'error' ? 'No se pudieron verificar tus permisos. Reintentá la consulta de capacidades.' :
estado !== 'disponible' ? 'Esperá a que se verifiquen tus permisos para exportar.' :
!autorizado ? 'Seleccioná una familia autorizada y generá el reporte para exportar.' :
desactualizado ? 'Generá nuevamente para exportar con los criterios actuales' :
!visible ? 'Generá el reporte con los criterios actuales para habilitar la exportación.' : nullGenerar el CSV del conjunto filtrado
1. Exportar reutiliza los filtros aplicados
El botón de exportación trabaja con los criterios efectivos del reporte vigente. Conserva estado, fechas e IDs de los filtros. El parámetro page y el tamaño de página se usan en la consulta de pantalla. El endpoint de exportación admite únicamente los filtros.
criteriosExportacion.ts · líneas 5–17
export function criteriosExportacion(criterios: CriteriosReporte): CriteriosExportacion {
const filtros: CriteriosExportacion = { estado: criterios.estado }
if (criterios.desde) filtros.desde = criterios.desde
if (criterios.hasta) filtros.hasta = criterios.hasta
for (const campo of CAMPOS_ID) {
if (criterios[campo] !== undefined) filtros[campo] = criterios[campo]
}
return Object.freeze(filtros)
}
export function parametrosExportacion(criterios: CriteriosReporte): Record<string, ValorParametro> {
return { ...criteriosExportacion(criterios) }
}GET /api/reportes/incidencias/exportar?desde=2026-10-01&hasta=2026-10-31&estado=TODOS&idGrupo=182. El servidor vuelve a consultar el conjunto
La API comprueba otra vez autorización y alcance. Reconstruye el conjunto en una transacción de lectura REPEATABLE_READ. Si los datos siguen iguales, descarga las 37 incidencias aunque se esté viendo la segunda página. Si hubo cambios entre ambas solicitudes, el CSV refleja la consulta nueva. Por encima de 10.000 filas responde 413 con un error JSON y se puede acotar el filtro.
ReporteConsultaService.java · líneas 32–40
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public Exportacion exportarCsv(String familia,ReporteFiltrosDto filtros,Authentication auth) {
Dataset datos=dataset(familia,filtros,auth);
if(datos.items().size()>LIMITE_EXPORTACION) throw new ReporteVolumenException();
OffsetDateTime generado=OffsetDateTime.now(ZoneId.of("America/Montevideo"));
String base=switch(familia) {
case "instancias" -> ReporteInstanciaDto.csvHeader();
case "incidencias" -> ReporteIncidenciaDto.csvHeader(datos.confidencial());
case "recordatorios" -> ReporteRecordatorioDto.csvHeader();3. Cada fila se convierte en texto seguro
La familia define las columnas. A ellas se suma el contexto del estudiante, sus grupos, carreras e ITR visibles. En la familia Incidencias, incidencias.confidencial habilita la columna de observaciones. El CSV sale en UTF-8 sin BOM y con finales de línea CRLF. El serializador duplica las comillas internas y antepone un apóstrofo al texto que empieza como fórmula, incluso si hay espacios o caracteres invisibles antes del signo.
ReporteIncidenciaDto.java · líneas 55–62
public String toCsvRow(boolean confidencial) {
String row=toCsvRow();
return confidencial ? row+","+CsvSerializer.row(observacionesConfidenciales) : row;
}
public static String csvHeader(boolean confidencial) {
return csvHeader()+(confidencial ? ",observacionesConfidenciales" : "");
}CsvSerializer.java · líneas 13–31
private static String cell(Object value) {
if (value == null) return "";
if (!(value instanceof String text)) return value.toString();
String exported = formulaPrefix(text) ? "'" + text : text;
return "\"" + exported.replace("\"", "\"\"") + "\"";
}
private static boolean formulaPrefix(String text) {
for (int index = 0; index < text.length();) {
int code = text.codePointAt(index);
if (Character.isWhitespace(code) || Character.isSpaceChar(code)
|| Character.isISOControl(code) || Character.getType(code) == Character.FORMAT) {
index += Character.charCount(code);
continue;
}
return code == '=' || code == '+' || code == '-' || code == '@';
}
return false;
}Recibir el archivo en el navegador
1. Spring devuelve los bytes y los datos del archivo
La respuesta indica el tipo text/csv;charset=UTF-8, el nombre de descarga y dos cabeceras con el total de filas y la hora de generación. CORS expone esas cabeceras y Content-Disposition para que las pueda leer el frontend.
ReporteController.java · líneas 101–106
var archivo=consultaService.exportarCsv(familia,filtros.consulta(),auth);
return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8"))
.header("Content-Disposition","attachment; filename=\""+archivo.nombreArchivo()+"\"")
.header("X-Reporte-Generado-En",archivo.generadoEn().toString())
.header("X-Reporte-Total",String.valueOf(archivo.total()))
.body(archivo.contenido().getBytes(StandardCharsets.UTF_8));CorsConfig.java · líneas 35–35
configuration.setExposedHeaders(List.of("Content-Disposition","X-Reporte-Generado-En","X-Reporte-Total"));2. React revisa que la respuesta siga correspondiendo a esa sesión
Al exportar, el frontend refresca permisos. Si se perdió acceso, retira el resultado. Antes de iniciar la descarga verifica que sigan vigentes la sesión, los criterios y la solicitud. También valida el tipo CSV, el total y la fecha, y usa un nombre alternativo seguro si el del servidor resulta inválido.
useExportacionReporte.ts · líneas 77–95
setEstado({ fase: 'verificando' })
try {
const permisos = await capturada.refrescar()
if (!vigente()) return
if (!permisos) {
setEstado({ fase: 'error', mensaje: 'No se pudieron verificar tus permisos. Reintentá la consulta de capacidades antes de exportar.' })
return
}
if (!permisos.includes('reportes.leer') || !permisos.includes(`${familia}.leer`) ||
(capturada.confidencial && !permisos.includes('incidencias.confidencial'))) {
setEstado({ fase: 'error', mensaje: 'Tus permisos cambiaron. Generá nuevamente el reporte antes de exportar.' })
capturada.alRetirarDatos(403)
return
}
setEstado({ fase: 'descargando' })
const archivo = await exportarReporte(familia, criterios, abortador.signal)
if (!vigente()) return
descargas.iniciar(archivo)
setEstado({ fase: 'iniciada', archivo: { total: archivo.total, generadoEn: archivo.generadoEn } })Un 400 o 413 pide volver a generar después de ajustar los filtros. Un 401 o 403 retira el resultado y detiene la descarga.
3. El navegador inicia la descarga con una URL temporal
El frontend crea una URL temporal para el Blob, la asigna a un enlace con download y activa ese enlace. Después retira el enlace y libera la URL. La pantalla informa la cantidad y la fecha que llegaron con el archivo.
descarga.ts · líneas 65–81
iniciar(archivo: ArchivoReporte) {
const url = URL.createObjectURL(archivo.contenido)
const enlace = document.createElement('a')
enlace.href = url
enlace.download = archivo.nombre
enlace.hidden = true
try {
document.body.append(enlace)
enlace.click()
pendientes.set(url, setTimeout(() => retirar(url), 1000))
} catch (error) {
retirar(url)
throw error
} finally {
enlace.remove()
}
},Para contarlo en la defensa
“El funcionario elige una familia y aplica filtros. El backend verifica el permiso de reportes, el permiso del módulo y el alcance de ambos. Devuelve una página de filas y las métricas de todo el resultado. Cuando se exporta, el backend vuelve a consultar con los mismos filtros y devuelve un CSV con todos los registros autorizados que coincidan en ese momento.”
Archivos revisados
Los fragmentos y números de línea corresponden a las copias de frontend y backend del paquete del 06/11/2026.
frontend/src/aplicacion/rutas.tsxfrontend/src/modulos/reportes/paginas/PaginaReportes.tsxfrontend/src/modulos/reportes/api/reportesApi.tsbackend/src/main/java/edu/utec/sienep/service/ReporteConsultaService.javabackend/src/main/java/edu/utec/sienep/dto/PeriodoConsultaDto.javafrontend/src/modulos/reportes/utilidades/criteriosExportacion.tsbackend/src/main/java/edu/utec/sienep/dto/ReporteIncidenciaDto.javabackend/src/main/java/edu/utec/sienep/util/CsvSerializer.javabackend/src/main/java/edu/utec/sienep/controller/ReporteController.javabackend/src/main/java/edu/utec/sienep/config/CorsConfig.javafrontend/src/modulos/reportes/utilidades/useExportacionReporte.tsfrontend/src/modulos/reportes/utilidades/descarga.ts