# 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 ```php // 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 ```php 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 ```js 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: ```js 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` ```js const procesoStore = useProcesoAdmisionStore() const procesosPublicados = computed(() => procesoStore.procesosPublicados) const procesoPrincipal = computed(() => procesoStore.procesosPublicados[0] || null) onMounted(() => { procesoStore.fetchProcesosPublicados() }) ``` ```html ``` - 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 `` con mensaje informativo ```js 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: ```js 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` ```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.vue` → `buildFormData()`): 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`): ```js // 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: ```php $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 JS** — `formState.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`.