main
elmer-20 2 months ago
parent 495595033d
commit e84ec16504

@ -366,6 +366,9 @@ public function generarPreguntas($examenId)
/**
* 4. INICIAR EXAMEN (marcar hora inicio)
*/
/**
* 4. INICIAR EXAMEN (marcar hora inicio e incrementar intentos)
*/
public function iniciarExamen(Request $request)
{
$request->validate([
@ -383,7 +386,7 @@ public function iniciarExamen(Request $request)
], 403);
}
// Traer datos del área-proceso directamente desde la DB
// Obtener datos del área-proceso
$areaProceso = \DB::table('area_proceso')
->join('procesos', 'area_proceso.proceso_id', '=', 'procesos.id')
->join('areas', 'area_proceso.area_id', '=', 'areas.id')
@ -405,7 +408,7 @@ public function iniciarExamen(Request $request)
], 400);
}
// Verificar que no exceda el número máximo de intentos
// Verificar límite de intentos
if ($areaProceso && $examen->intentos >= $areaProceso->proceso_intentos_maximos) {
return response()->json([
'success' => false,
@ -413,16 +416,15 @@ public function iniciarExamen(Request $request)
], 403);
}
// Marcar hora inicio si no está iniciado
if (!$examen->hora_inicio) {
$examen->update([
'hora_inicio' => now(),
'estado' => 'en_progreso',
'intentos' => $examen->intentos + 1
]);
}
// 🔥 Incrementar intento y marcar inicio
$examen->increment('intentos');
// Obtener preguntas con toda la información
$examen->update([
'hora_inicio' => now(), // Hora normal del servidor
'estado' => 'en_progreso'
]);
// Obtener preguntas completas
$preguntas = $this->examenService->obtenerPreguntasExamen($examen);
return response()->json([
@ -442,9 +444,7 @@ public function iniciarExamen(Request $request)
}
/**
* 5. RESPONDER PREGUNTA
*/
public function responderPregunta($preguntaAsignadaId, Request $request)
{
$request->validate([

@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Postulante;
use App\Models\ResultadoAdmision;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Auth;
@ -145,7 +146,7 @@ class PostulanteAuthController extends Controller
}
public function obtenerPagosPostulante(Request $request)
public function obtenerPagosPostulante(Request $request)
{
$postulante = $request->user(); // o Auth::guard('postulante')->user();
@ -209,7 +210,7 @@ public function obtenerPagosPostulante(Request $request)
// ===============================
// 3⃣ BANCO NACIÓN
// ===============================
$urlBanco = 'https://inscripciones.admision.unap.edu.pe/api/get-pagos-banco-dni';
$urlBanco = 'https://inscripciones.admision.unap.edu.pe/api/get-pagos-banco';
$responseBanco = Http::post($urlBanco, [
'dni' => $dni,

@ -99,6 +99,7 @@ Route::prefix('postulante')->group(function () {
Route::get('/me', [PostulanteAuthController::class, 'me']);
Route::get('/pagos', [PostulanteAuthController::class, 'obtenerPagosPostulante']);
Route::get('/postulante/mis-procesos',[PostulanteAuthController::class, 'misProcesos']);
Route::get('/mis-procesos', [PostulanteAuthController::class, 'misProcesos']);
});
@ -154,4 +155,5 @@ Route::middleware(['auth:postulante'])->group(function () {
// Finalizar examen
Route::post('/examen/{examen}/finalizar', [ExamenController::class, 'finalizarExamen']);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 KiB

@ -3,39 +3,30 @@
<NavbarModerno />
<div class="main-content">
<!-- Hero Section -->
<HeroSection
<HeroSection
@scroll-to-convocatoria="scrollToConvocatoria"
@virtual-tour="openVirtualTour"
/>
<!-- Proceso de Admisión -->
<ProcessSection />
<!-- Convocatorias -->
<ConvocatoriasSection
<ConvocatoriasSection
@show-modal="showModal"
@open-preinscripcion="openPreinscripcion"
/>
<!-- Programas Académicos -->
<ProgramasSection :facultades="facultades" />
<!-- Estadísticas -->
<StatsSection />
<!-- Noticias -->
<NoticiasSection :noticias="noticias" />
<!-- Modalidades -->
<ModalidadesSection :modalidades="modalidades" />
<!-- Contacto -->
<ContactSection />
</div>
<!-- Modal de Preinscripción -->
<PreinscripcionModal
<PreinscripcionModal
v-model:visible="preinscripcionModalVisible"
:facultades="facultades"
@submit="submitPreinscripcion"
@ -45,7 +36,8 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, markRaw } from "vue"
import { message } from "ant-design-vue"
// Components
import NavbarModerno from '../components/nabvar.vue'
@ -62,94 +54,179 @@ import ModalidadesSection from './WebPageSections/ModalidadesSection.vue'
import ContactSection from './WebPageSections/ContactSection.vue'
import PreinscripcionModal from './WebPageSections//modal/PreinscripcionModal.vue'
// Iconos para programas y modalidades
import {
// Iconos
import {
MedicineBoxOutlined,
BuildOutlined,
CodeOutlined,
BookOutlined,
TrophyOutlined,
BankOutlined,
ExperimentOutlined
} from '@ant-design/icons-vue'
ExperimentOutlined,
UserOutlined,
} from "@ant-design/icons-vue"
// Estado
const preinscripcionModalVisible = ref(false)
// Datos
const facultades = ref([
// Datos (estáticos const, no ref)
const facultades = [
{
id: '1',
nombre: 'Ciencias de la Salud',
id: "1",
nombre: "Ciencias de la Salud",
carreras: [
{ id: 1, nombre: 'Medicina Humana', grado: 'Bachiller', descripcion: 'Formación médica integral con prácticas desde primer año', vacantes: 50, puntaje: '1800+', icono: MedicineBoxOutlined },
{ id: 2, nombre: 'Enfermería', grado: 'Bachiller', descripcion: 'Cuidado integral de la salud', vacantes: 60, puntaje: '1500+', icono: 'UserOutlined' }
]
{
id: 1,
nombre: "Medicina Humana",
grado: "Bachiller",
descripcion: "Formación médica integral con prácticas desde primer año",
vacantes: 50,
puntaje: "1800+",
icono: markRaw(MedicineBoxOutlined),
},
{
id: 2,
nombre: "Enfermería",
grado: "Bachiller",
descripcion: "Cuidado integral de la salud",
vacantes: 60,
puntaje: "1500+",
icono: markRaw(UserOutlined),
},
],
},
{
id: '2',
nombre: 'Ingenierías',
id: "2",
nombre: "Ingenierías",
carreras: [
{ id: 3, nombre: 'Ingeniería Civil', grado: 'Bachiller', descripcion: 'Diseño y construcción de infraestructura', vacantes: 80, puntaje: '1700+', icono: BuildOutlined },
{ id: 4, nombre: 'Ingeniería de Sistemas', grado: 'Bachiller', descripcion: 'Desarrollo de software e inteligencia artificial', vacantes: 100, puntaje: '1600+', icono: CodeOutlined }
]
{
id: 3,
nombre: "Ingeniería Civil",
grado: "Bachiller",
descripcion: "Diseño y construcción de infraestructura",
vacantes: 80,
puntaje: "1700+",
icono: markRaw(BuildOutlined),
},
{
id: 4,
nombre: "Ingeniería de Sistemas",
grado: "Bachiller",
descripcion: "Desarrollo de software e inteligencia artificial",
vacantes: 100,
puntaje: "1600+",
icono: markRaw(CodeOutlined),
},
],
},
{
id: '3',
nombre: 'Derecho y Humanidades',
id: "3",
nombre: "Derecho y Humanidades",
carreras: [
{ id: 5, nombre: 'Derecho', grado: 'Bachiller', descripcion: 'Formación jurídica integral', vacantes: 120, puntaje: '1550+' },
{ id: 6, nombre: 'Psicología', grado: 'Bachiller', descripcion: 'Ciencias del comportamiento humano', vacantes: 70, puntaje: '1450+', icono: 'UserOutlined' }
]
}
])
const modalidades = ref([
{ id: 1, nombre: 'Admisión Ordinaria', descripcion: 'Examen de conocimientos generales', estado: 'Abierto', estadoColor: 'success', color: '#1890ff', icono: BookOutlined },
{ id: 2, nombre: 'Evaluación de Talentos', descripcion: 'Para deportistas y artistas destacados', estado: 'Próximamente', estadoColor: 'orange', color: '#faad14', icono: TrophyOutlined },
{ id: 3, nombre: 'Traslado Externo', descripcion: 'Estudiantes de otras universidades', estado: 'Cerrado', estadoColor: 'red', color: '#ff4d4f', icono: BankOutlined },
{ id: 4, nombre: 'Segunda Carrera', descripcion: 'Para profesionales graduados', estado: 'Abierto', estadoColor: 'success', color: '#52c41a', icono: ExperimentOutlined }
])
const noticias = ref([
{
id: 5,
nombre: "Derecho",
grado: "Bachiller",
descripcion: "Formación jurídica integral",
vacantes: 120,
puntaje: "1550+",
icono: markRaw(BookOutlined),
},
{
id: 6,
nombre: "Psicología",
grado: "Bachiller",
descripcion: "Ciencias del comportamiento humano",
vacantes: 70,
puntaje: "1450+",
icono: markRaw(UserOutlined),
},
],
},
]
const modalidades = [
{
id: 1,
titulo: 'Nuevo Laboratorio de Investigación',
descripcion: 'Inauguramos el moderno laboratorio de ciencias con tecnología de punta.',
fecha: '15 Nov 2023',
categoria: 'Infraestructura',
tagColor: 'blue',
imagen: 'https://images.unsplash.com/photo-1532094349884-543bc11b234d?ixlib=rb-1.2.1&auto=format&fit=crop&w=600&q=80'
nombre: "Admisión Ordinaria",
descripcion: "Examen de conocimientos generales",
estado: "Abierto",
estadoColor: "success",
color: "#1890ff",
icono: markRaw(BookOutlined),
},
{
id: 2,
titulo: 'Convenio Internacional',
descripcion: 'Firmamos acuerdo con universidad europea para intercambio estudiantil.',
fecha: '10 Nov 2023',
categoria: 'Internacional',
tagColor: 'green',
imagen: 'https://images.unsplash.com/photo-1523050854058-8df90110c9f1?ixlib=rb-1.2.1&auto=format&fit=crop&w=600&q=80'
nombre: "Evaluación de Talentos",
descripcion: "Para deportistas y artistas destacados",
estado: "Próximamente",
estadoColor: "orange",
color: "#faad14",
icono: markRaw(TrophyOutlined),
},
{
id: 3,
titulo: 'Resultados Publicados',
descripcion: 'Consulta los resultados del examen de admisión extraordinario.',
fecha: '5 Nov 2023',
categoria: 'Resultados',
tagColor: 'red',
imagen: 'https://images.unsplash.com/photo-1562774053-701939374585?ixlib=rb-1.2.1&auto=format&fit=crop&w=600&q=80'
}
])
nombre: "Traslado Externo",
descripcion: "Estudiantes de otras universidades",
estado: "Cerrado",
estadoColor: "red",
color: "#ff4d4f",
icono: markRaw(BankOutlined),
},
{
id: 4,
nombre: "Segunda Carrera",
descripcion: "Para profesionales graduados",
estado: "Abierto",
estadoColor: "success",
color: "#52c41a",
icono: markRaw(ExperimentOutlined),
},
]
const noticias = [
{
id: 1,
titulo: "Nuevo Laboratorio de Investigación",
descripcion: "Inauguramos el moderno laboratorio de ciencias con tecnología de punta.",
fecha: "15 Nov 2023",
categoria: "Infraestructura",
tagColor: "blue",
imagen:
"https://images.unsplash.com/photo-1532094349884-543bc11b234d?auto=format&fit=crop&w=600&q=80",
},
{
id: 2,
titulo: "Convenio Internacional",
descripcion: "Firmamos acuerdo con universidad europea para intercambio estudiantil.",
fecha: "10 Nov 2023",
categoria: "Internacional",
tagColor: "green",
imagen:
"https://images.unsplash.com/photo-1523050854058-8df90110c9f1?auto=format&fit=crop&w=600&q=80",
},
{
id: 3,
titulo: "Resultados Publicados",
descripcion: "Consulta los resultados del examen de admisión extraordinario.",
fecha: "5 Nov 2023",
categoria: "Resultados",
tagColor: "red",
imagen:
"https://images.unsplash.com/photo-1562774053-701939374585?auto=format&fit=crop&w=600&q=80",
},
]
// Métodos
const scrollToConvocatoria = () => {
const element = document.getElementById('convocatorias')
element?.scrollIntoView({ behavior: 'smooth' })
const el = document.getElementById("convocatorias")
el?.scrollIntoView({ behavior: "smooth", block: "start" })
}
const openVirtualTour = () => {
window.open('#', '_blank')
// pon aquí tu URL real
window.open("https://example.com", "_blank", "noopener,noreferrer")
}
const openPreinscripcion = () => {
@ -157,11 +234,11 @@ const openPreinscripcion = () => {
}
const showModal = (type) => {
console.log('Mostrar modal:', type)
console.log("Mostrar modal:", type)
}
const submitPreinscripcion = () => {
alert('Preinscripción iniciada exitosamente')
message.success("Preinscripción iniciada exitosamente")
preinscripcionModalVisible.value = false
}
</script>

@ -7,198 +7,281 @@
<h2 class="section-title">Convocatorias Vigentes</h2>
<a-badge count="Nuevo" class="new-badge" />
</div>
<p class="section-subtitle">Selecciona el proceso de admisión que se ajuste a tu perfil</p>
<p class="section-subtitle">
Selecciona el proceso de admisión que se ajuste a tu perfil
</p>
</div>
<!-- PRINCIPAL ARRIBA (UNA SOLA FILA) + SECUNDARIAS ABAJO -->
<div class="convocatorias-grid">
<!-- Convocatoria Principal -->
<!-- Convocatoria Principal -->
<a-card class="main-convocatoria-card">
<div class="card-badge">Principal</div>
<div class="main-card-content">
<!-- IZQUIERDA: CONTENIDO -->
<div class="main-card-text">
<div class="convocatoria-header">
<div>
<h3>Admisión Ordinaria 2024-I</h3>
<p class="convocatoria-date">
Inscripciones: 20 Oct - 30 Nov 2023
</p>
</div>
<a-tag color="success" class="status-tag">Abierto</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 2023.
</p>
<a-divider class="custom-divider" />
<div class="quick-actions">
<h4>Acciones Rápidas</h4>
<div class="action-buttons-grid">
<a-button class="action-btn" @click="$emit('show-modal', 'requisitos')">
<template #icon><FileTextOutlined /></template>
Requisitos
</a-button>
<a-button class="action-btn" @click="$emit('show-modal', 'pagos')">
<template #icon><DollarOutlined /></template>
Pagos
</a-button>
<!-- PRINCIPAL -->
<a-card class="main-convocatoria-card">
<div class="card-badge">Principal</div>
<div class="main-card-grid">
<!-- IZQUIERDA -->
<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>
</div>
<a-tag color="success" class="status-tag">Abierto</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.
</p>
<a-divider class="custom-divider" />
<div class="quick-actions">
<h4 class="subheading">Acciones Rápidas</h4>
<div class="action-buttons-grid">
<a-button class="action-btn" @click="$emit('show-modal', 'requisitos')">
<template #icon><FileTextOutlined /></template>
Requisitos
</a-button>
<a-button class="action-btn" @click="$emit('show-modal', 'pagos')">
<template #icon><DollarOutlined /></template>
Pagos
</a-button>
<a-button class="action-btn" @click="$emit('show-modal', 'vacantes')">
<template #icon><TeamOutlined /></template>
Vacantes
</a-button>
<a-button class="action-btn" @click="$emit('show-modal', 'cronograma')">
<template #icon><CalendarOutlined /></template>
Cronograma
</a-button>
</div>
</div>
<a-divider class="custom-divider" />
<div class="preinscripcion-section">
<div class="preinscripcion-info">
<h4 class="subheading">Preinscripción en Línea</h4>
<p>Completa tu preinscripción de manera digital y segura</p>
</div>
<a-button
type="primary"
size="large"
class="preinscripcion-btn"
@click="$emit('open-preinscripcion')"
>
<template #icon><FormOutlined /></template>
Iniciar Preinscripción
</a-button>
</div>
</div>
<a-button class="action-btn" @click="$emit('show-modal', 'vacantes')">
<template #icon><TeamOutlined /></template>
Vacantes
</a-button>
<!-- DERECHA -->
<div class="main-card-media">
<a-image
src="/images/extra.jpg"
alt="Convocatoria"
:preview="true"
class="convocatoria-image"
/>
</div>
</div>
</a-card>
<a-button class="action-btn" @click="$emit('show-modal', 'cronograma')">
<template #icon><CalendarOutlined /></template>
Cronograma
</a-button>
</div>
<div class="secondary-list">
<!-- CARD 1 -->
<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-divider class="custom-divider" />
<a-tag class="status-tag" color="default">FINALIZADO</a-tag>
</div>
<div class="preinscripcion-section">
<div class="preinscripcion-info">
<h4>Preinscripción en Línea</h4>
<p>Completa tu preinscripción de manera digital y segura</p>
</div>
<p class="convocatoria-desc">Postulantes del CEPRE</p>
<a-button
type="primary"
size="large"
class="preinscripcion-btn"
@click="$emit('open-preinscripcion')"
>
<template #icon><FormOutlined /></template>
Iniciar Preinscripción
</a-button>
</div>
<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>
<!-- CARD 2 -->
<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>
<p class="convocatoria-desc">Modalidad extraordinaria para perfiles específicos</p>
</div>
</a-card>
<!-- IMAGEN DERECHA -->
<div class="convocatoria-image-wrapper">
<a-image
src="/images/extra.jpg"
alt="Convocatoria"
:preview="true"
class="convocatoria-image"
/>
</div>
<!-- Otras convocatorias -->
<a-card class="secondary-convocatoria-card" >
<div class="convocatoria-header">
<div>
<h4>cepreuna</h4>
<p class="convocatoria-date">30 de enero</p>
</div>
<a-tag class="status-tag">FINALIZADO</a-tag>
</div>
<p class="convocatoria-desc">postulaNTES DEL CEPRE </p>
<div class="card-footer">
<a-button type="link" size="small">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>cepreuna</h4>
<p class="convocatoria-date">30 de enero</p>
</div>
<a-tag class="status-tag">FINALIZADO</a-tag>
</div>
<p class="convocatoria-desc">postulaNTES DEL CEPRE </p>
<div class="card-footer">
<a-button type="link" size="small">Ver detalles</a-button>
<a-button type="primary" ghost size="small">Consultar</a-button>
</div>
</a-card>
<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>
</div>
</div>
</section>
</template>
<script setup>
import { FileTextOutlined, DollarOutlined, TeamOutlined, CalendarOutlined, FormOutlined } from '@ant-design/icons-vue'
import {
FileTextOutlined,
DollarOutlined,
TeamOutlined,
CalendarOutlined,
FormOutlined,
} from "@ant-design/icons-vue"
defineProps({
otrasConvocatorias: {
type: Array,
required: true
}
default: () => [], // para que no salga el warning si no mandas nada
},
})
defineEmits(['show-modal', 'open-preinscripcion'])
const emit = defineEmits(["show-modal", "open-preinscripcion"])
const handleConsultar = (c) => {
// Si llega función, la ejecuta; si no, abre un modal genérico
if (c && typeof c.onConsultar === "function") {
c.onConsultar()
return
}
emit("show-modal", c?.modalKey ? c.modalKey : "detalle")
}
</script>
<style scoped>
/* =========================
CONVOCATORIAS (con fondo cuadriculado)
========================= */
.convocatorias-modern {
padding: 80px 0;
position: relative; /* necesario para ::before */
padding: 40px 0;
font-family: "Times New Roman", Times, serif;
background: #fbfcff; /* base clara */
overflow: hidden; /* evita desbordes del fondo */
}
/* Fondo cuadriculado detrás del contenido */
.convocatorias-modern::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
/* Cuadrícula suave (líneas cada 24px) */
background-image:
repeating-linear-gradient(
to right,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 24px
),
repeating-linear-gradient(
to bottom,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 24px
);
opacity: 0.55;
}
/* Asegura que TODO el contenido vaya encima del fondo */
.section-container {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
/* =========================
HEADER
========================= */
.section-header {
text-align: center;
margin-bottom: 60px;
margin-bottom: 50px;
}
.header-with-badge {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 14px;
}
.section-title {
font-size: 2.8rem;
font-size: 2.6rem;
font-weight: 700;
color: #0d1b52;
margin-bottom: 16px;
margin: 0;
}
.section-subtitle {
font-size: 1.125rem;
color: #666;
max-width: 600px;
margin: 0 auto;
}
.header-with-badge {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
max-width: 640px;
margin: 14px auto 0;
}
.new-badge :deep(.ant-badge-count) {
background: linear-gradient(45deg, #ff6b6b, #ffd700);
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
border-radius: 999px;
}
/* =========================
GRID PRINCIPAL (1 columna)
========================= */
.convocatorias-grid {
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-columns: 1fr; /* principal arriba, secundarias abajo */
gap: 24px;
align-items: start;
}
/* =========================
CARD PRINCIPAL
========================= */
.main-convocatoria-card {
position: relative;
border: none;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.08);
border-radius: 16px;
padding: 32px;
}
.main-convocatoria-card :deep(.ant-card-body) {
padding: 28px;
}
.card-badge {
@ -208,103 +291,198 @@ defineEmits(['show-modal', 'open-preinscripcion'])
background: linear-gradient(45deg, #1890ff, #52c41a);
color: white;
padding: 6px 16px;
border-radius: 20px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
font-weight: 700;
}
/* Grid interno del card principal: texto + imagen */
.main-card-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 22px;
align-items: start;
}
.main-card-text {
min-width: 0;
}
.main-card-media {
display: flex;
justify-content: flex-end;
}
.convocatoria-image :deep(img) {
width: 100%;
max-width: 360px;
height: 100%;
max-height: 360px;
object-fit: cover;
border-radius: 14px;
}
/* =========================
TEXTOS Y HEADER DENTRO DE CARDS
========================= */
.convocatoria-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
gap: 14px;
margin-bottom: 14px;
}
.convocatoria-header h3 {
margin: 0;
font-size: 1.5rem;
font-size: 1.55rem;
color: #1a237e;
}
.secondary-title {
margin: 0;
font-size: 1.05rem;
color: #1a237e;
text-transform: capitalize;
}
.convocatoria-date {
color: #666;
margin: 8px 0 0;
margin: 6px 0 0;
font-size: 0.95rem;
}
.status-tag {
font-weight: 600;
font-weight: 700;
padding: 4px 12px;
border-radius: 20px;
border-radius: 999px;
white-space: nowrap;
}
.convocatoria-desc {
color: #666;
line-height: 1.6;
margin-bottom: 24px;
margin: 0 0 18px;
}
.custom-divider {
margin: 24px 0;
margin: 18px 0;
}
.subheading {
margin: 0 0 6px;
color: #1a237e;
font-weight: 700;
}
/* =========================
ACCIONES RÁPIDAS
========================= */
.action-buttons-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-top: 16px;
margin-top: 12px;
}
.action-btn {
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 48px;
height: 46px;
border-radius: 10px;
}
/* =========================
PREINSCRIPCIÓN
========================= */
.preinscripcion-section {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
gap: 18px;
}
.preinscripcion-info h4 {
margin: 0 0 8px;
color: #1a237e;
.preinscripcion-info p {
margin: 0;
color: #666;
}
.preinscripcion-btn {
background: linear-gradient(135deg, #1a237e 0%, #283593 100%);
border: none;
height: 56px;
font-weight: 600;
height: 52px;
font-weight: 700;
border-radius: 12px;
}
/* =========================
SECUNDARIAS ABAJO
========================= */
.secondary-list {
display: grid;
grid-template-columns: repeat(2, 1fr); /* 2 columnas en desktop */
gap: 16px;
}
.secondary-convocatoria-card {
border: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
border-radius: 12px;
}
.secondary-convocatoria-card :deep(.ant-card-body) {
padding: 18px;
}
.card-footer {
display: flex;
justify-content: space-between;
margin-top: 20px;
align-items: center;
margin-top: 12px;
}
/* =========================
RESPONSIVE
========================= */
@media (max-width: 992px) {
.section-title {
font-size: 2.1rem;
}
.main-card-grid {
grid-template-columns: 1fr; /* imagen baja */
}
.main-card-media {
justify-content: center;
}
.convocatoria-image :deep(img) {
max-width: 100%;
max-height: 320px;
}
.secondary-list {
grid-template-columns: 1fr; /* 1 columna en tablet */
}
}
@media (max-width: 768px) {
.convocatorias-grid {
grid-template-columns: 1fr;
.convocatorias-modern {
padding: 55px 0;
}
.preinscripcion-section {
flex-direction: column;
text-align: center;
align-items: stretch;
}
.section-title {
font-size: 2rem;
.secondary-list {
grid-template-columns: 1fr;
}
}
</style>

@ -1,37 +1,102 @@
<!-- components/hero/HeroSection.vue -->
<template>
<section class="modern-hero">
<section class="hero" aria-label="Sección principal de admisión">
<!-- Fondo institucional (sin degradados) -->
<div class="hero-bg" aria-hidden="true">
<div class="hero-grid"></div>
<div class="hero-shape hero-shape-1"></div>
<div class="hero-shape hero-shape-2"></div>
</div>
<div class="hero-container">
<!-- Contenido -->
<div class="hero-content">
<a-tag color="blue" class="hero-tag">Convocatoria 2026</a-tag>
<div class="hero-badges">
<a-tag class="hero-tag">Convocatoria 2026</a-tag>
<span class="hero-pill">
<CheckCircleOutlined />
Inscripciones abiertas
</span>
</div>
<h1 class="hero-title">
Admision
<span class="gradient-text">2026</span>
Admisión <span class="hero-year">2026</span>
</h1>
<p class="hero-subtitle">
Forma parte de la comunidad universitaria más prestigiosa del país.
<br>Admisión 2024-I | Inscripciones abiertas
Forma parte de una comunidad académica de excelencia.
<span class="hero-subtitle-muted">Postula, infórmate y conoce el proceso.</span>
</p>
<div class="hero-actions">
<a-button type="primary" size="large" class="cta-button" @click="$emit('scroll-to-convocatoria')">
<a-button
type="primary"
size="large"
class="cta-button"
@click="$emit('scroll-to-convocatoria')"
>
<template #icon><RightCircleOutlined /></template>
Postular Ahora
Postular ahora
</a-button>
<a-button size="large" class="secondary-button" @click="$emit('virtual-tour')">
<template #icon><PlayCircleOutlined /></template>
Tour Virtual
Tour virtual
</a-button>
</div>
<!-- Micro datos (opcional) -->
<div class="hero-metrics">
<div class="metric">
<span class="metric-value">44</span>
<span class="metric-label">Programas</span>
</div>
<div class="metric">
<span class="metric-value">3</span>
<span class="metric-label">Áreas</span>
</div>
<div class="metric">
<span class="metric-value">+10</span>
<span class="metric-label">Sedes</span>
</div>
</div>
</div>
<!-- Visual -->
<div class="hero-visual">
<div class="floating-card">
<div class="card-header">
<CalendarOutlined />
<span>Próximo Evento</span>
<div class="visual-stack">
<div class="floating-card">
<div class="card-header">
<CalendarOutlined />
<span>Próximo evento</span>
</div>
<h3 class="card-title">Charla informativa</h3>
<div class="card-info">
<span class="info-item">
<ClockCircleOutlined />
25 Nov 4:00 PM
</span>
<span class="info-item">
<VideoCameraOutlined />
Virtual
</span>
</div>
<a-button type="primary" ghost class="card-cta" size="middle">
Registrarse
</a-button>
</div>
<div class="mini-card" aria-hidden="true">
<span class="mini-dot"></span>
<div class="mini-text">
<div class="mini-title">Guía del postulante</div>
<div class="mini-sub">Requisitos y fechas</div>
</div>
<ArrowRightOutlined class="mini-icon" />
</div>
<h3>Charla Informativa</h3>
<p>25 Nov | 4:00 PM - Virtual</p>
<a-button type="link" size="small">Registrarse</a-button>
</div>
</div>
</div>
@ -39,109 +104,371 @@
</template>
<script setup>
import { RightCircleOutlined, PlayCircleOutlined, CalendarOutlined } from '@ant-design/icons-vue'
import {
RightCircleOutlined,
PlayCircleOutlined,
CalendarOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
VideoCameraOutlined,
ArrowRightOutlined,
} from "@ant-design/icons-vue";
defineEmits(['scroll-to-convocatoria', 'virtual-tour'])
defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
</script>
<style scoped>
.modern-hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 80px 0;
/* =========================================================
TIPOGRAFÍA: Times (institucional)
Puedes mover esto a un estilo global si lo deseas.
========================================================= */
.hero,
.hero * {
font-family: "Times New Roman", Times, serif;
}
/* =========================================================
COLORES INSTITUCIONALES (AJÚSTALOS A TU MANUAL DE MARCA)
- primary: color principal (fondo)
- secondary: apoyo
- accent: énfasis (año/indicadores)
========================================================= */
.hero {
--inst-primary: #1a237e; /* AZUL institucional (ejemplo) */
--inst-secondary: #0f172a; /* oscuro de apoyo */
--inst-accent: #c8a100; /* dorado institucional (ejemplo) */
position: relative;
overflow: hidden;
color: #ffffff;
padding: 88px 0;
background: var(--inst-primary); /* SIN DEGRADADOS */
}
/* Fondo decorativo sutil (sin degradados) */
.hero-bg {
position: absolute;
inset: 0;
pointer-events: none;
}
/* Patrón de líneas muy suave */
.hero-grid {
position: absolute;
inset: 0;
opacity: 0.10;
background-image: linear-gradient(rgba(255, 255, 255, 0.14) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.14) 1px, transparent 1px);
background-size: 52px 52px;
}
/* Formas planas (sin degradados) para dar profundidad */
.hero-shape {
position: absolute;
border-radius: 999px;
opacity: 0.16;
background: #ffffff; /* plano */
}
.hero-shape-1 {
width: 460px;
height: 460px;
left: -220px;
top: -240px;
}
.hero-shape-2 {
width: 560px;
height: 560px;
right: -260px;
bottom: -320px;
}
.hero-container {
position: relative;
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 80px;
grid-template-columns: 1.1fr 0.9fr;
gap: 72px;
align-items: center;
}
/* ====== CONTENT ====== */
.hero-badges {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 22px;
}
.hero-tag {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
margin-bottom: 24px;
border: 1px solid rgba(255, 255, 255, 0.25);
color: #fff;
background: rgba(255, 255, 255, 0.12);
font-weight: 700;
}
.hero-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.10);
font-size: 0.95rem;
font-weight: 700;
}
.hero-title {
font-size: 3.5rem;
font-weight: 800;
line-height: 1.1;
margin-bottom: 24px;
font-size: 3.4rem;
font-weight: 900;
line-height: 1.08;
margin: 0 0 18px;
letter-spacing: -0.01em;
}
.gradient-text {
background: linear-gradient(45deg, #ffd700, #ff6b6b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
.hero-year {
color: var(--inst-accent); /* énfasis institucional */
position: relative;
}
/* Subrayado institucional (sin degradados) */
.hero-year::after {
content: "";
position: absolute;
left: 0;
bottom: -10px;
width: 100%;
height: 4px;
background: var(--inst-accent);
border-radius: 999px;
opacity: 0.9;
}
.hero-subtitle {
font-size: 1.25rem;
font-size: 1.18rem;
opacity: 0.95;
margin: 0 0 32px;
line-height: 1.65;
max-width: 560px;
font-weight: 600;
}
.hero-subtitle-muted {
display: inline-block;
margin-left: 6px;
opacity: 0.9;
margin-bottom: 32px;
line-height: 1.6;
font-weight: 600;
}
.hero-actions {
display: flex;
gap: 16px;
gap: 14px;
flex-wrap: wrap;
}
.cta-button {
background: white;
border: none;
color: #1a237e;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.22);
font-weight: 800;
border-radius: 12px;
/* Botón principal con acento institucional (sin degradado) */
background: var(--inst-accent);
color: var(--inst-secondary);
}
.cta-button:hover {
filter: brightness(1.02);
transform: translateY(-1px);
}
.secondary-button {
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
border: 2px solid rgba(255, 255, 255, 0.28);
color: #fff;
border-radius: 12px;
font-weight: 800;
}
.secondary-button:hover {
background: rgba(255, 255, 255, 0.08);
}
.hero-metrics {
margin-top: 28px;
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.metric {
min-width: 120px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.10);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.metric-value {
display: block;
font-size: 1.25rem;
font-weight: 900;
line-height: 1.1;
color: #fff;
}
.metric-label {
display: block;
opacity: 0.9;
font-size: 0.95rem;
margin-top: 2px;
font-weight: 700;
}
/* ====== VISUAL ====== */
.hero-visual {
display: flex;
justify-content: flex-end;
}
.visual-stack {
width: min(420px, 100%);
display: grid;
gap: 14px;
}
.floating-card {
background: white;
color: #333;
padding: 24px;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
background: #ffffff;
color: #111827;
padding: 22px;
border-radius: 18px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18);
border: 1px solid rgba(17, 24, 39, 0.08);
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.floating-card:hover {
transform: translateY(-4px);
box-shadow: 0 28px 70px rgba(0, 0, 0, 0.22);
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
color: #6b7280;
margin-bottom: 14px;
font-weight: 800;
}
.card-title {
margin: 0 0 12px;
color: #111827;
font-weight: 900;
}
.card-info {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
margin-bottom: 14px;
color: #374151;
font-weight: 700;
}
.info-item {
display: inline-flex;
align-items: center;
gap: 8px;
color: #666;
margin-bottom: 16px;
font-size: 0.95rem;
font-weight: 800;
}
.card-cta {
border-radius: 12px;
font-weight: 900;
border-color: var(--inst-primary);
color: var(--inst-primary);
}
.mini-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.10);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.mini-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--inst-accent);
box-shadow: 0 0 0 6px rgba(200, 161, 0, 0.18);
}
.mini-title {
font-weight: 900;
line-height: 1.1;
}
.mini-sub {
opacity: 0.9;
font-size: 0.95rem;
margin-top: 2px;
font-weight: 700;
}
.mini-icon {
opacity: 0.9;
}
/* ====== Responsive ====== */
@media (max-width: 992px) {
.hero {
padding: 72px 0;
}
.hero-container {
grid-template-columns: 1fr;
text-align: center;
gap: 48px;
gap: 40px;
}
.hero-title {
font-size: 2.5rem;
.hero-subtitle {
margin-left: auto;
margin-right: auto;
}
.hero-actions {
flex-direction: column;
justify-content: center;
}
.hero-visual {
justify-content: center;
}
.hero-title {
font-size: 2.7rem;
}
.hero-metrics {
justify-content: center;
}
}
@media (max-width: 768px) {
.hero-title {
font-size: 2rem;
font-size: 2.15rem;
}
.metric {
min-width: 110px;
}
}
</style>

@ -8,15 +8,41 @@
</div>
<div class="modalidades-grid">
<a-card v-for="modalidad in modalidades" :key="modalidad.id" class="modalidad-card">
<div class="modalidad-icon" :style="{ backgroundColor: modalidad.color }">
<component :is="modalidad.icono" />
<!-- ORDINARIO -->
<a-card class="modalidad-card">
<div class="modalidad-icon bg-ordinario">
<!-- Si no quieres íconos, elimina esta línea -->
<CalendarOutlined />
</div>
<h4>{{ modalidad.nombre }}</h4>
<p>{{ modalidad.descripcion }}</p>
<a-tag :color="modalidad.estadoColor" class="modalidad-status">
{{ modalidad.estado }}
</a-tag>
<h4>Ordinario</h4>
<p>Proceso regular de admisión, orientado a postulantes que rinden el examen general.</p>
<a-tag color="green" class="modalidad-status">Disponible</a-tag>
</a-card>
<!-- EXTRAORDINARIO -->
<a-card class="modalidad-card">
<div class="modalidad-icon bg-extraordinario">
<StarOutlined />
</div>
<h4>Extraordinario</h4>
<p>Modalidades especiales (por ejemplo: primeros puestos, deportistas calificados u otros).</p>
<a-tag color="blue" class="modalidad-status">Requisitos especiales</a-tag>
</a-card>
<!-- SEDES -->
<a-card class="modalidad-card">
<div class="modalidad-icon bg-sedes">
<EnvironmentOutlined />
</div>
<h4>Sedes</h4>
<p>Postulación según la sede disponible, con vacantes y condiciones específicas por ubicación.</p>
<a-tag color="purple" class="modalidad-status">Por sede</a-tag>
</a-card>
</div>
</div>
@ -24,12 +50,8 @@
</template>
<script setup>
defineProps({
modalidades: {
type: Array,
required: true
}
})
// Icons (Ant Design Vue)
import { CalendarOutlined, StarOutlined, EnvironmentOutlined } from "@ant-design/icons-vue";
</script>
<style scoped>
@ -94,6 +116,17 @@ defineProps({
font-size: 28px;
}
/* Colores fijos por modalidad (sin props) */
.bg-ordinario {
background: #22c55e; /* verde */
}
.bg-extraordinario {
background: #3b82f6; /* azul */
}
.bg-sedes {
background: #8b5cf6; /* morado */
}
.modalidad-card h4 {
margin: 0 0 12px;
color: #1a237e;

@ -1,134 +1,298 @@
<!-- components/noticias/NoticiasSection.vue -->
<template>
<section class="noticias-modern">
<div class="section-container">
<div class="section-header">
<h2 class="section-title">Noticias y Comunicados</h2>
<a-button type="link" class="view-all">Ver todos</a-button>
</div>
<section class="news-section">
<div class="container">
<!-- Header -->
<div class="header">
<div class="header-left">
<a-typography-title :level="2" class="title">
Noticias y Comunicados
</a-typography-title>
<a-typography-paragraph class="subtitle">
Entérate de los anuncios, resultados y novedades institucionales
</a-typography-paragraph>
</div>
<div class="noticias-grid-modern">
<a-card v-for="noticia in noticias" :key="noticia.id" class="noticia-card-modern">
<div class="noticia-image" :style="{ backgroundImage: `url(${noticia.imagen})` }">
<a-tag :color="noticia.tagColor" class="noticia-tag">{{ noticia.categoria }}</a-tag>
</div>
<div class="noticia-content">
<div class="noticia-meta">
<CalendarOutlined />
<span>{{ noticia.fecha }}</span>
</div>
<h4>{{ noticia.titulo }}</h4>
<p>{{ noticia.descripcion }}</p>
<a-button type="link" size="small">Leer más</a-button>
</div>
</a-card>
</div>
<a-divider class="divider" />
<!-- Grid -->
<a-row :gutter="[24, 24]">
<a-col
v-for="noticia in noticias"
:key="noticia.id"
:xs="24"
:sm="12"
:lg="8"
>
<!-- Ribbon (categoría) -->
<a-badge-ribbon
:text="noticia.categoria"
:color="noticia.tagColor || 'blue'"
>
<a-card hoverable class="card" :bodyStyle="{ padding: '16px' }">
<!-- Cover -->
<template #cover>
<div
class="cover"
:style="{ backgroundImage: `url(${noticia.imagen})` }"
>
<div class="cover-overlay" />
<!-- Fecha pill -->
<div class="date-pill">
<CalendarOutlined />
<span>{{ noticia.fecha }}</span>
</div>
</div>
</template>
<!-- Content -->
<a-space direction="vertical" size="small" class="content">
<a-typography-title
:level="4"
class="card-title"
:content="noticia.titulo"
:ellipsis="{ rows: 2, tooltip: noticia.titulo }"
/>
<a-typography-paragraph
class="desc"
:content="noticia.descripcion"
:ellipsis="{ rows: 3, tooltip: noticia.descripcion }"
/>
<div class="actions">
<a-button type="link" class="read-more">
Leer más
<ArrowRightOutlined />
</a-button>
<!-- Tag secundario opcional (si quieres mostrar algo extra)
<a-tag color="default" class="tag-soft">Institucional</a-tag>
-->
</div>
</a-space>
</a-card>
</a-badge-ribbon>
</a-col>
</a-row>
</div>
</section>
</template>
<script setup>
import { CalendarOutlined } from '@ant-design/icons-vue'
import { CalendarOutlined, RightOutlined, ArrowRightOutlined } from "@ant-design/icons-vue"
defineProps({
noticias: {
type: Array,
required: true
}
default: () => [],
},
})
</script>
<style scoped>
.noticias-modern {
padding: 80px 0;
/* ===== Sección con fondo elegante tipo “paper grid” ===== */
.news-section {
position: relative;
padding: 88px 0;
background: radial-gradient(1200px 400px at 20% 0%, #eef4ff 0%, transparent 55%),
radial-gradient(1000px 420px at 95% 10%, #f3f0ff 0%, transparent 55%),
#fbfcff;
overflow: hidden;
font-family: "Times New Roman", Times, serif;
}
.news-section::before {
content: "";
position: absolute;
inset: 0;
opacity: 0.40;
pointer-events: none;
background-image:
repeating-linear-gradient(to right,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 28px
),
repeating-linear-gradient(to bottom,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 28px
);
}
.section-container {
.container {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
.section-header {
/* ===== Header ===== */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 60px;
align-items: flex-end;
gap: 16px;
margin-bottom: 18px;
}
.section-title {
font-size: 2.5rem;
font-weight: 700;
color: #1a237e;
margin: 0;
.header-left {
max-width: 820px;
}
/* ===== Título centrado + Times New Roman ===== */
.title {
margin: 0 !important;
text-align: center;
font-family: "Times New Roman", Times, serif !important;
font-weight: 900; /* fuerte para título */
color: #111a56;
letter-spacing: -0.4px;
}
/* ===== Subtítulo delgado ===== */
.subtitle {
margin: 8px 0 0 !important;
text-align: center;
font-family: "Times New Roman", Times, serif; /* opcional, para que combine */
font-weight: 300; /* DELGADO */
color: rgba(0, 0, 0, 0.58);
line-height: 1.6;
font-size: 1.02rem;
}
.title :deep(.ant-typography),
.subtitle :deep(.ant-typography) {
font-family: "Times New Roman", Times, serif !important;
}
.view-all {
color: #1890ff;
font-weight: 500;
.btn-all {
border-radius: 999px;
font-weight: 800;
padding: 0 18px;
height: 40px;
box-shadow: 0 10px 22px rgba(24, 144, 255, 0.18);
}
.noticias-grid-modern {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 32px;
.divider {
margin: 18px 0 28px !important;
opacity: 0.6;
}
.noticia-card-modern {
border: none;
border-radius: 12px;
/* ===== Card ===== */
.card {
border: 0;
border-radius: 18px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transition: transform 0.3s ease;
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(8px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.noticia-card-modern:hover {
.card:hover {
transform: translateY(-4px);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.12);
}
/* Ribbon spacing fix */
.card :deep(.ant-card-cover) {
margin: 0;
}
.noticia-image {
/* ===== Cover ===== */
.cover {
position: relative;
height: 200px;
background-size: cover;
background-position: center;
position: relative;
}
.noticia-tag {
.cover-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.06),
rgba(0, 0, 0, 0.45)
);
}
/* Fecha pill */
.date-pill {
position: absolute;
top: 16px;
left: 16px;
border: none;
font-weight: 500;
left: 14px;
bottom: 14px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
color: rgba(255, 255, 255, 0.92);
background: rgba(17, 26, 86, 0.40);
border: 1px solid rgba(255, 255, 255, 0.22);
backdrop-filter: blur(8px);
font-size: 0.92rem;
font-weight: 700;
}
/* ===== Content ===== */
.content {
width: 100%;
}
.card-title {
margin: 0 0 8px;
font-size: 1.2rem;
font-weight: 700;
color: rgba(0, 0, 0, 0.88);
}
.noticia-content {
padding: 24px;
.desc {
margin: 0 !important;
color: rgba(0, 0, 0, 0.62);
line-height: 1.6;
font-size: 0.98rem;
}
.noticia-meta {
/* Actions */
.actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
color: #666;
font-size: 0.875rem;
margin-bottom: 12px;
margin-top: 4px;
}
.noticia-content h4 {
margin: 0 0 12px;
color: #1a237e;
font-size: 1.125rem;
.read-more {
padding: 0;
font-weight: 900;
}
.noticia-content p {
color: #666;
line-height: 1.5;
margin-bottom: 16px;
/* ===== Responsive ===== */
@media (max-width: 768px) {
.news-section {
padding: 64px 0;
}
.header {
flex-direction: column; /* pone botón debajo si lo dejas */
align-items: center;
text-align: center;
gap: 10px;
}
@media (max-width: 992px) {
.section-title {
font-size: 2rem;
.btn-all {
margin-top: 6px;
}
}
</style>

@ -1,33 +1,65 @@
<!-- components/process/ProcessSection.vue -->
<template>
<section class="process-section">
<section class="process-section" aria-labelledby="process-title">
<div class="section-container">
<div class="section-header">
<h2 class="section-title">Proceso de Admisión 2026</h2>
<h2 id="process-title" class="section-title">Proceso de Admisión 2026</h2>
<p class="section-subtitle">
Sigue estos pasos para postular al Examen General 2026-I
</p>
</div>
<div class="process-timeline">
<a-steps :current="2" class="modern-steps">
<div class="process-card">
<a-steps
:current="currentStep"
:direction="isMobile ? 'vertical' : 'horizontal'"
: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-steps>
</div>
<div class="process-note">
<span class="dot" />
<span>Fechas referenciales. Verifica el cronograma oficial de la Dirección de Admisión</span>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from "vue"
/**
* Si quieres mantenerlo fijo como antes, deja 2.
* Si luego quieres automático por fecha, te lo ajusto con tu cronograma real.
*/
const currentStep = 2
const isMobile = ref(false)
const checkScreen = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
checkScreen()
window.addEventListener("resize", checkScreen)
})
onUnmounted(() => {
window.removeEventListener("resize", checkScreen)
})
</script>
<style scoped>
.process-section {
padding: 30px 0; /* mucho menos espacio */
background: #ffffff; /* quitamos el gris */
padding: 30px 0;
background: #ffffff;
font-family: "Times New Roman", Times, serif;
}
@ -39,14 +71,14 @@
.section-header {
text-align: center;
margin-bottom: 25px; /* reducido */
margin-bottom: 18px;
}
.section-title {
font-size: 2.1rem;
font-weight: 600;
font-weight: 700;
color: #2c3e50;
margin: 0 0 6px 0; /* sin espacio exagerado */
margin: 0 0 6px 0;
line-height: 1.2;
}
@ -57,44 +89,65 @@
line-height: 1.4;
}
/* ====== STEPS ====== */
.process-timeline {
max-width: 900px;
margin: 0 auto;
/* Card contenedora (se ve moderno sin fondo gris) */
.process-card {
border: 1px solid #e5e7eb;
border-radius: 14px;
padding: 16px 14px 12px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
background: #fff;
}
/* Steps compactos */
.modern-steps {
padding: 15px 10px; /* MUY compacto */
padding: 8px 8px;
}
/* ====== IMPORTANTE: evitar que se corten letras (sin "…") ====== */
.modern-steps :deep(.ant-steps-item-title) {
white-space: normal !important; /* permite salto de línea */
overflow: visible !important; /* no ocultar texto */
text-overflow: clip !important; /* sin "..." */
max-width: none !important;
}
.modern-steps :deep(.ant-steps-item-content) {
min-width: 0; /* clave para que wrap funcione en flex */
width: 100%;
}
.modern-steps :deep(.ant-steps-item-container) {
align-items: flex-start; /* título de 2 líneas se alinea bien */
}
/* QUITAR ESPACIO EXTRA DE ANT DESIGN */
.modern-steps :deep(.ant-steps-item) {
padding-bottom: 0;
flex: 1 1 0; /* reparte espacio */
}
/* Tipografías de steps */
.modern-steps :deep(.ant-steps-item-title) {
font-size: 0.9rem;
font-weight: 600;
font-size: 0.95rem;
font-weight: 700;
color: #34495e;
line-height: 1.2;
line-height: 1.15;
}
.modern-steps :deep(.ant-steps-item-description) {
font-size: 0.8rem;
font-size: 0.82rem;
color: #888;
margin-top: 2px;
line-height: 1.25;
}
/* Iconos más pequeños */
/* Iconos */
.modern-steps :deep(.ant-steps-item-icon) {
width: 28px;
height: 28px;
line-height: 28px;
width: 30px;
height: 30px;
line-height: 30px;
font-size: 13px;
}
/* Línea más fina */
/* Línea */
.modern-steps :deep(.ant-steps-item-tail::after) {
height: 2px;
background: #dfe6e9;
@ -102,14 +155,69 @@
/* Step activo */
.modern-steps :deep(.ant-steps-item-process .ant-steps-item-icon) {
background-color: #4a69bd;
border-color: #4a69bd;
background-color: #1e3a8a;
border-color: #1e3a8a;
}
/* Quitar altura extra que mete Ant */
.modern-steps :deep(.ant-steps-horizontal:not(.ant-steps-label-vertical) .ant-steps-item) {
margin-top: 0;
/* Step terminado (mejor look) */
.modern-steps :deep(.ant-steps-item-finish .ant-steps-item-icon > .ant-steps-icon) {
color: #1e3a8a;
}
.modern-steps :deep(.ant-steps-item-finish .ant-steps-item-icon) {
border-color: #1e3a8a;
}
/* Nota inferior */
.process-note {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
font-size: 0.86rem;
color: #6b7280;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #1e3a8a;
flex-shrink: 0;
}
</style>
/* Tablet */
@media (max-width: 992px) {
.section-title {
font-size: 1.85rem;
}
.modern-steps :deep(.ant-steps-item-title) {
font-size: 0.92rem;
}
}
/* Mobile */
@media (max-width: 768px) {
.process-section {
padding: 24px 0;
}
.section-title {
font-size: 1.55rem;
}
.process-card {
padding: 12px 10px 10px;
}
.modern-steps {
padding: 4px 4px;
}
/* en vertical se ve mejor con un poquito más de alto */
.modern-steps :deep(.ant-steps-item-icon) {
width: 28px;
height: 28px;
line-height: 28px;
}
}
</style>

@ -1,59 +1,102 @@
<!-- components/programas/ProgramasSection.vue -->
<!-- components/programas/ProgramasSection.vue (SOLO ÁREAS, estilo AntDV) -->
<template>
<section class="programas-section">
<section class="areas-section">
<div class="section-container">
<div class="section-header">
<h2 class="section-title">Programas Académicos</h2>
<p class="section-subtitle">Explora nuestra oferta académica de pregrado y posgrado</p>
<h2 class="section-title">Áreas Académicas</h2>
<p class="section-subtitle">
Elige el área de tu interés y conoce su enfoque formativo
</p>
</div>
<div class="facultades-tabs">
<a-tabs v-model:activeKey="facultadActiva">
<a-tab-pane v-for="facultad in facultades" :key="facultad.id" :tab="facultad.nombre">
<div class="carreras-grid">
<a-card v-for="carrera in facultad.carreras" :key="carrera.id" class="carrera-card">
<div class="carrera-header">
<div class="carrera-icon">
<component :is="carrera.icono" />
</div>
<div>
<h4>{{ carrera.nombre }}</h4>
<p class="carrera-grado">{{ carrera.grado }}</p>
</div>
</div>
<p class="carrera-desc">{{ carrera.descripcion }}</p>
<div class="carrera-stats">
<span><UserOutlined /> Vacantes: {{ carrera.vacantes }}</span>
<span><TrophyOutlined /> Puntaje: {{ carrera.puntaje }}</span>
</div>
<a-button type="link" size="small" class="carrera-link">Ver plan de estudios</a-button>
</a-card>
<div class="areas-grid">
<a-card
v-for="a in areas"
:key="a.id"
hoverable
class="area-card"
@click="selectArea(a)"
>
<!-- Imagen -->
<div class="area-media">
<a-image :src="a.imagen" :preview="false" class="area-img" />
<a-tag class="area-tag" color="blue">{{ a.nombre }}</a-tag>
</div>
<!-- Contenido -->
<div class="area-body">
<h3 class="area-title">{{ a.nombre }}</h3>
<p class="area-desc">{{ a.descripcion }}</p>
<!-- Métricas: AntDV (Tag) -->
<div class="area-metrics">
<a-tag color="processing">Programas: {{ a.programas }}</a-tag>
<a-tag color="default">Enfoque: {{ a.enfoque }}</a-tag>
<a-tag color="default">Duración: {{ a.duracion }}</a-tag>
</div>
</a-tab-pane>
</a-tabs>
<a-button type="primary" block class="area-btn">
Ver detalle del área
</a-button>
</div>
</a-card>
</div>
</div>
</section>
</template>
<script setup>
import { ref } from 'vue'
import { UserOutlined, TrophyOutlined } from '@ant-design/icons-vue'
defineProps({
facultades: {
const props = defineProps({
areas: {
type: Array,
required: true
}
default: () => [
{
id: "ing",
nombre: "Ingenierías",
imagen: "/images/areas/ingenierias.jpg",
descripcion:
"Formación orientada a la innovación, el diseño, la construcción y el desarrollo tecnológico.",
programas: "18",
enfoque: "Tecnológico",
duracion: "10 sem.",
},
{
id: "bio",
nombre: "Biomédicas",
imagen: "/images/areas/biomedicas.jpg",
descripcion:
"Ciencias de la salud y tecnología aplicada al bienestar, con formación científica y práctica.",
programas: "12",
enfoque: "Salud",
duracion: "1012 sem.",
},
{
id: "soc",
nombre: "Sociales",
imagen: "/images/areas/sociales.jpg",
descripcion:
"Formación humanística y social con análisis, investigación y compromiso con la sociedad.",
programas: "14",
enfoque: "Humanístico",
duracion: "10 sem.",
},
],
},
})
const facultadActiva = ref('1')
const emit = defineEmits(["select-area"])
const selectArea = (area) => {
emit("select-area", area)
}
</script>
<style scoped>
.programas-section {
/* ✅ Fondo AntDV (sin cuadrados) */
.areas-section {
padding: 80px 0;
background: #f8f9fa;
background: #f5f5f5; /* estilo Ant */
font-family: "Times New Roman", Times, serif;
}
.section-container {
@ -64,98 +107,116 @@ const facultadActiva = ref('1')
.section-header {
text-align: center;
margin-bottom: 60px;
margin-bottom: 40px;
}
.section-title {
font-size: 2.5rem;
margin: 0 0 10px;
font-size: 2.4rem;
font-weight: 700;
color: #1a237e;
margin-bottom: 16px;
color: rgba(0, 0, 0, 0.88); /* Ant text */
}
.section-subtitle {
font-size: 1.125rem;
color: #666;
max-width: 600px;
margin: 0 auto;
max-width: 680px;
font-size: 1.05rem;
color: rgba(0, 0, 0, 0.65); /* Ant secondary text */
line-height: 1.45;
}
.facultades-tabs :deep(.ant-tabs-nav) {
margin-bottom: 40px;
}
.carreras-grid {
/* Grid */
.areas-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.carrera-card {
border: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
/* Card: AntDV, mínimo override */
.area-card {
border-radius: 12px;
transition: transform 0.3s ease;
overflow: hidden;
cursor: pointer;
}
.carrera-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
/* Imagen */
.area-media {
position: relative;
height: 190px;
overflow: hidden;
}
.carrera-header {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
.area-img :deep(img) {
width: 100%;
height: 190px;
object-fit: cover;
display: block;
}
.carrera-icon {
background: linear-gradient(135deg, #1a237e 0%, #283593 100%);
color: white;
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
/* Tag arriba */
.area-tag {
position: absolute;
top: 12px;
left: 12px;
margin: 0;
border-radius: 999px;
font-weight: 600;
}
.carrera-header h4 {
margin: 0;
color: #1a237e;
/* Body */
.area-body {
padding: 14px 14px 16px;
}
.carrera-grado {
color: #666;
font-size: 0.875rem;
margin: 4px 0 0;
.area-title {
margin: 0 0 8px;
font-size: 1.2rem;
font-weight: 700;
color: rgba(0, 0, 0, 0.88);
}
.carrera-desc {
color: #666;
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 16px;
.area-desc {
margin: 0 0 12px;
color: rgba(0, 0, 0, 0.65);
font-size: 0.95rem;
line-height: 1.45;
}
.carrera-stats {
.area-metrics {
display: flex;
justify-content: space-between;
color: #666;
font-size: 0.875rem;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.carrera-link {
color: #1890ff;
font-weight: 500;
/* Botón */
.area-btn {
font-weight: 600;
border-radius: 10px;
}
/* Responsive */
@media (max-width: 992px) {
.areas-grid {
grid-template-columns: 1fr;
}
.area-media {
height: 220px;
}
.area-img :deep(img) {
height: 220px;
}
.section-title {
font-size: 2rem;
}
}
@media (max-width: 480px) {
.areas-section {
padding: 55px 0;
}
}
</style>

@ -1,24 +1,41 @@
<!-- components/stats/StatsSection.vue -->
<template>
<section class="stats-section">
<section class="stats-section" aria-labelledby="stats-title">
<div class="section-container">
<div class="section-header">
<h2 id="stats-title" class="section-title">Cifras Clave</h2>
<p class="section-subtitle">
Indicadores institucionales que reflejan nuestro compromiso académico
</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>25+</h3>
<p>Carreras Profesionales</p>
<div class="stat-top">
<span class="stat-number">25+</span>
</div>
<p class="stat-label">Carreras Profesionales</p>
</div>
<div class="stat-card">
<h3>98%</h3>
<p>Indice de Satisfacción</p>
<div class="stat-top">
<span class="stat-number">98%</span>
</div>
<p class="stat-label">Índice de Satisfacción</p>
</div>
<div class="stat-card">
<h3>15,000+</h3>
<p>Estudiantes Activos</p>
<div class="stat-top">
<span class="stat-number">15,000+</span>
</div>
<p class="stat-label">Estudiantes Activos</p>
</div>
<div class="stat-card">
<h3>85%</h3>
<p>Egresados Laborando</p>
<div class="stat-top">
<span class="stat-number">85%</span>
</div>
<p class="stat-label">Egresados Laborando</p>
</div>
</div>
</div>
@ -26,38 +43,158 @@
</template>
<style scoped>
/* ===== Sección ===== */
.stats-section {
padding: 60px 0;
position: relative;
padding: 70px 0;
font-family: "Times New Roman", Times, serif;
color: #fff;
background: linear-gradient(135deg, #1a237e 0%, #283593 100%);
color: white;
overflow: hidden;
}
/* Fondo cuadriculado suave encima del gradiente */
.stats-section::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.35;
background-image:
repeating-linear-gradient(
to right,
rgba(255, 255, 255, 0.10) 0,
rgba(255, 255, 255, 0.10) 1px,
transparent 1px,
transparent 26px
),
repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0.10) 0,
rgba(255, 255, 255, 0.10) 1px,
transparent 1px,
transparent 26px
);
}
/* Brillo sutil decorativo (opcional) */
.stats-section::after {
content: "";
position: absolute;
width: 520px;
height: 520px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 215, 0, 0.18), transparent 60%);
top: -220px;
right: -220px;
z-index: 0;
pointer-events: none;
}
.section-container {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
/* ===== Header ===== */
.section-header {
text-align: center;
margin-bottom: 34px;
}
.section-title {
margin: 0 0 8px;
font-size: 2.2rem;
font-weight: 800;
letter-spacing: 0.2px;
}
.section-subtitle {
margin: 0 auto;
max-width: 720px;
font-size: 1.05rem;
opacity: 0.9;
line-height: 1.45;
}
/* ===== Grid ===== */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 40px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
/* ===== Card ===== */
.stat-card {
padding: 22px 18px;
border-radius: 16px;
text-align: center;
/* Glass */
background: rgba(255, 255, 255, 0.10);
border: 1px solid rgba(255, 255, 255, 0.18);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18);
transition: transform 0.18s ease, background 0.18s ease;
}
.stat-card h3 {
font-size: 3rem;
font-weight: 800;
margin: 0;
.stat-card:hover {
transform: translateY(-4px);
background: rgba(255, 255, 255, 0.14);
}
.stat-top {
display: flex;
justify-content: center;
align-items: baseline;
gap: 6px;
}
/* Número destacado */
.stat-number {
font-size: 2.8rem;
font-weight: 900;
line-height: 1;
background: linear-gradient(45deg, #ffd700, #ff6b6b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-card p {
font-size: 1rem;
opacity: 0.9;
margin: 8px 0 0;
/* Texto */
.stat-label {
margin: 10px 0 0;
font-size: 1.02rem;
opacity: 0.92;
}
/* ===== Responsive ===== */
@media (max-width: 992px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.section-title {
font-size: 2rem;
}
}
@media (max-width: 480px) {
.stats-section {
padding: 54px 0;
}
.stats-grid {
grid-template-columns: 1fr;
}
.stat-number {
font-size: 2.4rem;
}
}
</style>

@ -2,11 +2,7 @@
<a-layout-header class="modern-header">
<div class="header-container">
<!-- LOGO -->
<div
class="university-logo"
@click="handleLogoClick"
style="cursor: pointer;"
>
<div class="university-logo" @click="goTo('inicio')" role="button" tabindex="0">
<div class="logo-icon">
<img src="/logotiny.png" alt="Logo UNA" />
</div>
@ -24,16 +20,32 @@
class="nav-menu-modern"
:items="navItems"
@click="handleMenuClick"
@openChange="handleDesktopOpenChange"
/>
</nav>
<!-- RIGHT ACTIONS (DESKTOP) -->
<div class="right-actions desktop-only">
<router-link
v-if="!authStore.isAuthenticated"
to="/login-postulante"
class="auth-link"
>
<a-button type="primary">
<template #icon><UserOutlined /></template>
Portal del Postulante
</a-button>
</router-link>
<router-link v-else to="/portal-postulante" class="auth-link">
<a-button type="primary" ghost>
<template #icon><DashboardOutlined /></template>
Mi Portal
</a-button>
</router-link>
</div>
<!-- MOBILE MENU BUTTON -->
<a-button
class="mobile-menu-btn mobile-only"
type="text"
@click="drawerOpen = true"
>
<a-button class="mobile-menu-btn mobile-only" type="text" @click="drawerOpen = true">
</a-button>
</div>
@ -44,7 +56,7 @@
placement="right"
:open="drawerOpen && isMobile"
@close="handleDrawerClose"
:width="280"
:width="300"
:bodyStyle="{ padding: 0 }"
:headerStyle="{ borderBottom: '1px solid #f0f0f0', fontFamily: 'Times New Roman' }"
:closable="true"
@ -72,225 +84,147 @@
@openChange="handleMobileOpenChange"
:openKeys="mobileOpenKeys"
/>
</div>
<div class="auth-section">
<router-link
v-if="!authStore.isAuthenticated"
to="/login-postulante"
class="login-link"
>
<a-button type="primary">
<template #icon><UserOutlined /></template>
Portal del Postulante
</a-button>
</router-link>
<router-link
v-else
to="/portal"
class="portal-link"
>
<a-button type="primary" ghost>
<template #icon><DashboardOutlined /></template>
Mi Portal
</a-button>
</router-link>
</div>
<!-- AUTH (MOBILE) -->
<div class="drawer-auth">
<router-link
v-if="!authStore.isAuthenticated"
to="/login-postulante"
class="auth-link"
@click="handleDrawerClose"
>
<a-button type="primary" block>
<template #icon><UserOutlined /></template>
Portal del Postulante
</a-button>
</router-link>
<router-link
v-else
to="/portal-postulante"
class="auth-link"
@click="handleDrawerClose"
>
<a-button type="primary" ghost block>
<template #icon><DashboardOutlined /></template>
Mi Portal
</a-button>
</router-link>
</div>
</div>
</a-drawer>
<div class="auth-section">
<router-link
v-if="!authStore.isAuthenticated"
to="/login-postulante"
class="login-link"
>
<a-button type="primary">
<template #icon><UserOutlined /></template>
Portal del Postulante
</a-button>
</router-link>
<router-link
v-else
to="/portal-postulante"
class="portal-link"
>
<a-button type="primary" ghost>
<template #icon><DashboardOutlined /></template>
Mi Portal
</a-button>
</router-link>
</div>
</a-layout-header>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '../store/postulanteStore'
import { UserOutlined, DashboardOutlined } from '@ant-design/icons-vue'
const drawerOpen = ref(false)
const selectedKeys = ref(['inicio'])
const isMobile = ref(false)
import { ref, computed, watch, onMounted, onUnmounted } from "vue"
import { useRouter } from "vue-router"
import { useAuthStore } from "../store/postulanteStore"
import { UserOutlined, DashboardOutlined } from "@ant-design/icons-vue"
const router = useRouter()
const authStore = useAuthStore()
const desktopOpenKeys = ref([])
const drawerOpen = ref(false)
const selectedKeys = ref(["inicio"])
const isMobile = ref(false)
const mobileOpenKeys = ref([])
// Detectar tamaño de pantalla
const checkScreenSize = () => {
isMobile.value = window.innerWidth < 768
// Si cambia a desktop, cerrar el drawer
if (!isMobile.value && drawerOpen.value) {
drawerOpen.value = false
mobileOpenKeys.value = [] // Limpiar openKeys del móvil
mobileOpenKeys.value = []
}
}
// Items del menú para desktop
const navItems = computed(() => [
{
key: 'inicio',
label: 'Inicio'
},
{ key: "inicio", label: "Inicio" },
{
key: 'programas',
label: 'Programas',
key: "programas",
label: "Programas",
children: [
{ key: 'ingenierias', label: 'Ingenierías' },
{ key: 'biomedicas', label: 'Biomédicas' },
{ key: 'sociales', label: 'Sociales' }
]
},
{
key: 'procesos',
label: 'Procesos'
{ key: "ingenierias", label: "Ingenierías" },
{ key: "biomedicas", label: "Biomédicas" },
{ key: "sociales", label: "Sociales" },
],
},
{ key: "procesos", label: "Procesos" },
{
key: 'modalidades',
label: 'Modalidades',
key: "modalidades",
label: "Modalidades",
children: [
{ key: 'ordinario', label: 'Ordinario' },
{ key: 'extraordinario', label: 'Extraordinario' },
{ key: 'sedes', label: 'Sedes' }
]
{ key: "ordinario", label: "Ordinario" },
{ key: "extraordinario", label: "Extraordinario" },
{ key: "sedes", label: "Sedes" },
],
},
{
key: 'resultados',
label: 'Resultados'
}
{ key: "resultados", label: "Resultados" },
])
// Items del menú para móvil con submenús contraídos
const mobileNavItems = computed(() => {
return navItems.value.map(item => {
const baseItem = {
key: item.key,
label: item.label
}
if (item.children) {
// Submenú plegable en móvil
return {
...baseItem,
children: item.children.map(child => ({
key: child.key,
label: child.label
}))
}
}
return baseItem
})
})
const mobileNavItems = computed(() => navItems.value)
// Observar cambios en el drawer para limpiar openKeys cuando se cierra
watch(drawerOpen, (newVal) => {
if (!newVal) {
// Limpiar openKeys cuando se cierra el drawer
mobileOpenKeys.value = []
}
watch(drawerOpen, (open) => {
if (!open) mobileOpenKeys.value = []
})
// Observar cambios en isMobile para cerrar drawer si cambia a desktop
watch(isMobile, (newVal) => {
if (!newVal && drawerOpen.value) {
drawerOpen.value = false
}
})
const handleMenuClick = ({ key }) => {
const routesByKey = {
inicio: "/",
programas: "/programas",
ingenierias: "/programas/ingenierias",
biomedicas: "/programas/biomedicas",
sociales: "/programas/sociales",
procesos: "/procesos",
modalidades: "/modalidades",
ordinario: "/modalidades/ordinario",
extraordinario: "/modalidades/extraordinario",
sedes: "/modalidades/sedes",
resultados: "/resultados",
}
const goTo = (key) => {
selectedKeys.value = [key]
console.log('Navegando a:', key)
// Si es un item de submenú, cerrar el drawer en móvil
const isSubmenuItem = navItems.value.some(item =>
item.children && item.children.some(child => child.key === key)
)
if (isSubmenuItem && isMobile.value) {
drawerOpen.value = false
}
const path = routesByKey[key]
if (path) router.push(path)
}
const handleDrawerMenuClick = ({ key }) => {
handleMenuClick({ key })
// Si NO es un submenú padre, cerrar el drawer
const isParentItem = navItems.value.some(item =>
item.key === key && item.children
)
if (!isParentItem && isMobile.value) {
drawerOpen.value = false
}
const handleMenuClick = ({ key }) => {
goTo(key)
}
const handleLogoClick = () => {
selectedKeys.value = ['inicio']
// router.push('/')
}
const handleDrawerMenuClick = ({ key }) => {
// si clickeas un padre con children, no cierres el drawer
const isParent = navItems.value.some((i) => i.key === key && i.children)
if (isParent) return
const handleDesktopOpenChange = (keys) => {
desktopOpenKeys.value = keys
goTo(key)
if (isMobile.value) handleDrawerClose()
}
const handleMobileOpenChange = (keys) => {
// En móvil, solo permitir un submenú abierto a la vez
const latestOpenKey = keys.find(key => !mobileOpenKeys.value.includes(key))
if (latestOpenKey) {
mobileOpenKeys.value = [latestOpenKey]
} else {
mobileOpenKeys.value = []
}
// Solo 1 submenu abierto en móvil
const latest = keys.find((k) => !mobileOpenKeys.value.includes(k))
mobileOpenKeys.value = latest ? [latest] : []
}
const handleDrawerClose = () => {
// Limpiar openKeys y cerrar drawer
mobileOpenKeys.value = []
drawerOpen.value = false
}
// Configurar event listeners para responsive
onMounted(() => {
checkScreenSize()
window.addEventListener('resize', checkScreenSize)
window.addEventListener("resize", checkScreenSize)
})
onUnmounted(() => {
window.removeEventListener('resize', checkScreenSize)
window.removeEventListener("resize", checkScreenSize)
})
</script>
<style scoped>
/* VARIABLES */
:root {
/* VARIABLES (mejor que :root dentro de scoped) */
:host {
--primary-color: #1e3a8a;
--secondary-color: #374151;
--border-color: #d1d5db;
@ -319,17 +253,17 @@ onUnmounted(() => {
position: sticky;
top: 0;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
/* CONTAINER */
.header-container {
max-width: 1320px;
margin: 0 auto;
display: flex;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
justify-content: space-between;
gap: 16px;
height: 100%;
width: 100%;
}
@ -339,97 +273,87 @@ onUnmounted(() => {
display: flex;
align-items: center;
gap: 14px;
min-width: 250px;
transition: opacity 0.3s;
cursor: pointer;
user-select: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.university-logo:hover {
opacity: 0.9;
opacity: 0.95;
transform: translateY(-1px);
}
.logo-icon {
width: 46px;
height: 46px;
border: 1px solid var(--border-color);
border-radius: 6px;
border-radius: 8px;
background: var(--bg-color);
display: flex;
align-items: center;
justify-content: center;
display: grid;
place-items: center;
flex-shrink: 0;
}
.logo-icon img {
width: 90%;
height: 90%;
object-fit: contain;
}
.logo-text {
display: flex;
flex-direction: column;
line-height: 1.4;
line-height: 1.2;
}
.logo-text h1 {
margin: 0;
font-size: 1.05rem;
font-size: 1.02rem;
font-weight: 700;
color: var(--primary-color);
}
.logo-text span {
font-size: 0.8rem;
font-size: 0.82rem;
color: var(--secondary-color);
}
/* NAV DESKTOP */
.modern-nav {
flex: 1;
display: flex;
justify-content: center;
min-width: 0;
}
.nav-menu-modern {
flex: 1;
width: 100%;
background: transparent !important;
border-bottom: none !important;
justify-content: center;
}
.nav-menu-modern :deep(.ant-menu-item),
.nav-menu-modern :deep(.ant-menu-submenu) {
height: 82px;
line-height: 82px;
padding: 0 16px;
padding: 0 14px;
font-size: 0.95rem;
color: var(--secondary-color);
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
transition: color 0.2s ease;
}
.nav-menu-modern :deep(.ant-menu-item:hover),
.nav-menu-modern :deep(.ant-menu-submenu:hover) {
color: var(--primary-color);
}
.nav-menu-modern :deep(.ant-menu-item-selected),
.nav-menu-modern :deep(.ant-menu-submenu-selected) {
color: var(--primary-color);
font-weight: 600;
}
.nav-menu-modern :deep(.ant-menu-item-selected::after),
.nav-menu-modern :deep(.ant-menu-submenu-selected::after) {
border-bottom: 3px solid var(--primary-color) !important;
}
.nav-menu-modern :deep(.ant-menu-submenu-arrow) {
color: var(--secondary-color);
/* RIGHT ACTIONS */
.right-actions {
display: flex;
justify-content: flex-end;
}
.nav-menu-modern :deep(.ant-menu-submenu:hover .ant-menu-submenu-arrow) {
color: var(--primary-color);
.auth-link {
text-decoration: none;
}
/* BOTÓN MÓVIL */
@ -437,45 +361,39 @@ onUnmounted(() => {
font-size: 24px;
color: #111827;
padding: 8px;
border-radius: 4px;
transition: background-color 0.3s;
border-radius: 8px;
transition: background-color 0.2s ease;
}
.mobile-menu-btn:hover {
background: rgba(0, 0, 0, 0.04);
}
/* DRAWER MÓVIL */
/* DRAWER */
.drawer-content {
display: flex;
flex-direction: column;
height: 100%;
}
.drawer-header {
padding: 20px 24px;
padding: 18px 20px;
border-bottom: 1px solid var(--border-color);
background: #fafafa;
}
.drawer-logo {
display: flex;
align-items: center;
gap: 12px;
}
.drawer-logo-text {
display: flex;
flex-direction: column;
line-height: 1.4;
line-height: 1.2;
}
.drawer-logo-text h3 {
margin: 0;
color: var(--primary-color);
font-size: 1rem;
}
.drawer-logo-text span {
font-size: 12px;
color: var(--secondary-color);
@ -484,175 +402,102 @@ onUnmounted(() => {
.drawer-menu {
border-right: none !important;
}
.drawer-menu :deep(.ant-menu-item) {
.drawer-menu :deep(.ant-menu-item),
.drawer-menu :deep(.ant-menu-submenu-title) {
font-size: 15px;
padding-left: 24px !important;
padding-left: 20px !important;
height: 48px;
line-height: 48px;
margin: 4px 0;
border-radius: 0;
transition: background-color 0.3s;
margin: 2px 0;
}
.drawer-menu :deep(.ant-menu-item:hover) {
.drawer-menu :deep(.ant-menu-item:hover),
.drawer-menu :deep(.ant-menu-submenu-title:hover) {
background-color: #f0f7ff;
}
.drawer-menu :deep(.ant-menu-item-selected) {
background-color: #f0f7ff;
color: var(--primary-color);
font-weight: 500;
font-weight: 600;
}
.drawer-menu :deep(.ant-menu-item-selected::after) {
border-right: 3px solid var(--primary-color);
}
.drawer-menu :deep(.ant-menu-submenu-title) {
font-size: 15px;
padding-left: 24px !important;
height: 48px;
line-height: 48px;
margin: 4px 0;
border-radius: 0;
transition: background-color 0.3s;
}
.drawer-menu :deep(.ant-menu-submenu-title:hover) {
background-color: #f0f7ff;
}
.drawer-menu :deep(.ant-menu-submenu-selected > .ant-menu-submenu-title) {
color: var(--primary-color);
font-weight: 500;
}
.drawer-menu :deep(.ant-menu-submenu-arrow) {
font-size: 12px !important;
}
.drawer-menu :deep(.ant-menu-submenu .ant-menu-item) {
padding-left: 48px !important;
padding-left: 42px !important;
font-size: 14px;
}
/* RESPONSIVE */
/* Drawer auth */
.drawer-auth {
margin-top: auto;
padding: 16px 20px;
border-top: 1px solid var(--border-color);
background: #fff;
}
/* RESPONSIVE HELPERS */
.desktop-only {
display: flex;
}
.mobile-only {
display: none;
}
/* TABLET (768px - 992px) */
@media (max-width: 992px) {
.modern-header {
padding: 0 16px !important;
}
.header-container {
padding: 0 8px;
}
.logo-text h1 {
font-size: 14px !important;
}
.logo-text span {
font-size: 12px !important;
}
.nav-menu-modern :deep(.ant-menu-item),
.nav-menu-modern :deep(.ant-menu-submenu) {
padding: 0 12px;
font-size: 0.9rem;
padding: 0 10px;
font-size: 0.92rem;
}
}
/* MOBILE (< 768px) */
@media (max-width: 768px) {
.header-container {
grid-template-columns: auto 1fr auto;
gap: 10px;
}
.desktop-only {
display: none;
}
.mobile-only {
display: inline-flex;
}
.modern-header {
height: 72px !important;
line-height: 72px !important;
}
.header-container {
padding: 0;
}
.university-logo {
min-width: auto;
gap: 10px;
}
.logo-icon {
width: 40px;
height: 40px;
}
.logo-text h1 {
font-size: 13px !important;
line-height: 1.2;
line-height: 1.1;
}
.logo-text span {
font-size: 11px !important;
}
}
/* SMALL MOBILE (< 480px) */
@media (max-width: 480px) {
.modern-header {
padding: 0 12px !important;
}
.logo-text h1 {
font-size: 12px !important;
}
.logo-text span {
display: none;
}
.mobile-menu-btn {
font-size: 20px;
padding: 4px;
}
.drawer-menu :deep(.ant-menu-submenu-title),
.drawer-menu :deep(.ant-menu-item) {
height: 44px;
line-height: 44px;
font-size: 14px;
}
.drawer-menu :deep(.ant-menu-submenu .ant-menu-item) {
height: 40px;
line-height: 40px;
font-size: 13px;
}
}
/* IMPRESIÓN */
@media print {
.modern-header {
position: static;
box-shadow: none;
border-bottom: 2px solid #000;
}
.mobile-menu-btn {
display: none !important;
padding: 6px;
}
}
</style>

@ -32,6 +32,12 @@ const routes = [
name: 'DashboardPostulante',
component: () => import('../views/postulante/Dashboard.vue'),
meta: { requiresAuth: true}
},
{
path: '/portal-postulante/test',
name: 'TestPostulante',
component: () => import('../views/postulante/Test.vue'),
meta: { requiresAuth: true}
},
{
path: '/portal-postulante/examen/:examenId',
@ -52,6 +58,12 @@ const routes = [
component: () => import('../views/postulante/Pagos.vue'),
meta: { requiresAuth: true }
},
{
path: '/portal-postulante/mis-procesos',
name: 'PanelProcesos',
component: () => import('../views/postulante/MisProcesos.vue'),
meta: { requiresAuth: true }
},
]

@ -1,355 +1,609 @@
<template>
<a-card title="Mi Examen" :loading="examenStore.cargando">
<!-- Estado de carga -->
<template v-if="examenStore.cargando && !examenStore.examenActual">
<a-skeleton active />
</template>
<!-- Si no hay examen asignado -->
<div v-else-if="!examenStore.examenActual" class="no-examen">
<a-empty description="No tienes un examen asignado actualmente">
<template #extra>
<a-button type="primary" @click="showModal = true">
Asignar Examen
</a-button>
</template>
</a-empty>
</div>
<!-- Si hay examen -->
<div v-else class="examen-info">
<a-descriptions title="Información del Examen" bordered>
<a-descriptions-item label="Proceso">
{{ examenStore.examenActual.proceso?.nombre || 'No asignado' }}
</a-descriptions-item>
<a-descriptions-item label="Área">
{{ examenStore.examenActual.area?.nombre || 'No asignado' }}
</a-descriptions-item>
<a-descriptions-item label="Intentos realizados">
{{ examenStore.examenActual.intentos || 0 }}
</a-descriptions-item>
<a-descriptions-item label="Estado del examen">
<a-tag :color="getEstadoColor">
{{ getEstadoTexto }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="Pago" v-if="examenStore.examenActual.pagado">
<a-tag color="green">Pagado ({{ examenStore.examenActual.tipo_pago }})</a-tag>
</a-descriptions-item>
</a-descriptions>
<div class="actions-container" style="margin-top: 24px;">
<!-- Botón Seleccionar Área (solo si no tiene área) -->
<a-button
v-if="!examenStore.examenActual.area"
type="primary"
@click="showModal = true"
style="margin-right: 8px;"
>
Seleccionar Área
</a-button>
<!-- Botón Iniciar Examen (solo si hay examen y no hay intentos) -->
<a-button
v-if="examenStore.examenActual && (!examenStore.examenActual.intentos || examenStore.examenActual.intentos === 0)"
type="primary"
:loading="iniciandoExamen"
@click="irAlExamen"
style="margin-right: 8px;"
>
Iniciar Examen
</a-button>
<!-- Botón Ver Resultados (solo si hay intentos) -->
<a-button
v-if="examenStore.examenActual?.intentos > 0"
type="default"
@click="verResultado"
>
Ver Resultados
</a-button>
</div>
</div>
<!-- Modal para seleccionar área -->
<a-modal
v-model:visible="showModal"
title="Seleccionar Área de Examen"
:confirm-loading="creandoExamen"
@ok="crearExamen"
@cancel="resetModal"
:mask-closable="false"
width="600px"
>
<a-form :model="formState" :rules="rules" layout="vertical">
<!-- Selección de Proceso -->
<a-form-item label="Proceso" name="proceso_id" required>
<a-select
v-model:value="formState.proceso_id"
placeholder="Seleccione un proceso"
:options="procesoOptions"
@change="handleProcesoChange"
:loading="examenStore.cargando"
/>
</a-form-item>
<!-- Selección de Área -->
<a-form-item label="Área" name="area_proceso_id" required>
<a-select
v-model:value="formState.area_proceso_id"
placeholder="Seleccione un área"
:options="areaOptions"
:disabled="!formState.proceso_id"
:loading="examenStore.cargando"
/>
</a-form-item>
<!-- Información de pago (si el proceso lo requiere) -->
<div v-if="procesoRequierePago">
<a-alert
message="Este proceso requiere pago"
type="info"
show-icon
style="margin-bottom: 16px;"
/>
<a-form-item label="Tipo de Pago" name="tipo_pago" required>
<a-select
v-model:value="formState.tipo_pago"
placeholder="Seleccione tipo de pago"
:options="tipoPagoOptions"
/>
</a-form-item>
<a-form-item label="Código de Pago" name="codigo_pago" required>
<a-input
v-model:value="formState.codigo_pago"
placeholder="Ingrese el código de pago"
/>
</a-form-item>
</div>
</a-form>
</a-modal>
</a-card>
</template>
<script setup>
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useExamenStore } from '../../store/examen.store'
import { message, Modal } from 'ant-design-vue'
const router = useRouter()
const examenStore = useExamenStore()
// Estados reactivos
const showModal = ref(false)
const iniciandoExamen = ref(false)
const creandoExamen = ref(false)
// Formulario para crear examen
const formState = reactive({
proceso_id: undefined,
area_proceso_id: undefined,
tipo_pago: undefined,
codigo_pago: ''
})
// Reglas de validación
const rules = {
proceso_id: [{ required: true, message: 'Por favor seleccione un proceso' }],
area_proceso_id: [{ required: true, message: 'Por favor seleccione un área' }],
tipo_pago: [{
required: computed(() => procesoRequierePago.value),
message: 'Por favor seleccione tipo de pago'
}],
codigo_pago: [{
required: computed(() => procesoRequierePago.value),
message: 'Por favor ingrese código de pago'
}]
}
// Opciones para selects
const procesoOptions = computed(() => {
return examenStore.procesos.map(p => ({
value: p.id,
label: p.nombre,
requiere_pago: p.requiere_pago
}))
})
const areaOptions = computed(() => {
return examenStore.areas.map(a => ({
value: a.area_proceso_id,
label: a.nombre
}))
})
const tipoPagoOptions = [
{ value: 'pyto_peru', label: 'Pago por Pytoperú' },
{ value: 'banco_nacion', label: 'Banco de la Nación' },
{ value: 'caja', label: 'Caja' }
]
// Computed properties
const procesoRequierePago = computed(() => {
const proceso = examenStore.procesos.find(p => p.id === formState.proceso_id)
return proceso?.requiere_pago === 1
})
const getEstadoColor = computed(() => {
if (!examenStore.examenActual?.intentos || examenStore.examenActual.intentos === 0) {
return 'blue'
}
return 'green'
})
const getEstadoTexto = computed(() => {
if (!examenStore.examenActual?.intentos || examenStore.examenActual.intentos === 0) {
return 'Disponible'
}
return 'Completado'
})
// Métodos
const handleProcesoChange = async (procesoId) => {
formState.area_proceso_id = undefined
if (procesoId) {
await examenStore.fetchAreas(procesoId)
}
import { computed, onMounted, reactive, ref, onBeforeUnmount } from "vue";
import { message, Modal } from "ant-design-vue";
const loading = ref(false);
const nowTick = ref(Date.now());
let timer = null;
const state = reactive({
applicant: { id: null, nombres: "Postulante", documento: "—" },
applications: [],
eligibility: {
isEligibleToApply: false,
reasons: [],
hasTestAssigned: true,
testStatus: "PENDIENTE", // PENDIENTE | EN_PROGRESO | COMPLETADO | NO_DISPONIBLE
testAvailableAt: null,
testExpiresAt: null,
testUrl: null,
},
availableProcesses: [],
});
/** ---------------------------
* Helpers tiempo (sin dayjs)
* --------------------------- */
function parseDate(val) {
if (!val) return null;
// soporta "YYYY-MM-DD HH:mm" o ISO
const iso = val.includes("T") ? val : val.replace(" ", "T");
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d;
}
function msToHuman(ms) {
if (ms <= 0) return "0m";
const totalMin = Math.floor(ms / 60000);
const d = Math.floor(totalMin / (60 * 24));
const h = Math.floor((totalMin % (60 * 24)) / 60);
const m = totalMin % 60;
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
const crearExamen = async () => {
/** ---------------------------
* Computed
* --------------------------- */
const totalApplications = computed(() => state.applications.length);
const applicationsByStatus = computed(() => {
const map = {};
for (const a of state.applications) map[a.status] = (map[a.status] || 0) + 1;
return map;
});
const eligibilityTag = computed(() => {
return state.eligibility.isEligibleToApply
? { color: "green", text: "Apto para postular" }
: { color: "red", text: "No apto para postular" };
});
const testStatusUi = computed(() => {
const s = state.eligibility.testStatus;
if (s === "EN_PROGRESO") return { color: "blue", text: "En progreso" };
if (s === "COMPLETADO") return { color: "green", text: "Completado" };
if (s === "NO_DISPONIBLE") return { color: "default", text: "No disponible" };
return { color: "orange", text: "Pendiente" };
});
const testCtaText = computed(() => {
const s = state.eligibility.testStatus;
if (s === "EN_PROGRESO") return "Continuar test";
if (s === "COMPLETADO") return "Test completado";
if (s === "NO_DISPONIBLE") return "No disponible";
return "Iniciar test";
});
const canStartTest = computed(() => {
const e = state.eligibility;
if (!e.hasTestAssigned) return false;
if (e.testStatus === "COMPLETADO") return false;
if (e.testStatus === "NO_DISPONIBLE") return false;
return true;
});
const testExpireMs = computed(() => {
const d = parseDate(state.eligibility.testExpiresAt);
if (!d) return null;
return d.getTime() - nowTick.value;
});
const expiresSoon = computed(() => {
// pronto si queda <= 24h
const ms = testExpireMs.value;
return ms !== null && ms > 0 && ms <= 24 * 60 * 60 * 1000;
});
const processColumns = [
{ title: "Proceso", dataIndex: "name", key: "name" },
{ title: "Inicio", dataIndex: "startDate", key: "startDate", width: 140 },
{ title: "Fin", dataIndex: "endDate", key: "endDate", width: 140 },
{ title: "Estado", dataIndex: "status", key: "status", width: 120 },
{ title: "Vacantes", dataIndex: "vacancies", key: "vacancies", width: 110 },
{ title: "Acciones", key: "actions", width: 220 },
];
/** ---------------------------
* API (reemplaza por tus endpoints)
* --------------------------- */
const api = {
async getDashboard() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
applicant: { id: 7, nombres: "Juan Pérez", documento: "DNI 12345678" },
applications: [
{ id: 1, processName: "Admisión 2025-II", status: "NO_APTO", createdAt: "2025-09-05" },
{ id: 2, processName: "Admisión 2026-I", status: "EN_REVISION", createdAt: "2026-02-03" },
{ id: 3, processName: "Admisión 2026-I", status: "APROBADO", createdAt: "2026-02-06" },
],
eligibility: {
isEligibleToApply: true,
reasons: [],
hasTestAssigned: true,
testStatus: "PENDIENTE",
testAvailableAt: "2026-02-10 09:00",
testExpiresAt: "2026-02-20 23:59",
testUrl: "/postulante/test",
},
availableProcesses: [
{ id: 10, name: "Admisión 2026-I", startDate: "2026-02-01", endDate: "2026-02-20", status: "ABIERTO", vacancies: 120, canApply: true },
{ id: 11, name: "Admisión Extraordinaria 2026", startDate: "2026-03-01", endDate: "2026-03-10", status: "PRONTO", vacancies: 40, canApply: false },
],
});
}, 350);
});
},
async startTest() {
return new Promise((resolve) => setTimeout(resolve, 300));
},
async applyToProcess() {
return new Promise((resolve) => setTimeout(resolve, 300));
},
};
/** ---------------------------
* Actions
* --------------------------- */
async function fetchDashboard() {
loading.value = true;
try {
creandoExamen.value = true
const pagoData = procesoRequierePago.value ? {
tipo_pago: formState.tipo_pago,
codigo_pago: formState.codigo_pago
} : null
const result = await examenStore.crearExamen(
formState.area_proceso_id,
pagoData
)
if (result.success) {
message.success('Examen creado correctamente')
showModal.value = false
resetModal()
// Recargar el examen actual
await examenStore.fetchExamenActual()
} else {
message.error(result.message || 'Error al crear examen')
}
} catch (error) {
message.error('Error al crear examen')
const data = await api.getDashboard();
state.applicant = data.applicant;
state.applications = data.applications;
state.eligibility = data.eligibility;
state.availableProcesses = data.availableProcesses;
} catch {
message.error("No se pudo cargar el dashboard.");
} finally {
creandoExamen.value = false
loading.value = false;
}
}
const irAlExamen = async () => {
if (!examenStore.examenActual?.id) {
message.error('No hay examen para iniciar')
return
}
try {
iniciandoExamen.value = true
// Primero intentar generar preguntas
const generarResult = await examenStore.generarPreguntas(examenStore.examenActual.id)
if (!generarResult.success) {
// Si hay error real, mostrar mensaje
if (!generarResult.ya_tiene_preguntas) {
message.error(generarResult.message || 'Error al generar preguntas')
return
async function onStartTest() {
if (!canStartTest.value) return;
Modal.confirm({
title: "Test de Admisión",
content: "Al iniciar o continuar, el tiempo puede comenzar a correr según la configuración. ¿Deseas continuar?",
okText: "Sí",
cancelText: "Cancelar",
async onOk() {
try {
await api.startTest();
message.success("Listo.");
if (state.eligibility.testUrl) window.location.href = state.eligibility.testUrl;
} catch {
message.error("No se pudo iniciar el test.");
}
// Si ya tiene preguntas, es un éxito (continuar)
}
// Luego iniciar el examen (esto carga las preguntas)
const iniciarResult = await examenStore.iniciarExamen(examenStore.examenActual.id)
if (iniciarResult.success) {
// Redirigir al panel de examen
router.push({ name: 'PanelExamen', params: { examenId: examenStore.examenActual.id } })
} else {
message.error(iniciarResult.message || 'Error al iniciar examen')
}
} catch (error) {
message.error('Error al procesar la solicitud')
console.error(error)
} finally {
iniciandoExamen.value = false
}
},
});
}
const verResultado = async () => {
// Actualizar intentos del examen
await examenStore.fetchExamenActual()
// Redirigir a resultados
router.push({
name: 'PanelResultados',
params: { examenId: examenStore.examenActual?.id }
})
async function onApply(process) {
if (!process.canApply) return;
Modal.confirm({
title: "Confirmar postulación",
content: `¿Deseas postular al proceso "${process.name}"?`,
okText: "Postular",
cancelText: "Cancelar",
async onOk() {
try {
await api.applyToProcess(process.id);
message.success("Postulación registrada.");
await fetchDashboard();
} catch {
message.error("No se pudo completar la postulación.");
}
},
});
}
const resetModal = () => {
Object.keys(formState).forEach(key => {
if (key === 'proceso_id' || key === 'area_proceso_id') {
formState[key] = undefined
} else if (key === 'codigo_pago') {
formState[key] = ''
} else {
formState[key] = undefined
}
})
function onViewProcess(process) {
message.info(`Abrir detalle del proceso: ${process.name}`);
}
// Lifecycle
onMounted(async () => {
// Cargar procesos disponibles
await examenStore.fetchProcesos()
// Cargar examen actual
await examenStore.fetchExamenActual()
})
// Watch para limpiar áreas cuando se cierra el modal
watch(showModal, (newVal) => {
if (!newVal) {
examenStore.areas = []
}
})
await fetchDashboard();
timer = setInterval(() => (nowTick.value = Date.now()), 1000);
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
</script>
<template>
<a-spin :spinning="loading">
<a-space direction="vertical" size="large" style="width: 100%">
<!-- Encabezado del postulante -->
<a-card class="soft-card">
<div class="header-row">
<div>
<div class="h-title">Bienvenido, {{ state.applicant.nombres }}</div>
<div class="h-sub">{{ state.applicant.documento }}</div>
</div>
<div class="header-actions">
<a-tag :color="eligibilityTag.color" class="pill-tag">{{ eligibilityTag.text }}</a-tag>
</div>
</div>
<a-alert
v-if="!state.eligibility.isEligibleToApply && state.eligibility.reasons?.length"
type="warning"
show-icon
message="Requisitos pendientes"
style="margin-top: 14px"
>
<template #description>
<ul style="margin: 8px 0 0 18px">
<li v-for="(r, idx) in state.eligibility.reasons" :key="idx">{{ r }}</li>
</ul>
</template>
</a-alert>
</a-card>
<!-- HERO: TEST DE ADMISIÓN (MUY RESALTADO) -->
<a-badge-ribbon
:text="state.eligibility.testStatus === 'COMPLETADO' ? 'LISTO' : 'IMPORTANTE'"
:color="state.eligibility.testStatus === 'COMPLETADO' ? 'green' : 'red'"
>
<a-card class="test-hero" :bordered="false">
<div class="test-grid">
<div>
<div class="test-kicker">Test de admisión</div>
<div class="test-title">Tu evaluación está aquí</div>
<div class="test-meta">
<a-tag :color="testStatusUi.color" class="pill-tag">
Estado: {{ testStatusUi.text }}
</a-tag>
<a-tag v-if="expiresSoon" color="volcano" class="pill-tag">
Vence pronto: {{ msToHuman(testExpireMs || 0) }}
</a-tag>
<a-tag v-else-if="testExpireMs !== null && testExpireMs > 0" color="blue" class="pill-tag">
Tiempo restante: {{ msToHuman(testExpireMs) }}
</a-tag>
</div>
<div class="test-dates" v-if="state.eligibility.testAvailableAt || state.eligibility.testExpiresAt">
<div v-if="state.eligibility.testAvailableAt">
<span class="muted">Disponible desde:</span> <b>{{ state.eligibility.testAvailableAt }}</b>
</div>
<div v-if="state.eligibility.testExpiresAt">
<span class="muted">Fecha límite:</span> <b>{{ state.eligibility.testExpiresAt }}</b>
</div>
</div>
<a-alert
v-if="state.eligibility.testStatus === 'COMPLETADO'"
type="success"
show-icon
message="Tu test ya fue completado. ¡Bien!"
style="margin-top: 12px"
/>
<a-alert
v-else-if="!state.eligibility.hasTestAssigned"
type="info"
show-icon
message="Aún no tienes un test asignado."
style="margin-top: 12px"
/>
<a-alert
v-else-if="!state.eligibility.isEligibleToApply"
type="warning"
show-icon
message="No estás apto para postular por ahora. Revisa los requisitos pendientes."
style="margin-top: 12px"
/>
</div>
<div class="test-cta">
<div class="cta-box">
<div class="cta-label">Acción</div>
<a-button
type="primary"
size="large"
block
class="cta-btn"
:disabled="!canStartTest"
@click="onStartTest"
>
{{ testCtaText }}
</a-button>
<div class="cta-hint" v-if="canStartTest">
Entra cuando estés listo. Si estás en progreso, puedes continuar.
</div>
<div class="cta-hint" v-else>
No disponible por el momento.
</div>
<a-divider style="margin: 14px 0" />
<div class="cta-stats">
<div class="stat">
<div class="stat-num">{{ totalApplications }}</div>
<div class="stat-txt">Postulaciones</div>
</div>
<div class="stat">
<div class="stat-num">
{{ state.eligibility.isEligibleToApply ? "Sí" : "No" }}
</div>
<div class="stat-txt">Apto</div>
</div>
</div>
</div>
</div>
</div>
</a-card>
</a-badge-ribbon>
<!-- KPIs secundarios (más limpios) -->
<a-row :gutter="[16, 16]">
<a-col :xs="24" :md="12">
<a-card class="soft-card" title="Resumen de postulaciones">
<div class="kpi-row">
<div class="kpi">
<div class="kpi-label">Veces que postuló</div>
<div class="kpi-value">{{ totalApplications }}</div>
</div>
<div class="kpi-tags">
<a-tag v-for="(v, k) in applicationsByStatus" :key="k">
{{ k }}: <b>{{ v }}</b>
</a-tag>
<a-tag v-if="!Object.keys(applicationsByStatus).length">Sin registros</a-tag>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :md="12">
<a-card class="soft-card" title="Estado del postulante">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="Aptitud">
<a-tag :color="eligibilityTag.color" class="pill-tag">{{ eligibilityTag.text }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="Test">
<a-tag :color="testStatusUi.color" class="pill-tag">{{ testStatusUi.text }}</a-tag>
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
</a-row>
<!-- Procesos disponibles -->
<a-card class="soft-card" title="Procesos disponibles">
<a-table
:columns="processColumns"
:data-source="state.availableProcesses"
row-key="id"
:pagination="{ pageSize: 6 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'ABIERTO' ? 'green' : record.status === 'PRONTO' ? 'blue' : 'default'">
{{ record.status }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button @click="onViewProcess(record)">Ver</a-button>
<a-button
type="primary"
:disabled="!record.canApply || !state.eligibility.isEligibleToApply"
@click="onApply(record)"
>
Postular
</a-button>
</a-space>
<div v-if="!state.eligibility.isEligibleToApply" class="mini-help">
Debes estar apto para postular.
</div>
</template>
</template>
</a-table>
</a-card>
<!-- Historial -->
<a-card class="soft-card" title="Historial de postulaciones">
<a-list
:data-source="state.applications"
:locale="{ emptyText: 'Aún no tienes postulaciones registradas.' }"
>
<template #renderItem="{ item }">
<a-list-item class="list-item">
<a-list-item-meta>
<template #title>
<div class="list-title">
<span class="list-title-text">{{ item.processName }}</span>
<a-tag>{{ item.status }}</a-tag>
</div>
</template>
<template #description>
<span class="muted">Postuló el:</span> {{ item.createdAt }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-space>
</a-spin>
</template>
<style scoped>
.no-examen {
text-align: center;
padding: 40px 0;
/* Cards */
.soft-card {
border-radius: 14px;
}
.pill-tag {
border-radius: 999px;
padding: 4px 10px;
font-weight: 600;
}
.examen-info {
padding: 16px 0;
/* Header */
.header-row {
display: flex;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
align-items: center;
}
.h-title {
font-size: 18px;
font-weight: 700;
}
.h-sub {
opacity: 0.75;
margin-top: 4px;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.actions-container {
/* TEST HERO */
.test-hero {
border-radius: 18px;
overflow: hidden;
background: linear-gradient(135deg, rgba(24, 144, 255, 0.14), rgba(82, 196, 26, 0.10));
}
.test-grid {
display: grid;
grid-template-columns: 1.4fr 0.8fr;
gap: 16px;
}
@media (max-width: 992px) {
.test-grid {
grid-template-columns: 1fr;
}
}
.test-kicker {
font-weight: 700;
letter-spacing: 0.2px;
opacity: 0.85;
}
.test-title {
font-size: 26px;
font-weight: 800;
margin-top: 6px;
line-height: 1.15;
}
.test-meta {
margin-top: 12px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.test-dates {
margin-top: 10px;
display: grid;
gap: 6px;
}
.muted {
opacity: 0.75;
}
/* CTA box */
.test-cta {
display: flex;
align-items: stretch;
}
.cta-box {
width: 100%;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 16px;
padding: 16px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
}
.cta-label {
font-weight: 700;
opacity: 0.85;
margin-bottom: 10px;
}
.cta-btn {
height: 44px;
border-radius: 12px;
font-weight: 800;
}
.cta-hint {
margin-top: 10px;
font-size: 12px;
opacity: 0.75;
line-height: 1.35;
}
.cta-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat {
border-radius: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.05);
}
.stat-num {
font-size: 18px;
font-weight: 900;
}
.stat-txt {
font-size: 12px;
opacity: 0.75;
}
:deep(.ant-descriptions) {
margin-bottom: 16px;
/* KPI */
.kpi-row {
display: grid;
gap: 10px;
}
.kpi {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.kpi-label {
opacity: 0.75;
}
.kpi-value {
font-size: 26px;
font-weight: 900;
}
.kpi-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
:deep(.ant-descriptions-item-label) {
font-weight: 600;
width: 180px;
/* List */
.list-item {
border-radius: 12px;
}
.list-title {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.list-title-text {
font-weight: 700;
}
.mini-help {
margin-top: 6px;
font-size: 12px;
opacity: 0.7;
}
</style>
</style>

@ -2,315 +2,544 @@
<div class="auth-wrapper">
<a-row justify="center" align="middle" class="full-height">
<a-col :xs="22" :sm="20" :md="20" :lg="16" :xl="14">
<a-row :gutter="24">
<!-- CARD FORMULARIO -->
<a-col :xs="24" :md="12">
<a-card class="auth-card" :bordered="false">
<!-- Header -->
<div class="auth-header">
<a-typography-title :level="3" style="margin-bottom: 8px">
{{ isRegister ? 'Crear Cuenta' : 'Iniciar Sesión' }}
</a-typography-title>
<a-typography-text type="secondary">
{{ isRegister
? 'Complete el formulario para registrarse'
: 'Ingrese sus credenciales para continuar'
}}
</a-typography-text>
</div>
<a-divider />
<!-- Formulario -->
<a-form
ref="formRef"
:model="formState"
:rules="rules"
@finish="handleSubmit"
layout="vertical"
>
<!-- DNI -->
<a-form-item v-if="isRegister" label="DNI" name="dni">
<a-input v-model:value="formState.dni" size="large">
<template #prefix><IdcardOutlined /></template>
</a-input>
</a-form-item>
<!-- Nombre -->
<a-form-item v-if="isRegister" label="Nombre completo" name="name">
<a-input v-model:value="formState.name" size="large">
<template #prefix><UserOutlined /></template>
</a-input>
</a-form-item>
<!-- Email -->
<a-form-item label="Correo electrónico" name="email">
<a-input v-model:value="formState.email" size="large">
<template #prefix><MailOutlined /></template>
</a-input>
</a-form-item>
<!-- Password -->
<a-form-item label="Contraseña" name="password">
<a-input-password v-model:value="formState.password" size="large">
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<!-- Confirm Password -->
<a-form-item
v-if="isRegister"
label="Confirmar contraseña"
name="password_confirmation"
<div class="auth-shell">
<a-row :gutter="[0, 0]" class="auth-layout">
<!-- PANEL IZQUIERDO: FORM -->
<a-col :xs="24" :md="12" class="auth-pane auth-pane-form">
<div class="pane-inner">
<!-- Branding -->
<div class="brand">
<div class="brand-mark">
<img
:src="logoSrc"
alt="Logo institucional"
class="brand-logo"
@error="logoError = true"
v-show="!logoError"
/>
<span v-if="logoError" class="brand-initials">UNA</span>
</div>
<div class="brand-text">
<div class="brand-name">Plataforma de Postulantes</div>
<div class="brand-sub">Admisión 2026</div>
</div>
</div>
<div class="auth-header">
<a-typography-title :level="3" style="margin: 0">
{{ isRegister ? "Crear Cuenta" : "Iniciar Sesión" }}
</a-typography-title>
<a-typography-text type="secondary">
{{
isRegister
? "Complete el formulario para registrarse"
: "Ingrese sus credenciales para continuar"
}}
</a-typography-text>
</div>
<a-divider class="auth-divider" />
<a-form
ref="formRef"
:model="formState"
:rules="rules"
@finish="handleSubmit"
layout="vertical"
>
<a-input-password
v-model:value="formState.password_confirmation"
size="large"
<!-- DNI -->
<a-form-item v-if="isRegister" label="DNI" name="dni">
<a-input v-model:value="formState.dni" size="large" placeholder="Ingrese su DNI">
<template #prefix><IdcardOutlined /></template>
</a-input>
</a-form-item>
<!-- Nombre -->
<a-form-item v-if="isRegister" label="Nombre completo" name="name">
<a-input
v-model:value="formState.name"
size="large"
placeholder="Ingrese su nombre completo"
>
<template #prefix><UserOutlined /></template>
</a-input>
</a-form-item>
<!-- Email -->
<a-form-item label="Correo electrónico" name="email">
<a-input v-model:value="formState.email" size="large" placeholder="correo@ejemplo.com">
<template #prefix><MailOutlined /></template>
</a-input>
</a-form-item>
<!-- Password -->
<a-form-item label="Contraseña" name="password">
<a-input-password
v-model:value="formState.password"
size="large"
placeholder="Ingrese su contraseña"
>
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<!-- Confirm Password -->
<a-form-item
v-if="isRegister"
label="Confirmar contraseña"
name="password_confirmation"
>
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<!-- Recordarme -->
<a-row v-if="!isRegister" justify="space-between" align="middle">
<a-checkbox v-model:checked="rememberMe">
Recordarme
</a-checkbox>
<a-button type="link" size="small" @click="handleForgotPassword">
¿Olvidó su contraseña?
</a-button>
</a-row>
<a-form-item style="margin-top: 24px">
<a-button
type="primary"
html-type="submit"
block
size="large"
:loading="loading"
<a-input-password
v-model:value="formState.password_confirmation"
size="large"
placeholder="Repita su contraseña"
>
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<!-- Recordarme -->
<a-row
v-if="!isRegister"
justify="space-between"
align="middle"
class="form-row"
>
{{ isRegister ? 'Registrarse' : 'Iniciar Sesión' }}
<a-checkbox v-model:checked="rememberMe">Recordarme</a-checkbox>
<a-button
type="link"
size="small"
class="link-muted"
@click="handleForgotPassword"
>
¿Olvidó su contraseña?
</a-button>
</a-row>
<a-form-item class="submit-wrap">
<a-button type="primary" html-type="submit" block size="large" :loading="loading">
{{ isRegister ? "Registrarse" : "Iniciar Sesión" }}
</a-button>
</a-form-item>
</a-form>
<div class="bottom-toggle">
<a-typography-text type="secondary">
{{ isRegister ? "¿Ya tiene una cuenta?" : "¿No tiene una cuenta?" }}
</a-typography-text>
<a-button type="link" class="toggle-link" @click="toggleMode">
{{ isRegister ? "Iniciar sesión" : "Registrarse" }}
</a-button>
</a-form-item>
</a-form>
<a-divider />
<!-- Toggle -->
<div style="text-align:center">
<a-typography-text type="secondary">
{{ isRegister
? '¿Ya tiene una cuenta?'
: '¿No tiene una cuenta?'
}}
</a-typography-text>
<br />
<a-button type="link" @click="toggleMode">
{{ isRegister ? 'Iniciar sesión' : 'Registrarse' }}
</a-button>
</div>
</div>
</a-card>
</a-col>
<!-- CARD DERECHO (MISMO DISEÑO) -->
<a-col :xs="24" :md="12">
<a-card class="auth-card" :bordered="false">
<div class="auth-header">
<a-typography-title :level="3" style="margin-bottom: 8px">
Información
</a-typography-title>
<a-typography-text type="secondary">
Plataforma de postulantes
</a-typography-text>
</a-col>
<!-- PANEL DERECHO: INFO (CORTO Y DINÁMICO) -->
<a-col :xs="24" :md="12" class="auth-pane auth-pane-info">
<div class="pane-inner pane-inner-info">
<div class="info-top">
<a-tag class="info-tag">Universidad Nacional del Altiplano Puno</a-tag>
<a-typography-title :level="3" style="margin: 8px 0 0">
{{ isRegister ? "Registro de Postulante" : "Portal del Postulante" }}
</a-typography-title>
<a-typography-text type="secondary">
{{
isRegister
? "Crea tu cuenta para iniciar tu inscripción al proceso de admisión."
: "Inicia sesión para continuar tu inscripción y consultar tu estado."
}}
</a-typography-text>
</div>
<div class="info-section">
<div class="info-section-title">
{{ isRegister ? "Al registrarte podrás" : "Al ingresar podrás" }}
</div>
<div class="info-list">
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Completar tu postulación</b> (modalidad, sede y programa).</span>
</div>
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Subir requisitos</b> y revisar observaciones.</span>
</div>
<div class="info-item" v-if="!isRegister">
<span class="info-bullet"></span>
<span><b>Ver comunicados y resultados</b> del proceso.</span>
</div>
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Consultar tu estado</b> de inscripción/admisión.</span>
</div>
</div>
</div>
<div class="info-foot">
<a-typography-text type="secondary">
Soporte: Mesa de ayuda Atención en horario institucional
</a-typography-text>
</div>
</div>
<a-divider />
<a-typography-paragraph>
Accede a tu examen virtual.
</a-typography-paragraph>
<a-typography-paragraph>
Consulta tus resultados.
</a-typography-paragraph>
<a-typography-paragraph>
Revisa tu estado de admisión.
</a-typography-paragraph>
<a-typography-paragraph>
Actualiza tu información personal.
</a-typography-paragraph>
</a-card>
</a-col>
</a-row>
</a-col>
</a-row>
</div>
</a-col>
</a-row>
</div>
</template>
<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../store/postulanteStore'
import { UserOutlined, MailOutlined, LockOutlined, IdcardOutlined } from '@ant-design/icons-vue'
import { notification } from 'ant-design-vue'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref()
const isRegister = ref(false)
const rememberMe = ref(false)
const loading = ref(false)
import { ref, reactive, watch, computed } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../../store/postulanteStore";
import { UserOutlined, MailOutlined, LockOutlined, IdcardOutlined } from "@ant-design/icons-vue";
import { notification } from "ant-design-vue";
const router = useRouter();
const authStore = useAuthStore();
const formRef = ref();
const isRegister = ref(false);
const rememberMe = ref(false);
const loading = ref(false);
/**
* Logo en /public (recomendado):
* public/logotiny.png -> "/logotiny.png"
*/
const logoSrc = "/logotiny.png";
const logoError = ref(false);
const formState = reactive({
name: '',
email: '',
password: '',
password_confirmation: '',
dni: ''
})
name: "",
email: "",
password: "",
password_confirmation: "",
dni: "",
});
notification.config({
placement: 'top',
placement: "top",
duration: 3,
maxCount: 3,
})
});
watch(isRegister, () => {
formRef.value?.clearValidate()
})
formRef.value?.clearValidate();
});
const rules = computed(() => ({
dni: [
{ required: isRegister.value, message: 'Ingrese su DNI', trigger: 'blur' },
{ pattern: /^\d{8}$/, message: 'El DNI debe tener 8 dígitos', trigger: 'blur' }
{ required: isRegister.value, message: "Ingrese su DNI", trigger: "blur" },
{ pattern: /^\d{8}$/, message: "El DNI debe tener 8 dígitos", trigger: "blur" },
],
name: [
{ required: isRegister.value, message: 'Ingrese su nombre completo', trigger: 'blur' },
{ min: 3, message: 'El nombre debe tener al menos 3 caracteres', trigger: 'blur' }
{ required: isRegister.value, message: "Ingrese su nombre completo", trigger: "blur" },
{ min: 3, message: "El nombre debe tener al menos 3 caracteres", trigger: "blur" },
],
email: [
{ required: true, message: 'Ingrese su correo electrónico', trigger: 'blur' },
{ type: 'email', message: 'Ingrese un correo electrónico válido', trigger: 'blur' }
{ required: true, message: "Ingrese su correo electrónico", trigger: "blur" },
{ type: "email", message: "Ingrese un correo electrónico válido", trigger: "blur" },
],
password: [
{ required: true, message: 'Ingrese su contraseña', trigger: 'blur' },
{ min: 6, message: 'La contraseña debe tener al menos 6 caracteres', trigger: 'blur' }
{ required: true, message: "Ingrese su contraseña", trigger: "blur" },
{ min: 6, message: "La contraseña debe tener al menos 6 caracteres", trigger: "blur" },
],
password_confirmation: [
{ required: isRegister.value, message: 'Confirme su contraseña', trigger: 'blur' },
{ required: isRegister.value, message: "Confirme su contraseña", trigger: "blur" },
{
validator: (rule, value) => {
if (!isRegister.value) return Promise.resolve()
if (!value) return Promise.reject('Confirme su contraseña')
if (value !== formState.password) return Promise.reject('Las contraseñas no coinciden')
return Promise.resolve()
if (!isRegister.value) return Promise.resolve();
if (!value) return Promise.reject("Confirme su contraseña");
if (value !== formState.password) return Promise.reject("Las contraseñas no coinciden");
return Promise.resolve();
},
trigger: 'blur'
}
]
}))
trigger: "blur",
},
],
}));
const showNotification = (type, message, description = '') => {
notification[type]({ message, description, duration: 3 })
}
const showNotification = (type, message, description = "") => {
notification[type]({ message, description, duration: 3 });
};
const toggleMode = () => {
isRegister.value = !isRegister.value
Object.keys(formState).forEach(key => formState[key] = '')
formRef.value?.clearValidate()
}
isRegister.value = !isRegister.value;
Object.keys(formState).forEach((key) => (formState[key] = ""));
formRef.value?.clearValidate();
};
const handleForgotPassword = () => {
showNotification('info', 'Recuperación de contraseña', 'Por favor, contacte al administrador del sistema')
}
showNotification("info", "Recuperación de contraseña", "Por favor, contacte al administrador del sistema");
};
const handleSubmit = async () => {
loading.value = true
loading.value = true;
try {
if (isRegister.value) {
const result = await authStore.register({ ...formState })
const result = await authStore.register({ ...formState });
if (result.success) {
showNotification('success', '¡Registro exitoso!', 'Tu cuenta ha sido creada correctamente')
toggleMode()
showNotification("success", "¡Registro exitoso!", "Tu cuenta ha sido creada correctamente");
toggleMode();
} else {
showNotification('error', 'Error en registro', result.error)
showNotification("error", "Error en registro", result.error);
}
} else {
const result = await authStore.login({
email: formState.email,
password: formState.password,
device_id: rememberMe.value ? localStorage.getItem('device_id') : null
})
device_id: rememberMe.value ? localStorage.getItem("device_id") : null,
});
if (result.success) {
showNotification('success', '¡Bienvenido!', `Hola ${authStore.userName || ''}`)
router.push('/dashboard')
showNotification("success", "¡Bienvenido!", `Hola ${authStore.userName || ""}`);
router.push("/portal-postulante");
} else {
showNotification('error', 'Error en login', result.error)
showNotification("error", "Error en login", result.error);
}
}
} catch (error) {
showNotification('error', 'Error', error.response?.data?.message || 'Ocurrió un error inesperado')
showNotification("error", "Error", error.response?.data?.message || "Ocurrió un error inesperado");
} finally {
loading.value = false
loading.value = false;
}
}
};
const checkExistingAuth = async () => {
if (authStore.isAuthenticated) {
const isValid = await authStore.checkAuth()
if (isValid) router.push('/dashboard-postulante')
const isValid = await authStore.checkAuth();
if (isValid) router.push("/portal-postulante");
}
}
checkExistingAuth()
};
checkExistingAuth();
</script>
<style scoped>
.auth-wrapper,
.auth-wrapper * {
font-family: "Times New Roman", Times, serif;
}
.auth-wrapper {
min-height: 100vh;
background: linear-gradient(135deg, #1890ff, #722ed1);
background: var(--ant-colorBgLayout, #f5f5f5);
padding: 40px 16px;
}
.full-height {
min-height: 100vh;
min-height: calc(100vh - 80px);
}
.auth-shell {
border-radius: 20px;
overflow: hidden;
background: var(--ant-colorBgContainer, #ffffff);
border: 1px solid var(--ant-colorBorderSecondary, rgba(0, 0, 0, 0.06));
box-shadow: var(--ant-boxShadowSecondary, 0 18px 48px rgba(0, 0, 0, 0.14));
}
.auth-layout {
min-height: 520px;
}
.auth-pane {
padding: 28px;
}
.auth-pane-form {
background: var(--ant-colorBgContainer, #ffffff);
}
.auth-pane-info {
background: var(--ant-colorFillAlter, #fafafa);
border-left: 1px solid var(--ant-colorBorderSecondary, rgba(0, 0, 0, 0.06));
}
.pane-inner {
height: 100%;
display: flex;
flex-direction: column;
gap: 14px;
}
.pane-inner-info {
justify-content: space-between;
}
/* Branding */
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.brand-mark {
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--ant-colorBgElevated, #fff);
border: 1px solid var(--ant-colorBorderSecondary, rgba(0, 0, 0, 0.06));
display: grid;
place-items: center;
overflow: hidden;
box-shadow: var(--ant-boxShadowSecondary, 0 10px 24px rgba(0, 0, 0, 0.12));
}
.brand-logo {
width: 100%;
height: 100%;
object-fit: contain;
padding: 6px;
}
.brand-initials {
color: var(--ant-colorPrimary, #1677ff);
font-weight: 800;
letter-spacing: 0.5px;
}
.brand-name {
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
line-height: 1.2;
}
.auth-card {
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
.brand-sub {
color: var(--ant-colorTextSecondary, #6b7280);
font-weight: 600;
font-size: 0.95rem;
}
.auth-header {
text-align: center;
margin-bottom: 16px;
text-align: left;
}
.auth-divider {
margin: 6px 0 14px;
}
:deep(.ant-input-affix-wrapper),
:deep(.ant-input-password) {
border-radius: 8px;
:deep(.ant-input-password),
:deep(.ant-input) {
border-radius: 12px;
}
:deep(.ant-form-item-label > label) {
font-weight: 700;
}
.form-row {
margin-top: 4px;
}
.link-muted {
padding: 0;
}
.submit-wrap {
margin-top: 10px;
margin-bottom: 0;
}
:deep(.ant-btn-primary) {
border-radius: 8px;
border-radius: 12px;
height: 48px;
font-weight: 800;
box-shadow: var(--ant-boxShadowSecondary, 0 12px 28px rgba(0, 0, 0, 0.14));
}
.bottom-toggle {
margin-top: auto;
padding-top: 6px;
}
.toggle-link {
padding: 0;
font-weight: 800;
}
/* Info panel corto */
.info-top {
text-align: left;
}
.info-tag {
border: 0;
background: color-mix(in srgb, var(--ant-colorPrimary) 14%, transparent);
color: var(--ant-colorPrimary, #1677ff);
font-weight: 800;
border-radius: 999px;
padding: 6px 12px;
}
.info-section {
margin-top: 8px;
}
.info-section-title {
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
margin: 6px 0 10px;
}
.info-list {
margin-top: 10px;
display: grid;
gap: 12px;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--ant-colorText, #374151);
font-weight: 600;
line-height: 1.45;
}
.info-bullet {
width: 10px;
height: 10px;
margin-top: 6px;
border-radius: 999px;
background: var(--ant-colorPrimary, #1677ff);
}
.info-foot {
padding-top: 10px;
border-top: 1px solid var(--ant-colorBorderSecondary, rgba(0, 0, 0, 0.06));
}
/* Responsive */
@media (max-width: 768px) {
.auth-pane {
padding: 22px;
}
.auth-pane-info {
border-left: none;
border-top: 1px solid var(--ant-colorBorderSecondary, rgba(0, 0, 0, 0.06));
}
.auth-layout {
min-height: auto;
}
}
/* Fallback si no hay color-mix */
@supports not (color: color-mix(in srgb, white 50%, black)) {
.info-tag {
background: rgba(22, 119, 255, 0.12);
}
}
</style>
</style>

@ -0,0 +1,222 @@
<template>
<a-card class="procesos-card" :bordered="false">
<template #title>
<div class="card-title">
<div class="title-left">
<div class="title-main">Mis procesos de admisión</div>
<div class="title-sub">Resultados registrados por DNI</div>
</div>
<div class="title-right">
<a-space>
<a-button @click="obtenerProcesos" :loading="loading">Actualizar</a-button>
</a-space>
</div>
</div>
</template>
<a-spin :spinning="loading">
<div class="top-summary">
<a-alert v-if="!loading" type="info" show-icon class="summary-alert">
Total de procesos: <strong>{{ procesos.length }}</strong>
</a-alert>
<a-input
v-model:value="search"
allow-clear
placeholder="Buscar por nombre de proceso..."
class="search-input"
/>
</div>
<a-table
class="procesos-table"
:dataSource="procesosFiltrados"
:columns="columns"
rowKey="id"
:pagination="{ pageSize: 7, showSizeChanger: false }"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<!-- Nombre -->
<template v-if="column.key === 'nombre'">
<div class="nombre">
{{ record.nombre || '-' }}
</div>
</template>
<!-- Puntaje -->
<template v-else-if="column.key === 'puntaje'">
<span class="puntaje">
{{ record.puntaje ?? '-' }}
</span>
</template>
<!-- Apto -->
<template v-else-if="column.key === 'apto'">
<a-tag :color="aptoColor(record.apto)" class="tag-pill">
{{ aptoTexto(record.apto) }}
</a-tag>
</template>
<!-- Acciones -->
<template v-else-if="column.key === 'acciones'">
<a-space>
<a-button size="small" @click="verDetalle(record)">Ver detalle</a-button>
</a-space>
</template>
</template>
<template #emptyText>
<a-empty description="No se encontraron procesos" />
</template>
</a-table>
</a-spin>
</a-card>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import api from '../../axiosPostulante' // ajusta la ruta a tu axios
const procesos = ref([])
const loading = ref(false)
const search = ref('')
const columns = [
{ title: 'Proceso', dataIndex: 'nombre', key: 'nombre', width: 420 },
{ title: 'Puntaje', dataIndex: 'puntaje', key: 'puntaje', width: 140 },
{ title: 'Estado', dataIndex: 'apto', key: 'apto', width: 160 },
{ title: 'Acciones', key: 'acciones', width: 160 }
]
const obtenerProcesos = async () => {
loading.value = true
try {
// Ruta: crea una ruta GET que apunte a misProcesos()
// Ejemplo: Route::get('/postulante/mis-procesos', ...)
const { data } = await api.get('/postulante/mis-procesos')
if (data?.success) {
procesos.value = Array.isArray(data.data) ? data.data : []
} else {
message.error('No se pudieron obtener los procesos')
}
} catch (e) {
console.error(e)
message.error(e.response?.data?.message || 'Error al cargar procesos')
} finally {
loading.value = false
}
}
const aptoTexto = (apto) => {
// en DB puede venir 1/0, true/false, "1"/"0"
if (apto === 1 || apto === true || apto === '1') return 'APTO'
if (apto === 0 || apto === false || apto === '0') return 'NO APTO'
return String(apto ?? '-').toUpperCase()
}
const aptoColor = (apto) => {
if (apto === 1 || apto === true || apto === '1') return 'green'
if (apto === 0 || apto === false || apto === '0') return 'red'
return 'default'
}
const procesosFiltrados = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return procesos.value
return procesos.value.filter(p =>
String(p.nombre || '').toLowerCase().includes(q)
)
})
const verDetalle = (record) => {
// Aquí puedes navegar a otra vista si tienes ruta
// router.push({ name: 'DetalleProceso', params: { procesoId: record.id } })
message.info(`Proceso: ${record.nombre} | Puntaje: ${record.puntaje ?? '-'}`)
}
onMounted(() => {
obtenerProcesos()
})
</script>
<style scoped>
.procesos-card {
max-width: 1100px;
margin: 20px auto;
border-radius: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.10);
background: #fff;
}
.card-title {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
align-items: flex-start;
}
.title-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.title-main {
font-weight: 900;
font-size: 18px;
color: #111827;
}
.title-sub {
font-weight: 650;
color: #6b7280;
font-size: 13px;
}
.top-summary {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 14px;
}
.summary-alert {
border-radius: 14px;
margin: 0;
flex: 1;
min-width: 280px;
}
.search-input {
max-width: 360px;
border-radius: 12px;
}
.nombre {
font-weight: 850;
color: #111827;
}
.puntaje {
font-weight: 900;
color: #1677ff;
}
.tag-pill {
border-radius: 999px;
font-weight: 800;
padding: 2px 10px;
}
.procesos-table :deep(.ant-table) {
border-radius: 14px;
overflow: hidden;
}
</style>

@ -1,79 +1,145 @@
<template>
<a-card class="pagos-card">
<a-card class="pagos-card" :bordered="false">
<template #title>
<span>Mis Pagos Realizados</span>
<div class="card-title">
<div class="title-left">
<div class="title-main">Mis Pagos Realizados</div>
<div class="title-sub">Historial de pagos registrados en el sistema</div>
</div>
<div class="title-right">
<a-space>
<a-button @click="obtenerPagos" :loading="loading">Actualizar</a-button>
</a-space>
</div>
</div>
</template>
<!-- Loading -->
<a-spin :spinning="loading">
<!-- Resumen superior -->
<div class="top-summary">
<a-alert type="info" show-icon class="summary-alert">
Total de pagos encontrados: <strong>{{ pagos.length }}</strong>
</a-alert>
<!-- Resumen -->
<a-alert
v-if="!loading"
type="info"
show-icon
class="mb-3"
>
Total de pagos encontrados: <strong>{{ pagos.length }}</strong>
</a-alert>
<a-input
v-model:value="search"
allow-clear
placeholder="Buscar por código, uso/proceso o tipo..."
class="search-input"
/>
</div>
<!-- Tabla -->
<a-table
:dataSource="pagos"
class="pagos-table"
:dataSource="pagosFiltrados"
:columns="columns"
rowKey="key"
bordered
:pagination="{ pageSize: 5 }"
:pagination="{ pageSize: 6, showSizeChanger: false }"
:scroll="{ x: 900 }"
>
<!-- Tipo -->
<template #bodyCell="{ column, record }">
<!-- Tipo -->
<template v-if="column.key === 'tipo'">
<a-tag :color="getColor(record.tipo)">
{{ record.tipo.toUpperCase() }}
<a-tag :color="getColor(record.tipo)" class="tag-pill">
{{ tipoLabel(record.tipo) }}
</a-tag>
</template>
<!-- Código -->
<template v-else-if="column.key === 'codigo'">
<div class="codigo-cell">
<span class="codigo">{{ record.codigo || '-' }}</span>
<a-tag v-if="record.estado" color="default" class="tag-mini">
{{ record.estado }}
</a-tag>
</div>
</template>
<!-- Uso / Proceso -->
<template v-else-if="column.key === 'uso'">
<div class="uso-cell">
<div class="uso-main">
{{ getUso(record) }}
</div>
<div v-if="record.proceso_nombre || record.proceso" class="uso-sub">
{{ record.proceso_nombre || record.proceso }}
</div>
</div>
</template>
<!-- Monto -->
<template v-else-if="column.key === 'monto'">
S/ {{ Number(record.monto).toFixed(2) }}
<span class="monto">
S/ {{ Number(record.monto || 0).toFixed(2) }}
</span>
</template>
<!-- Fecha -->
<template v-else-if="column.key === 'fecha_pago'">
{{ formatFecha(record.fecha_pago) }}
<span class="fecha">{{ formatFecha(record.fecha_pago) }}</span>
</template>
</template>
<!-- Empty -->
<template #emptyText>
<a-empty description="No se encontraron pagos" />
</template>
</a-table>
<!-- Tip informativo -->
<a-alert
class="tip-alert"
type="warning"
show-icon
message="Nota"
description="Si un pago no muestra el campo “Uso / Proceso”, es porque aún no está asociado a un proceso en el backend."
/>
</a-spin>
</a-card>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { message } from 'ant-design-vue'
import api from '.././../axiosPostulante'
const pagos = ref([])
const loading = ref(false)
const search = ref('')
const columns = [
{
title: 'Tipo',
dataIndex: 'tipo',
key: 'tipo'
key: 'tipo',
width: 140
},
{
title: 'Código',
dataIndex: 'codigo',
key: 'codigo'
key: 'codigo',
width: 200
},
{
title: 'Uso / Proceso',
dataIndex: 'uso',
key: 'uso',
width: 320
},
{
title: 'Monto',
dataIndex: 'monto',
key: 'monto'
key: 'monto',
width: 140
},
{
title: 'Fecha de Pago',
dataIndex: 'fecha_pago',
key: 'fecha_pago'
key: 'fecha_pago',
width: 220
}
]
@ -83,14 +149,13 @@ const obtenerPagos = async () => {
const { data } = await api.get('/postulante/pagos')
if (data.success) {
pagos.value = data.pagos.map((pago, index) => ({
pagos.value = (data.pagos || []).map((pago, index) => ({
...pago,
key: index
key: pago.id ?? `${pago.codigo ?? 'pago'}-${index}` // key estable
}))
} else {
message.error('No se pudieron obtener los pagos')
}
} catch (error) {
console.error(error)
message.error('Error al cargar pagos')
@ -106,11 +171,64 @@ const getColor = (tipo) => {
return 'default'
}
const tipoLabel = (tipo) => {
const map = {
pyto_peru: 'PYTO PERÚ',
banco_nacion: 'BANCO NACIÓN',
caja: 'CAJA'
}
return map[tipo] || String(tipo || '-').toUpperCase()
}
const formatFecha = (fecha) => {
if (!fecha) return '-'
return new Date(fecha).toLocaleString('es-PE')
const soloFecha = String(fecha).split(' ')[0] // "2025-01-27"
const [y, m, d] = soloFecha.split('-')
return `${Number(d)}/${Number(m)}/${y}` // "27/1/2025"
}
/**
* Uso / Proceso
* Ajusta los nombres según lo que tu backend mande:
* - uso
* - concepto
* - motivo
* - descripcion
* - proceso_nombre
* - proceso
*/
const getUso = (record) => {
return (
record.uso ||
record.concepto ||
record.motivo ||
record.descripcion ||
record.proceso_nombre ||
record.proceso ||
'-'
)
}
const pagosFiltrados = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return pagos.value
return pagos.value.filter((p) => {
const texto = [
p.tipo,
p.codigo,
getUso(p),
p.proceso_nombre,
p.proceso
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return texto.includes(q)
})
})
onMounted(() => {
obtenerPagos()
})
@ -118,11 +236,118 @@ onMounted(() => {
<style scoped>
.pagos-card {
max-width: 1000px;
max-width: 1100px;
margin: 20px auto;
border-radius: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.10);
background: #fff;
}
.card-title {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
align-items: flex-start;
}
.title-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.title-main {
font-weight: 900;
font-size: 18px;
color: #111827;
}
.title-sub {
font-weight: 650;
color: #6b7280;
font-size: 13px;
}
.top-summary {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 14px;
}
.summary-alert {
border-radius: 14px;
margin: 0;
flex: 1;
min-width: 280px;
}
.search-input {
max-width: 360px;
border-radius: 12px;
}
.pagos-table :deep(.ant-table) {
border-radius: 14px;
overflow: hidden;
}
.tag-pill {
border-radius: 999px;
font-weight: 800;
padding: 2px 10px;
}
.tag-mini {
border-radius: 999px;
font-weight: 700;
}
.codigo-cell {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.codigo {
font-weight: 900;
color: #111827;
}
.uso-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.uso-main {
font-weight: 800;
color: #111827;
line-height: 1.3;
}
.uso-sub {
font-weight: 650;
color: #6b7280;
font-size: 12px;
}
.monto {
font-weight: 900;
color: #1677ff;
}
.fecha {
font-weight: 650;
color: #374151;
}
.mb-3 {
margin-bottom: 16px;
.tip-alert {
margin-top: 14px;
border-radius: 14px;
}
</style>
</style>

File diff suppressed because it is too large Load Diff

@ -10,8 +10,9 @@
<p><strong>Intentos:</strong> {{ examenInfo.intentos }} / {{ examenInfo.intentos_maximos }}</p>
<p><strong>Tiempo restante:</strong></p>
</div>
<div class="timer">
<a-statistic-countdown
<a-statistic-countdown
:value="timerValue"
@finish="finalizarExamenAutomaticamente"
format="HH:mm:ss"
@ -20,44 +21,54 @@
</div>
</a-card>
<!-- Preguntas -->
<a-card
v-for="(pregunta, index) in preguntasTransformadas"
:key="pregunta.id"
<!-- Loading inicial -->
<a-card v-if="cargandoInicio" style="margin-top: 16px;">
<a-skeleton active />
</a-card>
<!-- Pregunta actual (UNA por pantalla) -->
<a-card
v-else-if="preguntaActual"
class="pregunta-card"
style="margin-top: 16px;"
>
<template #title>
<div class="pregunta-header">
<span class="pregunta-numero">Pregunta {{ index + 1 }}</span>
<span class="pregunta-numero">
Pregunta {{ indiceActual + 1 }} de {{ totalPreguntas }}
</span>
<span class="curso-tag">
<a-tag color="blue">{{ pregunta.curso }}</a-tag>
<a-tag color="blue">{{ preguntaActual.curso }}</a-tag>
</span>
<a-tag :color="pregunta.respondida ? 'green' : 'orange'">
{{ pregunta.estado === 'respondida' ? 'Respondida' : 'Pendiente' }}
<a-tag :color="tagColorEstado(preguntaActual)">
{{ tagTextoEstado(preguntaActual) }}
</a-tag>
</div>
</template>
<!-- Enunciado -->
<div class="enunciado" v-html="pregunta.enunciado"></div>
<div class="enunciado" v-html="preguntaActual.enunciado"></div>
<!-- Contenido adicional -->
<div v-if="pregunta.extra && pregunta.extra !== pregunta.enunciado"
class="extra" v-html="pregunta.extra"></div>
<!-- Opciones múltiples -->
<div class="opciones" v-if="pregunta.opciones && pregunta.opciones.length">
<a-radio-group
v-model:value="pregunta.respuestaSeleccionada"
@change="responderPregunta(pregunta)"
:disabled="pregunta.estado === 'respondida'"
<div
v-if="preguntaActual.extra && preguntaActual.extra !== preguntaActual.enunciado"
class="extra"
v-html="preguntaActual.extra"
></div>
<!-- Opciones múltiples (NO guarda en change) -->
<div class="opciones" v-if="preguntaActual.opciones && preguntaActual.opciones.length">
<a-radio-group
v-model:value="preguntaActual.respuestaSeleccionada"
:disabled="preguntaActual.estado === 'respondida'"
>
<a-space direction="vertical" style="width: 100%;">
<a-radio
v-for="opcion in pregunta.opcionesOrdenadas"
<a-radio
v-for="opcion in preguntaActual.opcionesOrdenadas"
:key="opcion.key"
:value="opcion.key.toString()" <!-- CONVERTIR A STRING -->
:value="opcion.key.toString()"
class="opcion-radio"
>
<span class="opcion-key">{{ getLetraOpcion(opcion.key) }}.</span>
@ -65,11 +76,10 @@
</a-radio>
</a-space>
</a-radio-group>
<!-- Mostrar respuesta seleccionada -->
<div v-if="pregunta.respuestaSeleccionada" class="seleccion-actual">
<a-alert
:message="`Ha seleccionado: ${getTextoOpcionSeleccionada(pregunta)}`"
<div v-if="preguntaActual.respuestaSeleccionada" class="seleccion-actual">
<a-alert
:message="`Ha seleccionado: ${getTextoOpcionSeleccionada(preguntaActual)}`"
type="info"
show-icon
style="margin-top: 12px;"
@ -77,21 +87,45 @@
</div>
</div>
<!-- Pregunta abierta (si no hay opciones) -->
<!-- Pregunta abierta (NO guarda en blur) -->
<div v-else class="pregunta-abierta">
<a-textarea
v-model:value="pregunta.respuestaTexto"
v-model:value="preguntaActual.respuestaTexto"
placeholder="Escriba su respuesta aquí..."
:rows="4"
:disabled="pregunta.estado === 'respondida'"
@blur="responderPreguntaTexto(pregunta)"
:disabled="preguntaActual.estado === 'respondida'"
/>
</div>
<!-- Información de respuesta correcta (solo para debug) -->
<!-- Botones por pregunta -->
<div class="nav-preguntas">
<a-button :disabled="indiceActual === 0" @click="irAnterior">
Anterior
</a-button>
<div class="nav-derecha">
<a-button
type="primary"
:loading="guardando"
:disabled="preguntaActual.estado === 'respondida'"
@click="guardarYContinuar"
>
Guardar respuesta y continuar
</a-button>
<a-button
:disabled="indiceActual >= totalPreguntas - 1"
@click="irSiguiente"
>
Siguiente
</a-button>
</div>
</div>
<!-- Debug -->
<div v-if="debugMode" class="debug-info">
<a-alert
:message="`Respuesta correcta: ${pregunta.respuesta} (key: ${pregunta.respuestaKey})`"
<a-alert
:message="`DEBUG | id: ${preguntaActual.id} | estado: ${preguntaActual.estado} | seleccion: ${preguntaActual.respuestaSeleccionada || ''}`"
type="warning"
show-icon
style="margin-top: 12px;"
@ -99,32 +133,53 @@
</div>
</a-card>
<!-- Si no hay pregunta -->
<a-card v-else style="margin-top: 16px;">
<a-alert
type="warning"
show-icon
message="No hay preguntas para mostrar"
description="Verifique que el examen tenga preguntas generadas."
/>
<div style="margin-top: 12px; text-align: right;">
<a-button @click="router.push({ name: 'DashboardPostulante' })">Volver</a-button>
</div>
</a-card>
<!-- Resumen y botones -->
<a-card style="margin-top: 24px;">
<div class="resumen-examen">
<h3>Resumen del Examen</h3>
<p><strong>Total preguntas:</strong> {{ preguntasTransformadas.length }}</p>
<p><strong>Respondidas:</strong> {{ preguntasRespondidas }} de {{ preguntasTransformadas.length }}</p>
<p><strong>Total preguntas:</strong> {{ totalPreguntas }}</p>
<p><strong>Respondidas (guardadas):</strong> {{ preguntasRespondidas }} de {{ totalPreguntas }}</p>
<p><strong>Progreso:</strong></p>
<a-progress :percent="porcentajeCompletado" :stroke-color="{
'0%': '#108ee9',
'100%': '#87d068',
}" />
<a-progress
:percent="porcentajeCompletado"
:stroke-color="{
'0%': '#108ee9',
'100%': '#87d068',
}"
/>
</div>
<div class="action-buttons" style="margin-top: 24px; text-align: center;">
<a-button
type="primary"
<a-button
type="primary"
size="large"
:loading="finalizando"
@click="finalizarExamen"
:disabled="!todasRespondidas"
>
{{ todasRespondidas ? 'Finalizar Examen' : `Responda todas las preguntas (${preguntasTransformadas.length - preguntasRespondidas} pendientes)` }}
{{
todasRespondidas
? 'Finalizar Examen'
: `Responda todas las preguntas (${totalPreguntas - preguntasRespondidas} pendientes)`
}}
</a-button>
<a-button
type="default"
<a-button
type="default"
size="large"
style="margin-left: 12px;"
@click="guardarYSalir"
@ -137,7 +192,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useExamenStore } from '../../store/examen.store'
import { message, Modal } from 'ant-design-vue'
@ -147,10 +202,21 @@ const router = useRouter()
const examenStore = useExamenStore()
const finalizando = ref(false)
const debugMode = ref(false) // Cambiar a true para depuración
const guardando = ref(false)
const debugMode = ref(false)
const timerValue = ref(null)
let timerIntervalId = null
const preguntasLocal = ref([])
const indiceActual = ref(0)
// Computed properties
const initOnce = ref(false)
const cargandoInicio = ref(false)
/* ---------------------------
INFO EXAMEN
---------------------------- */
const examenInfo = computed(() => {
if (!examenStore.examenActual) {
return {
@ -171,140 +237,181 @@ const examenInfo = computed(() => {
}
})
// Transformar preguntas - CORREGIDO para manejar keys numéricas
const preguntasTransformadas = computed(() => {
if (!examenStore.preguntas || !Array.isArray(examenStore.preguntas)) {
return []
}
/* ---------------------------
TRANSFORMACIÓN (UNA VEZ)
---------------------------- */
const transformarPreguntas = (arr) => {
if (!Array.isArray(arr)) return []
return examenStore.preguntas.map(pregunta => {
// Encontrar la key correcta para la respuesta
return arr.map((pregunta) => {
let respuestaKey = null
if (pregunta.opciones && pregunta.respuesta) {
const opcionCorrecta = pregunta.opciones.find(op =>
op.texto === pregunta.respuesta ||
const opcionCorrecta = pregunta.opciones.find(op =>
op.texto === pregunta.respuesta ||
op.key.toString() === pregunta.respuesta.toString()
)
respuestaKey = opcionCorrecta ? opcionCorrecta.key : null
}
// Ordenar opciones por key
const opcionesOrdenadas = pregunta.opciones ?
[...pregunta.opciones].sort((a, b) => a.key - b.key) :
[]
const opcionesOrdenadas = pregunta.opciones
? [...pregunta.opciones].sort((a, b) => a.key - b.key)
: []
return {
...pregunta,
respuestaKey, // Guardar la key de la respuesta correcta
respuestaKey,
opcionesOrdenadas,
// Agregar propiedades reactivas
// estado viene del backend: 'pendiente' o 'respondida'
respuestaSeleccionada: null,
respuestaTexto: '',
// El estado real viene del backend: 'pendiente' o 'respondida'
respuestaTexto: ''
}
})
}
/* IMPORTANTE:
No reseteamos preguntasLocal si ya existe, para no perder respuestas locales.
*/
watch(
() => examenStore.preguntas,
(nuevas) => {
if (!Array.isArray(nuevas) || nuevas.length === 0) return
if (preguntasLocal.value.length === 0) {
preguntasLocal.value = transformarPreguntas(nuevas)
indiceActual.value = 0
}
},
{ immediate: true }
)
/* ---------------------------
COMPUTEDS
---------------------------- */
const totalPreguntas = computed(() => preguntasLocal.value.length)
const preguntaActual = computed(() => {
return preguntasLocal.value[indiceActual.value] || null
})
// Helper para convertir key numérico a letra
const preguntasRespondidas = computed(() => {
return preguntasLocal.value.filter(p => p.estado === 'respondida').length
})
const porcentajeCompletado = computed(() => {
if (totalPreguntas.value === 0) return 0
return Math.round((preguntasRespondidas.value / totalPreguntas.value) * 100)
})
const todasRespondidas = computed(() => {
return preguntasLocal.value.every(p => p.estado === 'respondida')
})
/* ---------------------------
HELPERS UI
---------------------------- */
const getLetraOpcion = (key) => {
const letras = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
return letras[key] || `Opción ${key}`
}
// Obtener texto de opción seleccionada
const getTextoOpcionSeleccionada = (pregunta) => {
if (!pregunta.respuestaSeleccionada) return ''
if (!pregunta?.respuestaSeleccionada) return ''
const opcion = pregunta.opcionesOrdenadas.find(
op => op.key.toString() === pregunta.respuestaSeleccionada.toString()
)
return opcion ? opcion.texto : 'Opción no encontrada'
}
const preguntasRespondidas = computed(() => {
return preguntasTransformadas.value.filter(p =>
p.estado === 'respondida' || p.respuestaSeleccionada || p.respuestaTexto
).length
})
const tieneRespuestaLocal = (p) => {
const tieneOpciones = p?.opciones && p.opciones.length
if (tieneOpciones) return !!p.respuestaSeleccionada
return !!(p.respuestaTexto && p.respuestaTexto.trim())
}
const porcentajeCompletado = computed(() => {
if (preguntasTransformadas.length === 0) return 0
return Math.round((preguntasRespondidas.value / preguntasTransformadas.value.length) * 100)
})
const tagTextoEstado = (p) => {
if (p.estado === 'respondida') return 'Respondida'
if (tieneRespuestaLocal(p)) return 'Sin guardar'
return 'Pendiente'
}
const todasRespondidas = computed(() => {
return preguntasTransformadas.value.every(p =>
p.estado === 'respondida' || p.respuestaSeleccionada || p.respuestaTexto
)
})
const tagColorEstado = (p) => {
if (p.estado === 'respondida') return 'green'
if (tieneRespuestaLocal(p)) return 'gold'
return 'orange'
}
// Métodos
const responderPregunta = async (pregunta) => {
if (!pregunta.respuestaSeleccionada) return
try {
// Asegurarse de que enviamos string (no número)
const respuestaString = pregunta.respuestaSeleccionada.toString()
// Encontrar el texto completo de la opción seleccionada
const opcionSeleccionada = pregunta.opcionesOrdenadas.find(
op => op.key.toString() === respuestaString
)
// Enviar el TEXTO de la opción, no la key
/* ---------------------------
NAVEGACIÓN
---------------------------- */
const irSiguiente = () => {
if (indiceActual.value < totalPreguntas.value - 1) indiceActual.value++
}
const irAnterior = () => {
if (indiceActual.value > 0) indiceActual.value--
}
/* ---------------------------
GUARDADO (BOTÓN)
---------------------------- */
const guardarRespuestaDePregunta = async (p) => {
if (!p) return { success: false, message: 'No hay pregunta' }
if (p.estado === 'respondida') return { success: true }
const tieneOpciones = p.opciones && p.opciones.length
if (tieneOpciones) {
if (!p.respuestaSeleccionada) return { success: false, message: 'Seleccione una opción' }
const respuestaString = p.respuestaSeleccionada.toString()
const opcionSeleccionada = p.opcionesOrdenadas.find(op => op.key.toString() === respuestaString)
const textoRespuesta = opcionSeleccionada ? opcionSeleccionada.texto : respuestaString
const result = await examenStore.responderPregunta(
pregunta.id, // ID de PreguntaAsignada
textoRespuesta // Enviar el texto, no la key
)
if (result.success) {
// Actualizar estado local
pregunta.estado = 'respondida'
message.success('Respuesta guardada correctamente')
// Verificar si es correcta
if (result.correcta) {
message.info('¡Respuesta correcta!')
} else {
message.warning('Respuesta incorrecta')
}
} else {
message.error(result.message || 'Error al guardar respuesta')
// Revertir selección si falla
pregunta.respuestaSeleccionada = null
}
} catch (error) {
message.error('Error al guardar respuesta')
console.error('Error:', error)
pregunta.respuestaSeleccionada = null
return await examenStore.responderPregunta(p.id, textoRespuesta)
}
if (!p.respuestaTexto || !p.respuestaTexto.trim()) {
return { success: false, message: 'Escriba una respuesta' }
}
return await examenStore.responderPregunta(p.id, p.respuestaTexto.trim())
}
const responderPreguntaTexto = async (pregunta) => {
if (!pregunta.respuestaTexto.trim()) return
const guardarYContinuar = async () => {
const p = preguntaActual.value
if (!p) return
try {
const result = await examenStore.responderPregunta(
pregunta.id,
pregunta.respuestaTexto
)
if (result.success) {
pregunta.estado = 'respondida'
message.success('Respuesta guardada')
guardando.value = true
const result = await guardarRespuestaDePregunta(p)
if (!result?.success) {
message.error(result?.message || 'Error al guardar respuesta')
return
}
p.estado = 'respondida'
message.success('Respuesta guardada')
if (indiceActual.value < totalPreguntas.value - 1) {
indiceActual.value++
} else {
message.error(result.message || 'Error al guardar respuesta')
message.info('Ya estás en la última pregunta')
}
} catch (error) {
} catch (e) {
console.error(e)
message.error('Error al guardar respuesta')
console.error(error)
} finally {
guardando.value = false
}
}
/* ---------------------------
FINALIZAR / GUARDAR Y SALIR
---------------------------- */
const finalizarExamen = async () => {
if (!todasRespondidas.value) {
message.warning(`Por favor responda todas las preguntas. ${preguntasTransformadas.value.length - preguntasRespondidas.value} pendientes.`)
message.warning(`Por favor responda todas las preguntas. ${totalPreguntas.value - preguntasRespondidas.value} pendientes.`)
return
}
@ -317,12 +424,13 @@ const finalizarExamen = async () => {
try {
finalizando.value = true
const result = await examenStore.finalizarExamen(route.params.examenId)
if (result.success) {
message.success('Examen finalizado correctamente')
router.push({
name: 'panel-resultados',
params: { examenId: route.params.examenId }
router.push({
// Ajusta el name si tu ruta se llama distinto
name: 'PanelResultados',
params: { examenId: route.params.examenId }
})
} else {
message.error(result.message || 'Error al finalizar examen')
@ -338,30 +446,24 @@ const finalizarExamen = async () => {
}
const guardarYSalir = async () => {
// Primero guardar todas las respuestas pendientes
const preguntasPendientes = preguntasTransformadas.value.filter(p =>
(p.respuestaSeleccionada || p.respuestaTexto) && p.estado !== 'respondida'
)
if (preguntasPendientes.length > 0) {
const pendientes = preguntasLocal.value.filter(p => p.estado !== 'respondida' && tieneRespuestaLocal(p))
if (pendientes.length > 0) {
Modal.confirm({
title: 'Guardar respuestas pendientes',
content: `Tiene ${preguntasPendientes.length} respuesta(s) pendientes de guardar. ¿Desea guardarlas antes de salir?`,
content: `Tiene ${pendientes.length} respuesta(s) lista(s) pero no guardada(s). ¿Desea guardarlas antes de salir?`,
okText: 'Guardar y salir',
cancelText: 'Salir sin guardar',
onOk: async () => {
try {
// Guardar cada respuesta pendiente
for (const pregunta of preguntasPendientes) {
if (pregunta.respuestaSeleccionada) {
await responderPregunta(pregunta)
} else if (pregunta.respuestaTexto) {
await responderPreguntaTexto(pregunta)
}
for (const p of pendientes) {
const r = await guardarRespuestaDePregunta(p)
if (r?.success) p.estado = 'respondida'
}
message.success('Respuestas guardadas')
router.push({ name: 'DashboardPostulante' })
} catch (error) {
} catch (e) {
console.error(e)
message.error('Error al guardar respuestas')
}
},
@ -374,54 +476,88 @@ const guardarYSalir = async () => {
}
}
/* ---------------------------
TIMER
---------------------------- */
const finalizarExamenAutomaticamente = () => {
message.warning('Tiempo agotado. El examen se finalizará automáticamente.')
finalizarExamen()
}
const calcularTiempoRestante = () => {
// preferir hora_inicio del backend
if (examenStore.examenActual?.hora_inicio && examenInfo.value.duracion) {
const horaInicio = new Date(examenStore.examenActual.hora_inicio)
const duracionMs = examenInfo.value.duracion * 60 * 1000
const tiempoFinal = horaInicio.getTime() + duracionMs
timerValue.value = tiempoFinal
// Verificar si ya se agotó el tiempo
if (Date.now() > tiempoFinal) {
finalizarExamenAutomaticamente()
}
if (Date.now() > tiempoFinal) finalizarExamenAutomaticamente()
} else {
// Si no hay hora_inicio, usar duración por defecto desde ahora
// fallback
timerValue.value = Date.now() + (examenInfo.value.duracion * 60 * 1000)
}
}
// Lifecycle
onMounted(async () => {
console.log('=== DATOS DEL EXAMEN ===')
/* ---------------------------
INICIO EXAMEN (EJECUTAR 1 SOLA VEZ)
---------------------------- */
const iniciarSesionExamen = async () => {
if (initOnce.value) return
initOnce.value = true
const examenId = route.params.examenId
if (!examenId) {
message.error('No se encontró el ID del examen')
router.push({ name: 'DashboardPostulante' })
return
}
try {
await examenStore.iniciarExamen(route.params.examenId)
// ahora las preguntas deberían estar disponibles
if (!examenStore.preguntas || examenStore.preguntas.length === 0) {
cargandoInicio.value = true
// AQUÍ SÍ se ejecuta iniciarExamen con su examen_id
const r = await examenStore.iniciarExamen(examenId)
if (!r?.success) {
message.error(r?.message || 'No se pudo iniciar el examen')
router.push({ name: 'DashboardPostulante' })
return
}
// Inicializar preguntasLocal con lo que vino del backend
if (Array.isArray(examenStore.preguntas) && examenStore.preguntas.length > 0) {
preguntasLocal.value = transformarPreguntas(examenStore.preguntas)
indiceActual.value = 0
} else {
message.error('No se encontraron preguntas para este examen')
router.push({ name: 'DashboardPostulante' })
return
}
// Timer basado en hora_inicio real
calcularTiempoRestante()
setInterval(calcularTiempoRestante, 30000)
} catch (err) {
console.error(err)
message.error('Error al cargar el examen')
if (timerIntervalId) clearInterval(timerIntervalId)
timerIntervalId = setInterval(calcularTiempoRestante, 30000)
} catch (e) {
console.error(e)
message.error('Error al iniciar el examen')
router.push({ name: 'DashboardPostulante' })
} finally {
cargandoInicio.value = false
}
}
onMounted(() => {
iniciarSesionExamen()
})
onBeforeUnmount(() => {
if (timerIntervalId) clearInterval(timerIntervalId)
})
</script>
<style scoped>
/* Estilos mejorados */
.examen-panel {
max-width: 1200px;
margin: 0 auto;
@ -507,10 +643,6 @@ onMounted(async () => {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.enunciado :deep(math) {
font-size: 1.1em;
}
.extra {
font-size: 14px;
line-height: 1.6;
@ -566,10 +698,6 @@ onMounted(async () => {
margin-top: 16px;
}
.debug-info {
margin-top: 16px;
}
.pregunta-abierta textarea {
margin-top: 16px;
font-size: 15px;
@ -599,6 +727,20 @@ onMounted(async () => {
gap: 12px;
}
.nav-preguntas {
display: flex;
gap: 12px;
margin-top: 20px;
justify-content: space-between;
flex-wrap: wrap;
}
.nav-derecha {
display: flex;
gap: 12px;
margin-left: auto;
}
:deep(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
@ -608,57 +750,58 @@ onMounted(async () => {
padding: 24px;
}
/* Estilos para matemáticas */
.enunciado :deep(.katex) {
font-size: 1.1em;
}
.extra :deep(.katex) {
font-size: 1em;
}
/* Responsive */
@media (max-width: 768px) {
.examen-panel {
padding: 10px;
}
.examen-header {
flex-direction: column;
align-items: stretch;
}
.timer {
text-align: left;
margin-top: 16px;
}
.pregunta-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.curso-tag {
margin-left: 0;
}
.enunciado {
padding: 16px;
font-size: 15px;
}
.opcion-radio {
padding: 12px 16px;
}
.action-buttons {
flex-direction: column;
gap: 16px;
}
.action-buttons button {
width: 100%;
}
.nav-derecha {
width: 100%;
margin-left: 0;
flex-direction: column;
}
.nav-derecha button {
width: 100%;
}
}
</style>

@ -0,0 +1,731 @@
<template>
<a-card :loading="examenStore.cargando" class="exam-card" :bordered="false">
<!-- TITLE -->
<template #title>
<div class="card-title">
<div class="title-left">
<div class="title-main">
<span class="title-text">Test diagnóstico de admisión</span>
<a-tag :color="estadoColor" class="pill-tag">{{ estadoTexto }}</a-tag>
</div>
<div class="title-tags">
<a-tag color="blue" class="pill-tag subtle">Referencial</a-tag>
<a-tag color="green" class="pill-tag subtle">Gratuito</a-tag>
<a-tag class="pill-tag subtle">10 preguntas 10 min</a-tag>
</div>
</div>
<div class="title-right">
<a-space wrap>
<a-button v-if="!examenStore.examenActual" type="primary" @click="showModal = true">
Seleccionar área
</a-button>
<a-button @click="refrescar">Actualizar</a-button>
</a-space>
</div>
</div>
</template>
<!-- HERO: QUÉ TOCA HACER AHORA -->
<a-card class="hero-card mb16" :bordered="false">
<div class="hero-grid">
<div class="hero-left">
<div class="hero-kicker">Diagnóstico previo al examen</div>
<div class="hero-title">Evalúate antes del proceso</div>
<div class="hero-sub">
Herramienta referencial para medir tus conocimientos.
<b>No afecta tu ingreso</b> a la universidad.
</div>
<a-alert
class="mt12"
:type="estadoAlertType"
show-icon
:message="estadoAlertMessage"
:description="estadoAlertDesc"
/>
</div>
<div class="hero-right">
<div class="cta-box">
<div class="cta-label">Acción principal</div>
<!-- CTA según estado -->
<a-button
v-if="!examenStore.examenActual"
type="primary"
size="large"
block
class="cta-btn"
@click="showModal = true"
>
Seleccionar área de evaluación
</a-button>
<a-button
v-else-if="examenStore.examenActual && !yaDioTest"
type="primary"
size="large"
block
class="cta-btn"
:loading="iniciandoExamen"
@click="irAlExamen"
>
Iniciar test
</a-button>
<a-button
v-else
type="default"
size="large"
block
class="cta-btn ghost"
@click="verResultado"
>
Ver resultados
</a-button>
<div class="cta-hint">
Intentos: <b>{{ intentosActuales }}</b> / <b>{{ intentosMax }}</b>
</div>
<a-divider style="margin: 14px 0" />
<div class="mini-stats">
<div class="mini-stat">
<div class="mini-num">{{ procesoNombre }}</div>
<div class="mini-lbl">Proceso</div>
</div>
<div class="mini-stat">
<div class="mini-num">{{ areaNombre }}</div>
<div class="mini-lbl">Área</div>
</div>
</div>
</div>
</div>
</div>
</a-card>
<!-- PASOS: MÁS ENTENDIBLE -->
<a-card class="section-card mb16" :bordered="false">
<div class="section-head">
<div>
<div class="section-title">Progreso</div>
<div class="section-sub">Sigue estos pasos</div>
</div>
<a-tag class="pill-tag subtle">Resultado inmediato</a-tag>
</div>
<a-steps :current="stepCurrent" size="small" class="steps">
<a-step title="Selecciona el área" description="Elige proceso y área." />
<a-step title="Realiza el test" description="10 preguntas (10 min aprox.)." />
<a-step title="Revisa tu resultado" description="Resultado y recomendaciones." />
</a-steps>
</a-card>
<!-- RESUMEN COMPACTO (EN VEZ DE MUCHAS CARDS) -->
<a-card class="section-card mb16" :bordered="false">
<div class="section-head">
<div>
<div class="section-title">Resumen del test</div>
<div class="section-sub">Información clave</div>
</div>
<a-tag :color="estadoColor" class="pill-tag">{{ estadoTexto }}</a-tag>
</div>
<a-descriptions bordered size="small" :column="{ xs: 1, sm: 2 }">
<a-descriptions-item label="Proceso">{{ procesoNombre }}</a-descriptions-item>
<a-descriptions-item label="Área">{{ areaNombre }}</a-descriptions-item>
<a-descriptions-item label="Duración">10 min aprox.</a-descriptions-item>
<a-descriptions-item label="Preguntas">10</a-descriptions-item>
<a-descriptions-item label="Intentos">{{ intentosActuales }} / {{ intentosMax }}</a-descriptions-item>
<a-descriptions-item label="Incluye">Recomendaciones</a-descriptions-item>
</a-descriptions>
</a-card>
<!-- INFO EXTRA EN COLLAPSE (MENOS SATURACIÓN) -->
<a-card class="section-card" :bordered="false">
<a-collapse accordion class="collapse">
<a-collapse-panel key="1" header="¿Qué es este test y para qué sirve?">
<ul class="bullets">
<li>Es un <b>diagnóstico referencial</b> para medir tu preparación.</li>
<li><b>No forma parte</b> del proceso oficial de admisión.</li>
<li>Al finalizar verás <b>resultado inmediato</b> + recomendaciones.</li>
</ul>
<a-alert
type="warning"
show-icon
message="Consejo"
description="Responde con calma y revisa tus respuestas antes de finalizar."
/>
</a-collapse-panel>
<!-- Solo aparece cuando el proceso requiere pago (más claro y menos ruido) -->
<a-collapse-panel v-if="procesoRequierePago" key="2" header="Secuencia de pago (solo si tu proceso lo requiere)">
<a-alert
type="info"
show-icon
message="Este test es gratuito"
description="Solo se solicita la secuencia del pago de tu Carpeta de Postulante (que ya realizaste para el proceso)."
class="mb16"
/>
<a-alert
type="warning"
show-icon
message="👁️ Habilitación"
description="Pagos en Banco de la Nación o pagalo.pe se habilitan después de 24 horas. En Caja es inmediato."
class="mb16"
/>
<div class="btn-row">
<a-space wrap>
<a-button @click="openSecuencia('caja')">Ver secuencia en Caja</a-button>
<a-button @click="openSecuencia('pagalo')">Ver secuencia en pagalo.pe</a-button>
<a-button @click="openSecuencia('bn')">Ver secuencia BN</a-button>
</a-space>
</div>
</a-collapse-panel>
<a-collapse-panel key="3" header="Acciones rápidas">
<a-space wrap>
<a-button v-if="!examenStore.examenActual" type="primary" @click="showModal = true">
Seleccionar área
</a-button>
<a-button
v-if="examenStore.examenActual && !yaDioTest"
type="primary"
:loading="iniciandoExamen"
@click="irAlExamen"
>
Iniciar test
</a-button>
<a-button v-if="examenStore.examenActual && yaDioTest" @click="verResultado">
Ver resultados
</a-button>
<a-button @click="refrescar">Actualizar</a-button>
</a-space>
<a-alert
v-if="examenStore.examenActual && yaDioTest"
type="info"
show-icon
class="mt12"
message="No es posible reiniciar"
description="Ya realizaste el test. No se puede cambiar de área ni volver a iniciar."
/>
</a-collapse-panel>
</a-collapse>
</a-card>
<!-- ================== MODAL SECUENCIA ================== -->
<a-modal
v-model:open="secuenciaModalOpen"
:title="secuenciaTitle"
:footer="null"
width="920px"
:centered="true"
:bodyStyle="modalBodyStyle"
class="voucher-modal"
>
<a-row :gutter="[16, 16]" align="top">
<a-col :xs="24" :md="14">
<a-card :bordered="false" class="voucher-card">
<div class="voucher-caption">
Busca el campo marcado como <b>Secuencia</b> o <b>N° operación</b> (según el comprobante).
</div>
<div class="voucher-img-wrap">
<a-image :src="voucherSrc" :preview="true" class="voucher-image" alt="Voucher de ejemplo" />
</div>
<a-alert class="mt12" type="warning" show-icon message="Habilitación" :description="habilitacionTexto" />
</a-card>
</a-col>
<a-col :xs="24" :md="10">
<a-card :bordered="false" class="voucher-side">
<div class="side-title">¿Qué debo hacer?</div>
<div class="step">
<span class="dot"></span>
<span>Ubica el <b>número de secuencia</b> en tu voucher/boleta.</span>
</div>
<div class="step">
<span class="dot"></span>
<span>Ingresa ese número cuando el sistema lo solicite.</span>
</div>
<div class="step">
<span class="dot"></span>
<span>Si pagaste hoy y no aparece, espera el tiempo de habilitación.</span>
</div>
<a-divider />
<a-alert type="info" show-icon message="Nota" description="Este test es gratuito. No se realiza ningún pago adicional." />
<div class="modal-actions">
<a-button @click="secuenciaModalOpen = false">Cerrar</a-button>
<a-button type="primary" @click="secuenciaModalOpen = false">Entendido</a-button>
</div>
</a-card>
</a-col>
</a-row>
</a-modal>
<!-- ================== MODAL SELECCIÓN ÁREA ================== -->
<a-modal
v-model:open="showModal"
title="Seleccionar Área de Examen"
:mask-closable="false"
width="640px"
@cancel="resetModal"
>
<a-alert
v-if="examenStore.examenActual"
type="warning"
show-icon
message="No permitido"
description="Ya tienes un test asignado. No se puede cambiar el área."
class="mb16"
/>
<a-form v-else ref="formRef" :model="formState" :rules="rules" layout="vertical">
<a-form-item label="Proceso" name="proceso_id" required>
<a-select
v-model:value="formState.proceso_id"
placeholder="Seleccione un proceso"
:options="procesoOptions"
@change="handleProcesoChange"
:loading="examenStore.cargando"
/>
</a-form-item>
<a-form-item label="Área" name="area_proceso_id" required>
<a-select
v-model:value="formState.area_proceso_id"
placeholder="Seleccione un área"
:options="areaOptions"
:disabled="!formState.proceso_id"
:loading="examenStore.cargando"
/>
</a-form-item>
<a-card v-if="procesoRequierePago" size="small" class="pay-card" :bordered="false">
<a-alert message="Este proceso requiere pago" type="info" show-icon class="mb12" />
<a-form-item label="Tipo de Pago" name="tipo_pago">
<a-select v-model:value="formState.tipo_pago" placeholder="Seleccione tipo de pago" :options="tipoPagoOptions" />
</a-form-item>
<a-form-item label="Código de Pago" name="codigo_pago">
<a-input v-model:value="formState.codigo_pago" placeholder="Ingrese el código de pago" />
</a-form-item>
</a-card>
</a-form>
<template #footer>
<a-space>
<a-button @click="showModal = false">Cerrar</a-button>
<a-button
v-if="!examenStore.examenActual"
type="primary"
:loading="creandoExamen"
@click="crearExamen"
>
Confirmar
</a-button>
</a-space>
</template>
</a-modal>
</a-card>
</template>
<script setup>
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useExamenStore } from '../../store/examen.store'
import { message } from 'ant-design-vue'
const router = useRouter()
const examenStore = useExamenStore()
const showModal = ref(false)
const iniciandoExamen = ref(false)
const creandoExamen = ref(false)
const formRef = ref()
const formState = reactive({
proceso_id: undefined,
area_proceso_id: undefined,
tipo_pago: undefined,
codigo_pago: ''
})
/* ===== Modal Voucher ===== */
const secuenciaModalOpen = ref(false)
const secuenciaTipo = ref('caja')
const secuenciaTitle = computed(() => {
const map = { caja: 'Secuencia en Caja', pagalo: 'Secuencia en pagalo.pe', bn: 'Secuencia en Banco de la Nación' }
return map[secuenciaTipo.value] || 'Secuencia de pago'
})
/** Imágenes en /public */
const voucherSrc = computed(() => {
const map = { caja: '/voucher-caja.png', pagalo: '/voucher-pagalo.png', bn: '/voucher-bn.png' }
return map[secuenciaTipo.value] || '/voucher-bn.png'
})
const habilitacionTexto = computed(() => (secuenciaTipo.value === 'caja'
? 'En Caja se habilita de forma inmediata.'
: 'En Banco de la Nación o pagalo.pe puede demorar hasta 24 horas.'
))
const openSecuencia = (tipo) => {
secuenciaTipo.value = tipo
secuenciaModalOpen.value = true
}
const modalBodyStyle = computed(() => ({ maxHeight: '72vh', overflowY: 'auto' }))
/* ===== Datos SIEMPRE disponibles (aunque no haya examen) ===== */
const procesoNombre = computed(() => examenStore.examenActual?.proceso?.nombre || 'No asignado')
const areaNombre = computed(() => examenStore.examenActual?.area?.nombre || 'No seleccionada')
const intentosActuales = computed(() => examenStore.examenActual?.intentos || 0)
const intentosMax = computed(() => examenStore.examenActual?.intentos_max || 1)
/* ✅ yaDioTest => bloquea cambiar área e iniciar */
const yaDioTest = computed(() => intentosActuales.value > 0)
const estadoColor = computed(() => (yaDioTest.value ? 'green' : 'blue'))
const estadoTexto = computed(() => (yaDioTest.value ? 'Completado' : 'Disponible'))
/** ✅ Steps + Alert texto claro */
const stepCurrent = computed(() => {
if (!examenStore.examenActual) return 0
return yaDioTest.value ? 2 : 1
})
const estadoAlertType = computed(() => {
if (!examenStore.examenActual) return 'info'
return yaDioTest.value ? 'success' : 'warning'
})
const estadoAlertMessage = computed(() => {
if (!examenStore.examenActual) return 'Paso 1: Selecciona tu área de evaluación'
return yaDioTest.value ? 'Test finalizado' : 'Listo para iniciar el test'
})
const estadoAlertDesc = computed(() => {
if (!examenStore.examenActual) return 'Elige el proceso y el área para generar tu test diagnóstico.'
return yaDioTest.value
? 'Ya realizaste el test. Puedes revisar tus resultados cuando quieras.'
: 'Cuando inicies, responderás 10 preguntas. Al finalizar verás el resultado inmediato.'
})
/* ===== Options ===== */
const procesoOptions = computed(() =>
examenStore.procesos.map(p => ({ value: p.id, label: p.nombre, requiere_pago: p.requiere_pago }))
)
const areaOptions = computed(() =>
examenStore.areas.map(a => ({ value: a.area_proceso_id, label: a.nombre }))
)
const tipoPagoOptions = [
{ value: 'pyto_peru', label: 'Pago por Pytoperú' },
{ value: 'banco_nacion', label: 'Banco de la Nación' },
{ value: 'caja', label: 'Caja' }
]
const procesoRequierePago = computed(() => {
const proceso = examenStore.procesos.find(p => p.id === formState.proceso_id)
return proceso?.requiere_pago === 1
})
/* ✅ Reglas de validación correctas (condicionales) */
const rules = {
proceso_id: [{ required: true, message: 'Por favor seleccione un proceso', trigger: 'change' }],
area_proceso_id: [{ required: true, message: 'Por favor seleccione un área', trigger: 'change' }],
tipo_pago: [{
trigger: 'change',
validator: (_, value) => {
if (!procesoRequierePago.value) return Promise.resolve()
if (!value) return Promise.reject('Seleccione tipo de pago')
return Promise.resolve()
}
}],
codigo_pago: [{
trigger: 'blur',
validator: (_, value) => {
if (!procesoRequierePago.value) return Promise.resolve()
if (!value || !String(value).trim()) return Promise.reject('Ingrese código de pago')
return Promise.resolve()
}
}]
}
/* ===== Actions ===== */
const refrescar = async () => {
await examenStore.fetchExamenActual()
message.success('Información actualizada')
}
const handleProcesoChange = async (procesoId) => {
formState.area_proceso_id = undefined
if (procesoId) await examenStore.fetchAreas(procesoId)
}
const crearExamen = async () => {
if (examenStore.examenActual) {
message.warning('Ya tienes un test asignado. No puedes cambiar el área.')
return
}
try {
creandoExamen.value = true
// Validar formulario antes
if (formRef.value) await formRef.value.validate()
const pagoData = procesoRequierePago.value
? { tipo_pago: formState.tipo_pago, codigo_pago: formState.codigo_pago }
: null
const result = await examenStore.crearExamen(formState.area_proceso_id, pagoData)
if (result.success) {
message.success('Área asignada correctamente')
showModal.value = false
resetModal()
await examenStore.fetchExamenActual()
} else {
message.error(result.message || 'Error al asignar área')
}
} catch (e) {
// Si viene por validación, no spamear error genérico
if (!String(e).toLowerCase().includes('validation')) {
message.error('Error al asignar área')
}
} finally {
creandoExamen.value = false
}
}
const irAlExamen = async () => {
if (iniciandoExamen.value) return
if (!examenStore.examenActual?.id) {
message.error('Primero selecciona un área')
return
}
if (yaDioTest.value) {
message.info('Ya realizaste el test. Revisa tus resultados.')
return
}
try {
iniciandoExamen.value = true
const id = examenStore.examenActual.id
const generarResult = await examenStore.generarPreguntas(id)
if (!generarResult.success && !generarResult.ya_tiene_preguntas) {
message.error(generarResult.message || 'Error al generar preguntas')
return
}
router.replace({ name: 'PanelExamen', params: { examenId: id } })
} catch (error) {
console.error(error)
message.error('Error al procesar la solicitud')
} finally {
iniciandoExamen.value = false
}
}
const verResultado = async () => {
if (!examenStore.examenActual?.id) return
await examenStore.fetchExamenActual()
router.push({ name: 'PanelResultados', params: { examenId: examenStore.examenActual?.id } })
}
const resetModal = () => {
formState.proceso_id = undefined
formState.area_proceso_id = undefined
formState.tipo_pago = undefined
formState.codigo_pago = ''
if (formRef.value) formRef.value.clearValidate?.()
}
/* ===== Lifecycle ===== */
onMounted(async () => {
await examenStore.fetchProcesos()
await examenStore.fetchExamenActual()
})
watch(showModal, (newVal) => {
if (!newVal) {
examenStore.areas = []
resetModal()
}
})
</script>
<style scoped>
.exam-card {
border-radius: 18px;
background: var(--ant-colorBgContainer, #fff);
box-shadow: var(--ant-boxShadowSecondary, 0 18px 48px rgba(0, 0, 0, 0.10));
}
/* Title */
.card-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title-left { display: flex; flex-direction: column; gap: 8px; }
.title-main { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.title-tags { display: flex; gap: 8px; flex-wrap: wrap; }
.title-text { font-weight: 900; color: var(--ant-colorTextHeading, #111827); }
.pill-tag {
border-radius: 999px;
font-weight: 800;
padding: 4px 10px;
}
.pill-tag.subtle {
font-weight: 700;
opacity: .9;
}
/* Spacing */
.mb16 { margin-bottom: 16px; }
.mt12 { margin-top: 12px; }
/* Sections */
.section-card {
border-radius: 18px;
background: var(--ant-colorBgContainer, #fff);
box-shadow: var(--ant-boxShadowTertiary, 0 10px 24px rgba(0, 0, 0, 0.06));
}
.section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.section-title { font-weight: 900; color: var(--ant-colorTextHeading, #111827); }
.section-sub { font-weight: 700; color: var(--ant-colorTextSecondary, #6b7280); margin-top: 2px; }
/* HERO */
.hero-card {
border-radius: 18px;
background: linear-gradient(135deg, rgba(24, 144, 255, 0.14), rgba(82, 196, 26, 0.10));
box-shadow: 0 10px 24px rgba(0,0,0,.06);
}
.hero-grid {
display: grid;
grid-template-columns: 1.3fr .9fr;
gap: 16px;
}
@media (max-width: 992px) {
.hero-grid { grid-template-columns: 1fr; }
}
.hero-kicker { font-weight: 800; opacity: .85; }
.hero-title { font-size: 24px; font-weight: 900; margin-top: 6px; line-height: 1.15; }
.hero-sub { margin-top: 8px; font-weight: 650; color: var(--ant-colorText, #374151); line-height: 1.55; }
/* CTA box */
.cta-box {
background: rgba(255,255,255,.82);
border: 1px solid rgba(0,0,0,.06);
border-radius: 16px;
padding: 16px;
box-shadow: 0 6px 18px rgba(0,0,0,.06);
}
.cta-label { font-weight: 900; opacity: .85; margin-bottom: 10px; }
.cta-btn {
height: 46px;
border-radius: 12px;
font-weight: 900;
}
.cta-btn.ghost {
background: rgba(255,255,255,.9);
}
.cta-hint { margin-top: 10px; font-size: 12px; opacity: .75; line-height: 1.35; }
.mini-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.mini-stat {
border-radius: 12px;
padding: 10px 12px;
background: rgba(0,0,0,.02);
border: 1px solid rgba(0,0,0,.05);
}
.mini-num {
font-weight: 900;
line-height: 1.2;
word-break: break-word;
}
.mini-lbl { font-size: 12px; opacity: .75; }
/* Steps + Collapse */
.steps { margin-top: 6px; }
.collapse :deep(.ant-collapse-item) { border-radius: 14px; overflow: hidden; }
.bullets { margin: 0 0 12px 18px; line-height: 1.7; }
.btn-row { margin-top: 10px; }
/* Pay Card */
.pay-card {
border-radius: 14px;
background: var(--ant-colorFillAlter, #fafafa);
}
/* Voucher modal styles */
.voucher-card, .voucher-side {
border-radius: 16px;
background: var(--ant-colorBgContainer, #fff);
box-shadow: var(--ant-boxShadowTertiary, 0 10px 24px rgba(0, 0, 0, 0.06));
}
.voucher-caption { font-weight: 700; color: var(--ant-colorText, #374151); line-height: 1.6; margin-bottom: 10px; }
.voucher-img-wrap {
border-radius: 14px;
overflow: hidden;
background: var(--ant-colorFillAlter, #fafafa);
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
padding: 10px;
}
:deep(.voucher-image .ant-image-img) { width: 100%; height: auto; display: block; object-fit: contain; }
.side-title { font-weight: 900; margin-bottom: 10px; color: var(--ant-colorTextHeading, #111827); }
.step { display: flex; gap: 10px; align-items: flex-start; font-weight: 650; color: var(--ant-colorText, #374151); line-height: 1.6; margin: 8px 0; }
.dot {
width: 10px; height: 10px; border-radius: 999px;
background: var(--ant-colorPrimary, #1677ff);
box-shadow: 0 0 0 6px rgba(22, 119, 255, 0.14);
margin-top: 7px; flex: 0 0 auto;
}
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 12px; }
@media (max-width: 576px) {
.title-right { width: 100%; }
.title-right :deep(.ant-space) { width: 100%; }
.title-right :deep(.ant-btn) { width: 100%; }
.mini-stats { grid-template-columns: 1fr; }
}
</style>
Loading…
Cancel
Save