feat: Implementar conexión de convocatorias vigentes con API de admisión y mejoras en el modal de detalles
parent
1f5f3ae81e
commit
7311693ed2
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('proceso_admision_detalles', function (Blueprint $table) {
|
||||
// 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');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('proceso_admision_detalles', function (Blueprint $table) {
|
||||
$table->dropUnique('uq_proceso_tipo');
|
||||
$table->unique('proceso_admision_id', 'uq_proceso_modalidad_tipo');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,390 @@
|
||||
# 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
|
||||
<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
|
||||
|
||||
```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`.
|
||||
Loading…
Reference in New Issue