@@ -16,11 +16,12 @@
:responsive="false"
class="modern-steps"
>
-
@@ -33,9 +34,14 @@
\ No newline at end of file
+
diff --git a/front/src/store/procesosAdmisionStore.js b/front/src/store/procesosAdmisionStore.js
index 0cc9980..88987ae 100644
--- a/front/src/store/procesosAdmisionStore.js
+++ b/front/src/store/procesosAdmisionStore.js
@@ -1,4 +1,5 @@
import { defineStore } from 'pinia'
+import axios from 'axios'
import api from '../axios'
export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
@@ -7,6 +8,7 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
error: null,
procesos: [],
+ procesosPublicados: [],
pagination: {
current_page: 1,
per_page: 15,
@@ -29,6 +31,22 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
},
+ 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
+ }
+ },
+
async fetchProcesos(params = {}) {
this.loading = true
this.error = null
diff --git a/front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue b/front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue
index f68495c..2eee84e 100644
--- a/front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue
+++ b/front/src/views/administrador/procesoadmision/ProcesosAdmisionList.vue
@@ -611,35 +611,35 @@ function buildFormData() {
const fields = {
titulo: formState.titulo,
- subtitulo: formState.subtitulo || null,
- descripcion: formState.descripcion || null,
+ subtitulo: formState.subtitulo ?? '',
+ descripcion: formState.descripcion ?? '',
slug: formState.slug,
- tipo_proceso: formState.tipo_proceso || null,
- modalidad: formState.modalidad || null,
+ tipo_proceso: formState.tipo_proceso ?? '',
+ modalidad: formState.modalidad ?? '',
publicado: formState.publicado ? 1 : 0,
- fecha_publicacion: formState.fecha_publicacion || null,
-
- fecha_inicio_preinscripcion: formState.fecha_inicio_preinscripcion || null,
- fecha_fin_preinscripcion: formState.fecha_fin_preinscripcion || null,
- fecha_inicio_inscripcion: formState.fecha_inicio_inscripcion || null,
- fecha_fin_inscripcion: formState.fecha_fin_inscripcion || null,
- fecha_examen1: formState.fecha_examen1 || null,
- fecha_examen2: formState.fecha_examen2 || null,
- fecha_resultados: formState.fecha_resultados || null,
- fecha_inicio_biometrico: formState.fecha_inicio_biometrico || null,
- fecha_fin_biometrico: formState.fecha_fin_biometrico || null,
-
- link_preinscripcion: formState.link_preinscripcion || null,
- link_inscripcion: formState.link_inscripcion || null,
- link_resultados: formState.link_resultados || null,
- link_reglamento: formState.link_reglamento || null,
+ fecha_publicacion: formState.fecha_publicacion ?? '',
+
+ fecha_inicio_preinscripcion: formState.fecha_inicio_preinscripcion ?? '',
+ fecha_fin_preinscripcion: formState.fecha_fin_preinscripcion ?? '',
+ fecha_inicio_inscripcion: formState.fecha_inicio_inscripcion ?? '',
+ fecha_fin_inscripcion: formState.fecha_fin_inscripcion ?? '',
+ fecha_examen1: formState.fecha_examen1 ?? '',
+ fecha_examen2: formState.fecha_examen2 ?? '',
+ fecha_resultados: formState.fecha_resultados ?? '',
+ fecha_inicio_biometrico: formState.fecha_inicio_biometrico ?? '',
+ fecha_fin_biometrico: formState.fecha_fin_biometrico ?? '',
+
+ link_preinscripcion: formState.link_preinscripcion ?? '',
+ link_inscripcion: formState.link_inscripcion ?? '',
+ link_resultados: formState.link_resultados ?? '',
+ link_reglamento: formState.link_reglamento ?? '',
estado: formState.estado
}
Object.entries(fields).forEach(([k, v]) => {
- if (v === null || v === undefined || v === '') return
+ if (v === undefined) return
fd.append(k, v)
})
diff --git a/install.md b/install.md
index 597faf8..cbbc14d 100644
--- a/install.md
+++ b/install.md
@@ -106,6 +106,31 @@ php artisan key:generate
php artisan storage:link
```
+### 4.5 Ejecutar migraciones y seeders
+
+```bash
+php artisan migrate
+php artisan db:seed --class=RoleSeeder
+```
+
+Esto crea las tablas de permisos (Spatie) y los roles: `usuario`, `administrador`, `superadmin`.
+
+### 4.6 Asignar rol a tu usuario
+
+Si ya tienes un usuario creado (por registro o por el dump SQL), asignarle un rol desde tinker:
+
+```bash
+php artisan tinker
+```
+
+```php
+$user = \App\Models\User::where('email', 'tu@email.com')->first();
+$user->assignRole('superadmin');
+exit
+```
+
+> **Importante:** Sin un rol asignado, el login devuelve 200 pero no redirige al dashboard.
+
---
## Paso 5: Configurar el Frontend (Vue 3)
diff --git a/one.md b/one.md
new file mode 100644
index 0000000..3fede61
--- /dev/null
+++ b/one.md
@@ -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
+
+
+
+```
+
+- 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`.