adadad
parent
495595033d
commit
e84ec16504
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 |
@ -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,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>
|
||||
|
||||
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@ -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…
Reference in New Issue