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.vuetenía cards estáticas ("Admisión Ordinaria 2026-I", "CEPREUNA", "Extraordinario")- Los botones de Requisitos/Pagos/Vacantes/Cronograma emitían eventos pero
showModal()enWebPage.vuesolo hacíaconsole.log - No existía endpoint público (sin auth) para consultar procesos publicados
ProcessSection.vuetenía fechas y steps hardcodeados- El formulario admin no permitía borrar campos nullable (subtítulo, fechas, etc.)
- Constraint único incorrecto en
proceso_admision_detallesimpedí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/publicadosfuera del middlewareauth: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 usaaxiosdirecto (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
procesoscomo 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
estadodel 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_urldel proceso (o fallback a/images/extra.jpg) - Botones Requisitos/Pagos/Vacantes/Cronograma emiten evento con
{ procesoId, tipo } - Botón "Iniciar Preinscripción" abre
link_preinscripcionen 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()enonMounted - 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
tipocoincida (requisitos, pagos, vacantes, cronograma) - Abre un
a-modalcon: imagen + título + descripción + lista del detalle - Si no encuentra detalle, muestra
<a-empty>con mensaje informativo
- Busca el proceso por ID en
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
procesocomo 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):
-
Frontend (
ProcesosAdmisionList.vue→buildFormData()): Los campos usaban|| nullque convertía strings vacíos anull, y luego el forEach filtrabanull. Resultado: campos vacíos nunca se enviaban al backend. -
Backend (
ProcesoAdmisionController@update): Laravelvalidate()con reglasometimes+nullableno siempre incluye campos con valornullen el array$datavalidado. 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
- Crear un proceso de admisión desde el panel admin con
publicado=true, agregar fechas y detalles de tiporequisitos,pagos,vacantes,cronograma - Ir a la página pública y verificar que aparece en "Convocatorias Vigentes"
- Verificar que el timeline de "Proceso de Admisión" muestra las fechas correctas y el step actual
- Verificar que los botones de Requisitos/Pagos/Vacantes/Cronograma muestran la info correcta en el modal
- Verificar que si el proceso tiene
publicado=false, no aparece en la web pública - Verificar que "Iniciar Preinscripción" abre el
link_preinscripcionen nueva pestaña - Verificar que se pueden crear múltiples detalles por proceso (uno por tipo)
- 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,slugtipo_proceso,modalidadpublicado(boolean),estado(nuevo/publicado/en_proceso/finalizado/cancelado)fecha_inicio_preinscripcion,fecha_fin_preinscripcionfecha_inicio_inscripcion,fecha_fin_inscripcionfecha_examen1,fecha_examen2fecha_resultadosfecha_inicio_biometrico,fecha_fin_biometricoimagen_path→ accessorimagen_urllink_preinscripcion,link_inscripcion,link_resultados- Relación:
detalles(hasMany ProcesoAdmisionDetalle)
ProcesoAdmisionDetalle (campos clave)
proceso_admision_idtipo(requisitos/pagos/vacantes/cronograma)titulo_detalle,descripcionlistas(JSON array),meta(JSON object)imagen_path→ accessorimagen_urlimagen_path_2→ accessorimagen_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
-
Laravel 12 no usa
Kernel.php— la configuración de middleware está enbootstrap/app.php. ElKernel.phpque existía era legacy y no se ejecutaba. -
|| nullvs?? ''en JS —formState.value || nullconvierte""(string vacío) anull. Usar?? ''solo conviertenull/undefineda"", preservando strings vacíos. -
Laravel
sometimes+nullableen validate() — Cuando un campo llega comonull(después deConvertEmptyStringsToNull), la validaciónsometimes+nullableno siempre lo incluye en$data. Se necesita un manejo explícito para forzarnullen campos que el usuario quiere borrar. -
Unique constraints — Verificar siempre que columnas incluye un unique index. El nombre
uq_proceso_modalidad_tiposugería que era compuesto, pero solo era sobreproceso_admision_id.