feat: Implementar conexión de convocatorias vigentes con API de admisión y mejoras en el modal de detalles

main
parent 1f5f3ae81e
commit 7311693ed2

@ -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);

@ -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');
});
}
};

@ -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

@ -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"

@ -7,11 +7,11 @@
@virtual-tour="openVirtualTour"
/>
<ProcessSection />
<ProcessSection :proceso="procesoPrincipal" />
<ConvocatoriasSection
:procesos="procesosPublicados"
@show-modal="showModal"
@open-preinscripcion="openPreinscripcion"
/>
<ProgramasSection :facultades="facultades" />
@ -31,13 +31,52 @@
@submit="submitPreinscripcion"
/>
<!-- Modal de detalles de convocatoria -->
<a-modal
v-model:open="detalleModalVisible"
:title="detalleModal.titulo"
:footer="null"
width="700px"
centered
>
<div v-if="detalleModal.imagen_url" class="detalle-modal-imagen">
<a-image
:src="detalleModal.imagen_url"
:alt="detalleModal.titulo"
style="width: 100%; max-height: 350px; object-fit: cover; border-radius: 8px;"
/>
</div>
<div v-if="detalleModal.descripcion" class="detalle-modal-desc" style="margin-top: 16px;">
<p style="white-space: pre-line; color: #555; line-height: 1.7;">{{ detalleModal.descripcion }}</p>
</div>
<div v-if="detalleModal.listas && detalleModal.listas.length > 0" style="margin-top: 12px;">
<ul style="padding-left: 20px; color: #555;">
<li v-for="(item, idx) in detalleModal.listas" :key="idx">{{ item }}</li>
</ul>
</div>
<div v-if="detalleModal.imagen_url_2" class="detalle-modal-imagen" style="margin-top: 16px;">
<a-image
:src="detalleModal.imagen_url_2"
:alt="detalleModal.titulo"
style="width: 100%; max-height: 350px; object-fit: cover; border-radius: 8px;"
/>
</div>
<div v-if="!detalleModal.descripcion && !detalleModal.imagen_url && (!detalleModal.listas || detalleModal.listas.length === 0)">
<a-empty description="No hay información disponible para esta sección." />
</div>
</a-modal>
<FooterModerno />
</template>
<script setup>
import { ref, markRaw } from "vue"
import { ref, computed, onMounted, markRaw } from "vue"
import { message } from "ant-design-vue"
import { useProcesoAdmisionStore } from "../store/procesosAdmisionStore"
import NavbarModerno from '../components/nabvar.vue'
import FooterModerno from '../components/footer.vue'
@ -52,7 +91,6 @@ import ModalidadesSection from './WebPageSections/ModalidadesSection.vue'
import ContactSection from './WebPageSections/ContactSection.vue'
import PreinscripcionModal from './WebPageSections//modal/PreinscripcionModal.vue'
import {
MedicineBoxOutlined,
BuildOutlined,
@ -64,7 +102,24 @@ import {
UserOutlined,
} from "@ant-design/icons-vue"
const procesoStore = useProcesoAdmisionStore()
const procesosPublicados = computed(() => procesoStore.procesosPublicados)
const procesoPrincipal = computed(() => procesoStore.procesosPublicados[0] || null)
onMounted(() => {
procesoStore.fetchProcesosPublicados()
})
const preinscripcionModalVisible = ref(false)
const detalleModalVisible = ref(false)
const detalleModal = ref({
titulo: '',
descripcion: '',
imagen_url: null,
imagen_url_2: null,
listas: [],
})
const facultades = [
{
@ -219,7 +274,6 @@ const scrollToConvocatoria = () => {
}
const openVirtualTour = () => {
// pon aquí tu URL real
window.open("https://example.com", "_blank", "noopener,noreferrer")
}
@ -227,8 +281,38 @@ const openPreinscripcion = () => {
preinscripcionModalVisible.value = true
}
const showModal = (type) => {
console.log("Mostrar modal:", type)
const tipoLabels = {
requisitos: 'Requisitos',
pagos: 'Pagos',
vacantes: 'Vacantes',
cronograma: 'Cronograma',
}
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
}
const submitPreinscripcion = () => {
@ -241,4 +325,8 @@ const submitPreinscripcion = () => {
.main-content {
min-height: 100vh;
}
</style>
.detalle-modal-imagen {
text-align: center;
}
</style>

@ -12,7 +12,13 @@
</p>
</div>
<div class="convocatorias-grid">
<!-- Sin procesos publicados -->
<div v-if="procesos.length === 0" class="empty-state">
<p>No hay convocatorias vigentes en este momento.</p>
</div>
<div v-else class="convocatorias-grid">
<!-- Card principal: primer proceso -->
<a-card class="main-convocatoria-card">
<div class="card-badge">Principal</div>
@ -20,16 +26,19 @@
<div class="main-card-text">
<div class="convocatoria-header">
<div>
<h3>Admisión Ordinaria 2026-I</h3>
<p class="convocatoria-date">Inscripciones: 20 Oct - 30 Nov</p>
<h3>{{ principal.titulo }}</h3>
<p class="convocatoria-date">
{{ formatFechasInscripcion(principal) }}
</p>
</div>
<a-tag color="success" class="status-tag">Abierto</a-tag>
<a-tag :color="getEstadoColor(principal.estado)" class="status-tag">
{{ getEstadoLabel(principal.estado) }}
</a-tag>
</div>
<p class="convocatoria-desc">
Proceso de admisión general para todas las carreras profesionales de pregrado.
Examen de conocimientos: 15 de diciembre.
{{ principal.descripcion || principal.subtitulo || 'Proceso de admisión' }}
</p>
<a-divider class="custom-divider" />
@ -38,22 +47,22 @@
<h4 class="subheading">Acciones Rápidas</h4>
<div class="action-buttons-grid">
<a-button class="action-btn" @click="$emit('show-modal', 'requisitos')">
<a-button class="action-btn" @click="$emit('show-modal', { procesoId: principal.id, tipo: 'requisitos' })">
<template #icon><FileTextOutlined /></template>
Requisitos
</a-button>
<a-button class="action-btn" @click="$emit('show-modal', 'pagos')">
<a-button class="action-btn" @click="$emit('show-modal', { procesoId: principal.id, tipo: 'pagos' })">
<template #icon><DollarOutlined /></template>
Pagos
</a-button>
<a-button class="action-btn" @click="$emit('show-modal', 'vacantes')">
<a-button class="action-btn" @click="$emit('show-modal', { procesoId: principal.id, tipo: 'vacantes' })">
<template #icon><TeamOutlined /></template>
Vacantes
</a-button>
<a-button class="action-btn" @click="$emit('show-modal', 'cronograma')">
<a-button class="action-btn" @click="$emit('show-modal', { procesoId: principal.id, tipo: 'cronograma' })">
<template #icon><CalendarOutlined /></template>
Cronograma
</a-button>
@ -72,7 +81,7 @@
type="primary"
size="large"
class="preinscripcion-btn"
@click="$emit('open-preinscripcion')"
@click="abrirPreinscripcion(principal)"
>
<template #icon><FormOutlined /></template>
Iniciar Preinscripción
@ -80,10 +89,9 @@
</div>
</div>
<div class="main-card-media">
<a-image
src="/images/extra.jpg"
:src="principal.imagen_url || '/images/extra.jpg'"
alt="Convocatoria"
:preview="true"
class="convocatoria-image"
@ -92,59 +100,45 @@
</div>
</a-card>
<div class="secondary-list">
<a-card class="secondary-convocatoria-card">
<div class="convocatoria-header">
<div>
<h4 class="secondary-title">CEPREUNA</h4>
<p class="convocatoria-date">30 de enero</p>
</div>
<a-tag class="status-tag" color="default">FINALIZADO</a-tag>
</div>
<p class="convocatoria-desc">Postulantes del CEPRE</p>
<div class="card-footer">
<a-button type="link" size="small" @click="$emit('show-modal', 'cepreuna')">
Ver detalles
</a-button>
<a-button type="primary" ghost size="small">
Consultar
</a-button>
</div>
</a-card>
<a-card class="secondary-convocatoria-card">
<div class="convocatoria-header">
<div>
<h4 class="secondary-title">Extraordinario</h4>
<p class="convocatoria-date">15 de febrero</p>
</div>
<a-tag class="status-tag" color="orange">PRÓXIMAMENTE</a-tag>
</div>
<!-- Cards secundarias: resto de procesos -->
<div v-if="secundarios.length > 0" class="secondary-list">
<a-card
v-for="proceso in secundarios"
:key="proceso.id"
class="secondary-convocatoria-card"
>
<div class="convocatoria-header">
<div>
<h4 class="secondary-title">{{ proceso.titulo }}</h4>
<p class="convocatoria-date">{{ formatFechasInscripcion(proceso) }}</p>
</div>
<p class="convocatoria-desc">Modalidad extraordinaria para perfiles específicos</p>
<a-tag class="status-tag" :color="getEstadoColor(proceso.estado)">
{{ getEstadoLabel(proceso.estado) }}
</a-tag>
</div>
<div class="card-footer">
<a-button type="link" size="small" @click="$emit('show-modal', 'extraordinario')">
Ver detalles
</a-button>
<a-button type="primary" ghost size="small">
Consultar
</a-button>
</div>
</a-card>
</div>
<p class="convocatoria-desc">
{{ proceso.descripcion || proceso.subtitulo || 'Proceso de admisión' }}
</p>
<div class="card-footer">
<a-button type="link" size="small" @click="$emit('show-modal', { procesoId: proceso.id, tipo: 'requisitos' })">
Ver detalles
</a-button>
<a-button type="primary" ghost size="small" @click="abrirPreinscripcion(proceso)">
Consultar
</a-button>
</div>
</a-card>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
import {
FileTextOutlined,
DollarOutlined,
@ -153,34 +147,65 @@ import {
FormOutlined,
} from "@ant-design/icons-vue"
defineProps({
otrasConvocatorias: {
const props = defineProps({
procesos: {
type: Array,
default: () => [],
default: () => [],
},
})
const emit = defineEmits(["show-modal", "open-preinscripcion"])
defineEmits(["show-modal"])
const handleConsultar = (c) => {
const principal = computed(() => props.procesos[0] || {})
const secundarios = computed(() => props.procesos.slice(1))
if (c && typeof c.onConsultar === "function") {
c.onConsultar()
return
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' },
}
function getEstadoLabel(estado) {
return estadoMap[estado]?.label || estado || 'Abierto'
}
function getEstadoColor(estado) {
return estadoMap[estado]?.color || 'success'
}
function formatFechasInscripcion(proceso) {
if (!proceso.fecha_inicio_inscripcion && !proceso.fecha_fin_inscripcion) {
return ''
}
const opts = { day: 'numeric', month: 'short' }
const inicio = proceso.fecha_inicio_inscripcion
? new Date(proceso.fecha_inicio_inscripcion).toLocaleDateString('es-PE', opts)
: ''
const fin = proceso.fecha_fin_inscripcion
? new Date(proceso.fecha_fin_inscripcion).toLocaleDateString('es-PE', opts)
: ''
if (inicio && fin) return `Inscripciones: ${inicio} - ${fin}`
if (inicio) return `Inicio: ${inicio}`
return `Hasta: ${fin}`
}
function abrirPreinscripcion(proceso) {
if (proceso.link_preinscripcion) {
window.open(proceso.link_preinscripcion, '_blank', 'noopener,noreferrer')
}
emit("show-modal", c?.modalKey ? c.modalKey : "detalle")
}
</script>
<style scoped>
.convocatorias-modern {
position: relative;
position: relative;
padding: 40px 0;
font-family: "Times New Roman", Times, serif;
background: #fbfcff;
overflow: hidden;
background: #fbfcff;
overflow: hidden;
}
.convocatorias-modern::before {
@ -209,7 +234,6 @@ const handleConsultar = (c) => {
opacity: 0.55;
}
.section-container {
position: relative;
z-index: 1;
@ -218,7 +242,6 @@ const handleConsultar = (c) => {
padding: 0 24px;
}
.section-header {
text-align: center;
margin-bottom: 50px;
@ -251,15 +274,13 @@ const handleConsultar = (c) => {
border-radius: 999px;
}
.convocatorias-grid {
display: grid;
grid-template-columns: 1fr;
grid-template-columns: 1fr;
gap: 24px;
align-items: start;
}
.main-convocatoria-card {
position: relative;
border: none;
@ -283,7 +304,6 @@ const handleConsultar = (c) => {
font-weight: 700;
}
.main-card-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
@ -298,7 +318,6 @@ const handleConsultar = (c) => {
.main-card-media {
display: flex;
justify-content: flex-end;
}
.convocatoria-image :deep(img) {
@ -310,7 +329,6 @@ const handleConsultar = (c) => {
border-radius: 14px;
}
.convocatoria-header {
display: flex;
justify-content: space-between;
@ -361,7 +379,6 @@ const handleConsultar = (c) => {
font-weight: 700;
}
.action-buttons-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@ -421,6 +438,13 @@ const handleConsultar = (c) => {
margin-top: 12px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
font-size: 1.1rem;
}
@media (max-width: 992px) {
.section-title {
font-size: 2.1rem;
@ -440,7 +464,7 @@ const handleConsultar = (c) => {
}
.secondary-list {
grid-template-columns: 1fr;
grid-template-columns: 1fr;
}
}
@ -459,4 +483,4 @@ const handleConsultar = (c) => {
grid-template-columns: 1fr;
}
}
</style>
</style>

@ -1,11 +1,11 @@
<!-- components/process/ProcessSection.vue -->
<template>
<section class="process-section" aria-labelledby="process-title">
<section v-if="proceso" class="process-section" aria-labelledby="process-title">
<div class="section-container">
<div class="section-header">
<h2 id="process-title" class="section-title">Proceso de Admisión 2026</h2>
<h2 id="process-title" class="section-title">{{ proceso.titulo || 'Proceso de Admisión 2026' }}</h2>
<p class="section-subtitle">
Sigue estos pasos para postular al Examen General 2026-I
{{ proceso.subtitulo || 'Sigue estos pasos para postular' }}
</p>
</div>
@ -16,11 +16,12 @@
:responsive="false"
class="modern-steps"
>
<a-step title="Preinscripción Virtual" description="20 Oct - 30 Nov" />
<a-step title="Inscripción Presencial" description="1 - 5 Dic" />
<a-step title="Examen" description="15 Diciembre" />
<a-step title="Resultados" description="20 Diciembre" />
<a-step title="Control Biométrico Ingresantes" description="8 - 12 Ene" />
<a-step
v-for="step in steps"
:key="step.title"
:title="step.title"
:description="step.description"
/>
</a-steps>
<div class="process-note">
@ -33,9 +34,14 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted } from "vue"
import { ref, computed, onMounted, onUnmounted } from "vue"
const currentStep = 2
const props = defineProps({
proceso: {
type: Object,
default: null,
},
})
const isMobile = ref(false)
const checkScreen = () => {
@ -50,6 +56,70 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener("resize", checkScreen)
})
function formatRango(inicio, fin) {
if (!inicio && !fin) return null
const opts = { day: 'numeric', month: 'short' }
const i = inicio ? new Date(inicio).toLocaleDateString('es-PE', opts) : null
const f = fin ? new Date(fin).toLocaleDateString('es-PE', opts) : null
if (i && f) return `${i} - ${f}`
if (i) return `Desde ${i}`
return `Hasta ${f}`
}
function formatFecha(fecha) {
if (!fecha) return null
return new Date(fecha).toLocaleDateString('es-PE', { day: 'numeric', month: 'long' })
}
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
})
const now = new Date()
const currentStep = computed(() => {
const p = props.proceso
if (!p) return 0
if (p.fecha_inicio_biometrico && now >= new Date(p.fecha_inicio_biometrico)) return steps.value.length - 1
if (p.fecha_resultados && now >= new Date(p.fecha_resultados)) return steps.value.findIndex(s => s.title === 'Resultados')
if (p.fecha_examen1 && now >= new Date(p.fecha_examen1)) return steps.value.findIndex(s => s.title === 'Examen')
if (p.fecha_inicio_inscripcion && now >= new Date(p.fecha_inicio_inscripcion)) return steps.value.findIndex(s => s.title === 'Inscripción Presencial')
if (p.fecha_inicio_preinscripcion && now >= new Date(p.fecha_inicio_preinscripcion)) return steps.value.findIndex(s => s.title === 'Preinscripción Virtual')
return 0
})
</script>
<style scoped>
@ -85,7 +155,6 @@ onUnmounted(() => {
line-height: 1.4;
}
.process-card {
border: 1px solid #e5e7eb;
border-radius: 14px;
@ -94,32 +163,30 @@ onUnmounted(() => {
background: #fff;
}
.modern-steps {
padding: 8px 8px;
}
.modern-steps :deep(.ant-steps-item-title) {
white-space: normal !important;
overflow: visible !important;
text-overflow: clip !important;
white-space: normal !important;
overflow: visible !important;
text-overflow: clip !important;
max-width: none !important;
}
.modern-steps :deep(.ant-steps-item-content) {
min-width: 0;
min-width: 0;
width: 100%;
}
.modern-steps :deep(.ant-steps-item-container) {
align-items: flex-start;
align-items: flex-start;
}
.modern-steps :deep(.ant-steps-item) {
flex: 1 1 0;
flex: 1 1 0;
}
.modern-steps :deep(.ant-steps-item-title) {
font-size: 0.95rem;
font-weight: 700;
@ -134,7 +201,6 @@ onUnmounted(() => {
line-height: 1.25;
}
.modern-steps :deep(.ant-steps-item-icon) {
width: 30px;
height: 30px;
@ -142,19 +208,16 @@ onUnmounted(() => {
font-size: 13px;
}
.modern-steps :deep(.ant-steps-item-tail::after) {
height: 2px;
background: #dfe6e9;
}
.modern-steps :deep(.ant-steps-item-process .ant-steps-item-icon) {
background-color: #1e3a8a;
border-color: #1e3a8a;
}
.modern-steps :deep(.ant-steps-item-finish .ant-steps-item-icon > .ant-steps-icon) {
color: #1e3a8a;
}
@ -162,7 +225,6 @@ onUnmounted(() => {
border-color: #1e3a8a;
}
.process-note {
display: flex;
align-items: center;
@ -180,7 +242,6 @@ onUnmounted(() => {
flex-shrink: 0;
}
@media (max-width: 992px) {
.section-title {
font-size: 1.85rem;
@ -190,7 +251,6 @@ onUnmounted(() => {
}
}
@media (max-width: 768px) {
.process-section {
padding: 24px 0;
@ -214,4 +274,4 @@ onUnmounted(() => {
line-height: 28px;
}
}
</style>
</style>

@ -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

@ -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)
})

@ -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)

390
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
<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