feat: add docker

main
parent ede7d7442c
commit 58e87e5a8d

390
one.md

@ -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
<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…
Cancel
Save