diff --git a/one.md b/one.md deleted file mode 100644 index 3fede61..0000000 --- a/one.md +++ /dev/null @@ -1,390 +0,0 @@ -# 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`.