diff --git a/back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php b/back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php index 5967f36..ed2fd79 100644 --- a/back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php +++ b/back/app/Http/Controllers/Administracion/ProcesoAdmisionController.php @@ -152,6 +152,23 @@ class ProcesoAdmisionController extends Controller ]); + // Campos nullable: si vienen en el request (aunque sea vacíos/null), incluirlos en $data + $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; + } + } + if (array_key_exists('slug', $data) && empty($data['slug'])) { $data['slug'] = Str::slug($data['titulo'] ?? $proceso->titulo); } @@ -175,6 +192,16 @@ class ProcesoAdmisionController extends Controller } + public function publicados() + { + $procesos = ProcesoAdmision::where('publicado', true) + ->with(['detalles' => fn($d) => $d->orderBy('id', 'asc')]) + ->orderByDesc('id') + ->get(); + + return response()->json($procesos); + } + public function destroy($id) { $proceso = ProcesoAdmision::findOrFail($id); diff --git a/back/database/migrations/2026_02_15_051618_fix_unique_constraint_proceso_admision_detalles.php b/back/database/migrations/2026_02_15_051618_fix_unique_constraint_proceso_admision_detalles.php new file mode 100644 index 0000000..3034db7 --- /dev/null +++ b/back/database/migrations/2026_02_15_051618_fix_unique_constraint_proceso_admision_detalles.php @@ -0,0 +1,33 @@ +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'); + }); + } +}; diff --git a/back/routes/api.php b/back/routes/api.php index c62b744..2b75a57 100644 --- a/back/routes/api.php +++ b/back/routes/api.php @@ -161,6 +161,9 @@ Route::middleware(['auth:postulante'])->group(function () { }); +// Ruta pública (sin auth) - procesos publicados para la web +Route::get('/procesos-admision/publicados', [ProcesoAdmisionController::class, 'publicados']); + Route::middleware('auth:sanctum')->prefix('admin')->group(function () { // PROCESOS diff --git a/front/package-lock.json b/front/package-lock.json index 8c0f97d..7158e86 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -1188,6 +1188,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -1197,6 +1198,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -1342,6 +1344,7 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -1351,7 +1354,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -1364,6 +1366,7 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", "license": "ISC", + "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -1384,6 +1387,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", + "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -1395,7 +1399,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/combined-stream": { "version": "1.0.8", @@ -1467,6 +1472,7 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1547,7 +1553,8 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-align": { "version": "1.12.4", @@ -1579,7 +1586,8 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/entities": { "version": "7.0.1", @@ -1727,6 +1735,7 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "license": "MIT", + "peer": true, "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -1809,6 +1818,7 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "license": "ISC", + "peer": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -1956,6 +1966,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2034,6 +2045,7 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "license": "MIT", + "peer": true, "dependencies": { "p-locate": "^4.1.0" }, @@ -2250,6 +2262,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "license": "MIT", + "peer": true, "dependencies": { "p-try": "^2.0.0" }, @@ -2265,6 +2278,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "license": "MIT", + "peer": true, "dependencies": { "p-limit": "^2.2.0" }, @@ -2277,6 +2291,7 @@ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -2296,6 +2311,7 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2318,7 +2334,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2352,6 +2367,7 @@ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" } @@ -2502,6 +2518,7 @@ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2510,7 +2527,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", @@ -2582,7 +2600,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/set-function-length": { "version": "1.2.2", @@ -2645,6 +2664,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -2659,6 +2679,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2728,7 +2749,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2803,7 +2823,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", @@ -2895,13 +2914,15 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -2915,13 +2936,15 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", "license": "MIT", + "peer": true, "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", @@ -2944,6 +2967,7 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "license": "ISC", + "peer": true, "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" diff --git a/front/src/components/WebPage.vue b/front/src/components/WebPage.vue index 61bd441..ffffa38 100644 --- a/front/src/components/WebPage.vue +++ b/front/src/components/WebPage.vue @@ -7,11 +7,11 @@ @virtual-tour="openVirtualTour" /> - + @@ -31,13 +31,52 @@ @submit="submitPreinscripcion" /> + + +
+ +
+ +
+

{{ detalleModal.descripcion }}

+
+ +
+
    +
  • {{ item }}
  • +
+
+ +
+ +
+ +
+ +
+
+ \ No newline at end of file + diff --git a/front/src/components/WebPageSections/ProcessSection.vue b/front/src/components/WebPageSections/ProcessSection.vue index 0ade7ac..0667465 100644 --- a/front/src/components/WebPageSections/ProcessSection.vue +++ b/front/src/components/WebPageSections/ProcessSection.vue @@ -1,11 +1,11 @@ \ 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`.