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.

RF30 y RF31 · React + Spring + PostgreSQL

Requisitos funcionales del recorrido

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

Ruta protegida → familia autorizada → catálogo → criterios

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

TypeScript
element: <RutaConPermiso permiso="reportes.leer" titulo="Reportes" />,
children: [{ path: '/reportes', element: <PaginaReportes />, handle: { titulo: 'Reportes' } }],

PaginaReportes.tsx · líneas 57–60

TypeScript
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 : null

2. 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

TypeScript
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

TypeScript
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

GET /consulta → permisos → visibilidad del origen → filtros → orden

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

TypeScript
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)
}
HTTP · ejemplo
GET /api/reportes/incidencias/consulta?desde=2026-10-01&hasta=2026-10-31&estado=TODOS&idGrupo=18&page=0&size=25

2. 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

Java
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

Java
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

Java
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

Conjunto filtrado → métricas globales + filas de la página → tabla y detalle

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

Java
    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

Java
@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

TypeScript
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

TypeScript
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.' : null

Generar el CSV del conjunto filtrado

Criterios efectivos → GET /exportar → nueva consulta → serialización

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

TypeScript
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) }
}
HTTP · ejemplo
GET /api/reportes/incidencias/exportar?desde=2026-10-01&hasta=2026-10-31&estado=TODOS&idGrupo=18

2. 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

Java
@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

Java
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

Java
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

Cabeceras + CSV → validación → Blob → enlace temporal → descarga

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

Java
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

Java
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

TypeScript
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

TypeScript
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.