You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

16 KiB

Plan: Conectar Convocatorias Vigentes con la API de Procesos de Admisión

Contexto

La sección "Convocatorias Vigentes" en la página pública (ConvocatoriasSection.vue) mostraba datos hardcodeados. El backend ya tenía todo listo (modelo ProcesoAdmision, controlador, endpoints CRUD). Solo faltaba conectar el frontend público con la API para que cuando un admin cree un proceso de admisión desde el panel, aparezca dinámicamente en la web pública.

Problemas que se resolvieron

  • ConvocatoriasSection.vue tenía cards estáticas ("Admisión Ordinaria 2026-I", "CEPREUNA", "Extraordinario")
  • Los botones de Requisitos/Pagos/Vacantes/Cronograma emitían eventos pero showModal() en WebPage.vue solo hacía console.log
  • No existía endpoint público (sin auth) para consultar procesos publicados
  • ProcessSection.vue tenía fechas y steps hardcodeados
  • El formulario admin no permitía borrar campos nullable (subtítulo, fechas, etc.)
  • Constraint único incorrecto en proceso_admision_detalles impedía crear más de un detalle por proceso

Implementación completada

1. Backend: Endpoint público (sin auth)

Archivo: back/routes/api.php

  • Se agregó ruta GET /procesos-admision/publicados fuera del middleware auth:sanctum
  • Esta ruta devuelve solo procesos con publicado=true, incluyendo sus detalles
// Ruta pública (sin auth) - procesos publicados para la web
Route::get('/procesos-admision/publicados', [ProcesoAdmisionController::class, 'publicados']);

Archivo: back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php

  • Se agregó método publicados() que retorna procesos publicados con sus detalles, ordenados por fecha
public function publicados()
{
    $procesos = ProcesoAdmision::where('publicado', true)
        ->with(['detalles' => fn($d) => $d->orderBy('id', 'asc')])
        ->orderByDesc('id')
        ->get();

    return response()->json($procesos);
}

2. Frontend: Store - action pública

Archivo: front/src/store/procesosAdmisionStore.js

  • Se agregó estado procesosPublicados: []
  • Se agregó action fetchProcesosPublicados() que usa axios directo (sin token de auth) para llamar al endpoint público
import axios from 'axios'

// En state:
procesosPublicados: [],

// Nueva action:
async fetchProcesosPublicados() {
  this.loading = true
  this.error = null
  try {
    const baseURL = import.meta.env.VITE_API_URL
    const { data } = await axios.get(`${baseURL}/procesos-admision/publicados`)
    this.procesosPublicados = Array.isArray(data) ? data : []
    return true
  } catch (err) {
    this._setError(err)
    return false
  } finally {
    this.loading = false
  }
},

Nota importante: Se usa axios directo en lugar de la instancia api porque esta última inyecta automáticamente el token Bearer. Al ser un endpoint público, no necesita autenticación.


3. Frontend: ConvocatoriasSection dinámico

Archivo: front/src/components/WebPageSections/ConvocatoriasSection.vue

Cambios principales:

  • Recibe procesos como prop (array de procesos desde la API)
  • El primer proceso se renderiza como card principal
  • Los demás se renderizan como cards secundarias
  • Mapeo de estado del proceso al tag visual:
const estadoMap = {
  publicado:  { label: 'Abierto',       color: 'success' },
  en_proceso: { label: 'En Proceso',    color: 'processing' },
  nuevo:      { label: 'PRÓXIMAMENTE',  color: 'orange' },
  finalizado: { label: 'FINALIZADO',    color: 'default' },
  cancelado:  { label: 'CANCELADO',     color: 'red' },
}
  • Las fechas de inscripción se muestran dinámicamente desde fecha_inicio_inscripcion / fecha_fin_inscripcion
  • La imagen usa imagen_url del proceso (o fallback a /images/extra.jpg)
  • Botones Requisitos/Pagos/Vacantes/Cronograma emiten evento con { procesoId, tipo }
  • Botón "Iniciar Preinscripción" abre link_preinscripcion en nueva pestaña directamente
  • Estado vacío: si no hay procesos publicados, muestra mensaje "No hay convocatorias vigentes"

4. Frontend: WebPage.vue conectado con data y modal

Archivo: front/src/components/WebPage.vue

Cambios principales:

  • Importa el store y llama fetchProcesosPublicados() en onMounted
  • Pasa los procesos como prop a ConvocatoriasSection
  • Pasa el proceso principal como prop a ProcessSection
const procesoStore = useProcesoAdmisionStore()
const procesosPublicados = computed(() => procesoStore.procesosPublicados)
const procesoPrincipal = computed(() => procesoStore.procesosPublicados[0] || null)

onMounted(() => {
  procesoStore.fetchProcesosPublicados()
})
<ProcessSection :proceso="procesoPrincipal" />

<ConvocatoriasSection
  :procesos="procesosPublicados"
  @show-modal="showModal"
/>
  • Implementa showModal({ procesoId, tipo }):
    • Busca el proceso por ID en procesosPublicados
    • Busca el detalle cuyo tipo coincida (requisitos, pagos, vacantes, cronograma)
    • Abre un a-modal con: imagen + título + descripción + lista del detalle
    • Si no encuentra detalle, muestra <a-empty> con mensaje informativo
const showModal = ({ procesoId, tipo }) => {
  const proceso = procesosPublicados.value.find(p => p.id === procesoId)
  if (!proceso) return

  const detalle = proceso.detalles?.find(d => d.tipo === tipo)

  if (detalle) {
    detalleModal.value = {
      titulo: detalle.titulo_detalle || tipoLabels[tipo] || tipo,
      descripcion: detalle.descripcion || '',
      imagen_url: detalle.imagen_url || null,
      imagen_url_2: detalle.imagen_url_2 || null,
      listas: detalle.listas || [],
    }
  } else {
    detalleModal.value = {
      titulo: `${tipoLabels[tipo] || tipo} - ${proceso.titulo}`,
      descripcion: '',
      imagen_url: null,
      imagen_url_2: null,
      listas: [],
    }
  }

  detalleModalVisible.value = true
}

5. Frontend: ProcessSection dinámico (timeline de fechas)

Archivo: front/src/components/WebPageSections/ProcessSection.vue

Antes tenía steps hardcodeados ("Preinscripción Virtual: 20 Oct - 30 Nov", etc.). Ahora:

  • Recibe proceso como prop (el proceso principal/más reciente publicado)
  • Genera los steps dinámicamente solo para las fechas que existan en el proceso:
const steps = computed(() => {
  const p = props.proceso
  if (!p) return []
  const list = []

  const preinscripcion = formatRango(p.fecha_inicio_preinscripcion, p.fecha_fin_preinscripcion)
  if (preinscripcion) list.push({ title: 'Preinscripción Virtual', description: preinscripcion })

  const inscripcion = formatRango(p.fecha_inicio_inscripcion, p.fecha_fin_inscripcion)
  if (inscripcion) list.push({ title: 'Inscripción Presencial', description: inscripcion })

  const examen = formatRango(p.fecha_examen1, p.fecha_examen2)
  if (examen) list.push({ title: 'Examen', description: examen })

  const resultados = formatFecha(p.fecha_resultados)
  if (resultados) list.push({ title: 'Resultados', description: resultados })

  const biometrico = formatRango(p.fecha_inicio_biometrico, p.fecha_fin_biometrico)
  if (biometrico) list.push({ title: 'Control Biométrico', description: biometrico })

  return list
})
  • El currentStep se calcula automáticamente comparando new Date() con las fechas del proceso
  • Muestra título y subtítulo del proceso dinámicamente
  • Si no hay proceso publicado, la sección no se muestra (v-if="proceso")
  • Si un paso no tiene fechas, simplemente no aparece en el timeline

Mapeo de campos DB → Steps:

Campo en DB Step
fecha_inicio_preinscripcion / fecha_fin_preinscripcion Preinscripción Virtual
fecha_inicio_inscripcion / fecha_fin_inscripcion Inscripción Presencial
fecha_examen1 / fecha_examen2 Examen
fecha_resultados Resultados
fecha_inicio_biometrico / fecha_fin_biometrico Control Biométrico

6. Fix: Unique constraint en detalles

Problema: La tabla proceso_admision_detalles tenía un unique constraint uq_proceso_modalidad_tipo solo sobre proceso_admision_id, impidiendo crear más de un detalle por proceso.

Solución: Migración 2026_02_15_051618_fix_unique_constraint_proceso_admision_detalles.php

// Eliminar el unique incorrecto (solo proceso_admision_id)
$table->dropUnique('uq_proceso_modalidad_tipo');

// Crear el unique correcto: un detalle por tipo por proceso
$table->unique(['proceso_admision_id', 'tipo'], 'uq_proceso_tipo');

Ahora permite un detalle por cada tipo (requisitos, pagos, vacantes, cronograma) por proceso.


7. Fix: Campos nullable no se borraban al editar

Problema: Cuando el admin borraba un campo (subtítulo, fechas, links) en el formulario de edición, el valor viejo persistía en la DB.

Causa raíz (doble):

  1. Frontend (ProcesosAdmisionList.vuebuildFormData()): Los campos usaban || null que convertía strings vacíos a null, y luego el forEach filtraba null. Resultado: campos vacíos nunca se enviaban al backend.

  2. Backend (ProcesoAdmisionController@update): Laravel validate() con regla sometimes + nullable no siempre incluye campos con valor null en el array $data validado. Resultado: $proceso->update($data) no recibía el campo y el valor viejo quedaba.

Solución Frontend (front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue):

// ANTES (no funcionaba):
subtitulo: formState.subtitulo || null,  // "" → null → filtrado → no se envía

// DESPUÉS:
subtitulo: formState.subtitulo ?? '',    // "" → "" → se envía

// ANTES:
if (v === null || v === undefined || v === '') return  // filtraba todo

// DESPUÉS:
if (v === undefined) return  // solo filtra undefined

Solución Backend (back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php):

Se agregó un bloque en update() que fuerza la inclusión de campos nullable cuando vienen en el request pero no quedaron en $data después de la validación:

$nullableFields = [
    'subtitulo','descripcion','tipo_proceso','modalidad',
    'fecha_publicacion',
    'fecha_inicio_preinscripcion','fecha_fin_preinscripcion',
    'fecha_inicio_inscripcion','fecha_fin_inscripcion',
    'fecha_examen1','fecha_examen2','fecha_resultados',
    'fecha_inicio_biometrico','fecha_fin_biometrico',
    'link_preinscripcion','link_inscripcion','link_resultados','link_reglamento',
];

foreach ($nullableFields as $field) {
    if ($request->has($field) && !array_key_exists($field, $data)) {
        $data[$field] = null;
    }
}

Archivos modificados (resumen)

# Archivo Cambio
1 back/routes/api.php Nueva ruta pública GET /procesos-admision/publicados
2 back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php Nuevo método publicados() + fix nullable en update()
3 front/src/store/procesosAdmisionStore.js Nuevo estado procesosPublicados + action fetchProcesosPublicados()
4 front/src/components/WebPageSections/ConvocatoriasSection.vue Componente dinámico con props en lugar de datos hardcodeados
5 front/src/components/WebPageSections/ProcessSection.vue Timeline dinámico con fechas del proceso principal
6 front/src/components/WebPage.vue Conexión con store, props a ProcessSection y ConvocatoriasSection, modal de detalles
7 front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue Fix buildFormData() para enviar campos vacíos
8 back/database/migrations/2026_02_15_051618_fix_unique_constraint_proceso_admision_detalles.php Fix unique constraint (proceso_admision_id, tipo)

Flujo completo

Admin Panel                          Backend                         Web Pública
─────────────                        ───────                         ───────────
Crear proceso con
publicado=true          ──────►      Se guarda en DB
+ fechas + detalles                  (procesos_admision +
                                      proceso_admision_detalles)

                                     GET /procesos-admision/publicados
                                     (sin auth)               ◄──── fetchProcesosPublicados()
                                                                     en onMounted()

                                     Retorna JSON con         ────►  procesosPublicados (store)
                                     procesos + detalles              │
                                                                      ├── ProcessSection
                                                                      │   (timeline con fechas)
                                                                      │
                                                                      ├── ConvocatoriasSection
                                                                      │   (cards dinámicas)
                                                                      │
                                                                      └── showModal()
                                                                          (abre modal con detalle)

Verificación

  1. Crear un proceso de admisión desde el panel admin con publicado=true, agregar fechas y detalles de tipo requisitos, pagos, vacantes, cronograma
  2. Ir a la página pública y verificar que aparece en "Convocatorias Vigentes"
  3. Verificar que el timeline de "Proceso de Admisión" muestra las fechas correctas y el step actual
  4. Verificar que los botones de Requisitos/Pagos/Vacantes/Cronograma muestran la info correcta en el modal
  5. Verificar que si el proceso tiene publicado=false, no aparece en la web pública
  6. Verificar que "Iniciar Preinscripción" abre el link_preinscripcion en nueva pestaña
  7. Verificar que se pueden crear múltiples detalles por proceso (uno por tipo)
  8. Verificar que al editar y borrar un campo nullable (subtítulo, fechas), se limpia correctamente en la DB

Modelos de referencia

ProcesoAdmision (campos clave)

  • titulo, subtitulo, descripcion, slug
  • tipo_proceso, modalidad
  • publicado (boolean), estado (nuevo/publicado/en_proceso/finalizado/cancelado)
  • fecha_inicio_preinscripcion, fecha_fin_preinscripcion
  • fecha_inicio_inscripcion, fecha_fin_inscripcion
  • fecha_examen1, fecha_examen2
  • fecha_resultados
  • fecha_inicio_biometrico, fecha_fin_biometrico
  • imagen_path → accessor imagen_url
  • link_preinscripcion, link_inscripcion, link_resultados
  • Relación: detalles (hasMany ProcesoAdmisionDetalle)

ProcesoAdmisionDetalle (campos clave)

  • proceso_admision_id
  • tipo (requisitos/pagos/vacantes/cronograma)
  • titulo_detalle, descripcion
  • listas (JSON array), meta (JSON object)
  • imagen_path → accessor imagen_url
  • imagen_path_2 → accessor imagen_url_2
  • Unique constraint: (proceso_admision_id, tipo) — un detalle por tipo por proceso

8. Fix menor: Segunda imagen del modal no estaba centrada

Archivo: front/src/components/WebPage.vue

El div de la segunda imagen (imagen_url_2) no tenía la clase detalle-modal-imagen, por lo que aparecía alineada a la izquierda. Se agregó la clase para que ambas imágenes se muestren centradas con el mismo estilo.


Lecciones aprendidas

  1. Laravel 12 no usa Kernel.php — la configuración de middleware está en bootstrap/app.php. El Kernel.php que existía era legacy y no se ejecutaba.

  2. || null vs ?? '' en JSformState.value || null convierte "" (string vacío) a null. Usar ?? '' solo convierte null/undefined a "", preservando strings vacíos.

  3. Laravel sometimes + nullable en validate() — Cuando un campo llega como null (después de ConvertEmptyStringsToNull), la validación sometimes + nullable no siempre lo incluye en $data. Se necesita un manejo explícito para forzar null en campos que el usuario quiere borrar.

  4. Unique constraints — Verificar siempre que columnas incluye un unique index. El nombre uq_proceso_modalidad_tipo sugería que era compuesto, pero solo era sobre proceso_admision_id.