You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
665 lines
17 KiB
Vue
665 lines
17 KiB
Vue
|
|
<template>
|
|
<a-spin :spinning="loading">
|
|
|
|
<div class="topbar">
|
|
<div class="topbarLeft">
|
|
<div class="hello">Bienvenido, {{ authStore.userName}}</div>
|
|
<div class="sub">DNI: {{ authStore.userDni || 'No registrado' }}</div>
|
|
</div>
|
|
|
|
<div class="topbarRight">
|
|
<div class="statusLine">
|
|
<span class="label">Estado:</span>
|
|
<a-badge :status="eligibilityUi.badge" :text="eligibilityUi.text" />
|
|
</div>
|
|
|
|
<a-button class="btnTop" @click="fetchDashboard" :loading="loading" block>
|
|
Actualizar
|
|
</a-button>
|
|
</div>
|
|
</div>
|
|
|
|
<a-divider />
|
|
|
|
<div class="testBox">
|
|
<div class="testHead">
|
|
<div class="testHeadLeft">
|
|
<div class="testTitle">Tu test de admisión</div>
|
|
<div class="testSub">
|
|
Responde con calma. Busca un lugar tranquilo. (10 preguntas • 10 min aprox.)
|
|
</div>
|
|
</div>
|
|
|
|
<div class="testHeadRight">
|
|
<a-button
|
|
type="primary"
|
|
size="large"
|
|
class="btnTest"
|
|
:disabled="!canStartTest"
|
|
block
|
|
@click="onStartTest"
|
|
>
|
|
{{ testCtaText }}
|
|
</a-button>
|
|
<div class="microHelp" v-if="!canStartTest">No disponible por el momento.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="testInfoGrid">
|
|
<div class="infoItem">
|
|
<div class="infoK">Estado</div>
|
|
<div class="infoV">
|
|
<a-badge :status="testStatusUi.badge" :text="testStatusUi.text" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="infoItem">
|
|
<div class="infoK">Tiempo restante</div>
|
|
<div class="infoV">
|
|
<span class="strong" :class="{ warn: expiresSoon && timeRemainingText !== 'Vencido' }">
|
|
{{ timeRemainingText }}
|
|
</span>
|
|
<span v-if="expiresSoon && timeRemainingText !== 'Vencido'" class="hintInline">
|
|
(vence pronto)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="infoItem">
|
|
<div class="infoK">Disponible desde</div>
|
|
<div class="infoV">
|
|
{{ state.eligibility.testAvailableAt ? fmtDate(state.eligibility.testAvailableAt) : "—" }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="infoItem">
|
|
<div class="infoK">Fecha límite</div>
|
|
<div class="infoV">
|
|
{{ state.eligibility.testExpiresAt ? fmtDate(state.eligibility.testExpiresAt) : "—" }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt12">
|
|
<a-alert
|
|
v-if="state.eligibility.testStatus === 'COMPLETADO'"
|
|
type="success"
|
|
show-icon
|
|
message="¡Listo! Ya completaste el test."
|
|
description="Ahora puedes revisar los procesos disponibles."
|
|
/>
|
|
<a-alert
|
|
v-else-if="!state.eligibility.hasTestAssigned"
|
|
type="info"
|
|
show-icon
|
|
message="Aún no tienes un test asignado."
|
|
description="Vuelve a revisar en unos minutos."
|
|
/>
|
|
<a-alert
|
|
v-else
|
|
type="info"
|
|
show-icon
|
|
message="Importante"
|
|
description="Al iniciar o continuar, el tiempo puede comenzar a correr según la configuración del proceso."
|
|
/>
|
|
</div>
|
|
|
|
<a-alert
|
|
v-if="!state.eligibility.isEligibleToApply && state.eligibility.reasons?.length"
|
|
type="warning"
|
|
show-icon
|
|
message="Requisitos pendientes"
|
|
class="mt12"
|
|
>
|
|
<template #description>
|
|
<ul class="reasons">
|
|
<li v-for="(r, idx) in state.eligibility.reasons" :key="idx">{{ r }}</li>
|
|
</ul>
|
|
</template>
|
|
</a-alert>
|
|
</div>
|
|
|
|
<a-divider />
|
|
|
|
|
|
|
|
<a-divider />
|
|
|
|
<!-- Procesos -->
|
|
<div class="section">
|
|
<div class="sectionHead">
|
|
<div>
|
|
<div class="sectionTitle">Procesos disponibles</div>
|
|
<div class="sectionSub">Revisa fechas y postula cuando esté habilitado.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tableWrap mt12">
|
|
<a-table
|
|
:columns="processColumns"
|
|
:data-source="state.availableProcesses"
|
|
row-key="id"
|
|
:pagination="{ pageSize: 6 }"
|
|
:scroll="{ x: 720 }"
|
|
>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.key === 'startDate'">
|
|
{{ fmtDate(record.startDate) }}
|
|
</template>
|
|
|
|
<template v-else-if="column.key === 'endDate'">
|
|
{{ fmtDate(record.endDate) }}
|
|
</template>
|
|
|
|
<template v-else-if="column.key === 'status'">
|
|
<a-tag class="chip">{{ record.status }}</a-tag>
|
|
</template>
|
|
|
|
<template v-else-if="column.key === 'actions'">
|
|
<div class="actions">
|
|
<a-button size="small" block @click="onViewProcess(record)">Ver</a-button>
|
|
<a-button size="small" type="primary" block :disabled="!record.canApply" @click="onApply(record)">
|
|
Postular
|
|
</a-button>
|
|
</div>
|
|
|
|
<div v-if="!record.canApply" class="microHelp">Este proceso aún no permite postular.</div>
|
|
</template>
|
|
</template>
|
|
</a-table>
|
|
</div>
|
|
</div>
|
|
|
|
<a-divider />
|
|
</a-spin>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, reactive, ref, onBeforeUnmount } from "vue";
|
|
import { message, Modal } from "ant-design-vue";
|
|
import { useAuthStore } from '../../store/postulanteStore'
|
|
const authStore = useAuthStore()
|
|
|
|
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",
|
|
testAvailableAt: null,
|
|
testExpiresAt: null,
|
|
testUrl: null,
|
|
},
|
|
availableProcesses: [],
|
|
});
|
|
|
|
|
|
function parseDate(val) {
|
|
if (!val) return null;
|
|
const iso = String(val).includes("T") ? String(val) : String(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`;
|
|
}
|
|
function fmtDate(val) {
|
|
const d = parseDate(val);
|
|
if (!d) return val || "—";
|
|
const dd = String(d.getDate()).padStart(2, "0");
|
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
const yyyy = d.getFullYear();
|
|
const hh = String(d.getHours()).padStart(2, "0");
|
|
const mi = String(d.getMinutes()).padStart(2, "0");
|
|
return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
|
|
}
|
|
|
|
|
|
const eligibilityUi = computed(() => {
|
|
return state.eligibility.isEligibleToApply
|
|
? { badge: "success", text: "Apto para postular" }
|
|
: { badge: "error", text: "No apto para postular" };
|
|
});
|
|
|
|
const testStatusUi = computed(() => {
|
|
const s = state.eligibility.testStatus;
|
|
if (s === "EN_PROGRESO") return { badge: "processing", text: "En progreso" };
|
|
if (s === "COMPLETADO") return { badge: "success", text: "Completado" };
|
|
if (s === "NO_DISPONIBLE") return { badge: "default", text: "No disponible" };
|
|
return { badge: "warning", text: "Pendiente" };
|
|
});
|
|
|
|
const testCtaText = computed(() => {
|
|
const s = state.eligibility.testStatus;
|
|
if (s === "EN_PROGRESO") return "Continuar test";
|
|
if (s === "COMPLETADO") return "Ver estado";
|
|
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(() => {
|
|
const ms = testExpireMs.value;
|
|
return ms !== null && ms > 0 && ms <= 24 * 60 * 60 * 1000;
|
|
});
|
|
|
|
const timeRemainingText = computed(() => {
|
|
const ms = testExpireMs.value;
|
|
if (ms === null) return "—";
|
|
if (ms <= 0) return "Vencido";
|
|
return msToHuman(ms);
|
|
});
|
|
|
|
|
|
const processColumns = computed(() => [
|
|
{ title: "Proceso", dataIndex: "name", key: "name", ellipsis: true },
|
|
{ title: "Inicio", dataIndex: "startDate", key: "startDate", responsive: ["md"] },
|
|
{ title: "Fin", dataIndex: "endDate", key: "endDate", responsive: ["md"] },
|
|
{ title: "Estado", dataIndex: "status", key: "status", responsive: ["sm"] },
|
|
{ title: "Vacantes", dataIndex: "vacancies", key: "vacancies", responsive: ["sm"] },
|
|
{ title: "Acciones", key: "actions" },
|
|
]);
|
|
|
|
|
|
const api = {
|
|
async getDashboard() {
|
|
return new Promise((resolve) => {
|
|
setTimeout(() => {
|
|
resolve({
|
|
|
|
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: "portal-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 {
|
|
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 {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
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: "Continuar",
|
|
cancelText: "Cancelar",
|
|
async onOk() {
|
|
try {
|
|
await api.startTest();
|
|
if (state.eligibility.testUrl) window.location.href = state.eligibility.testUrl;
|
|
} catch {
|
|
message.error("No se pudo iniciar el test.");
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
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.");
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
function onViewProcess(process) {
|
|
message.info(`Abrir detalle del proceso: ${process.name}`);
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await fetchDashboard();
|
|
timer = setInterval(() => (nowTick.value = Date.now()), 1000);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
if (timer) clearInterval(timer);
|
|
});
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.page {
|
|
width: 100%;
|
|
padding: 12px;
|
|
}
|
|
|
|
.pageCard {
|
|
max-width: 1120px;
|
|
margin: 0 auto;
|
|
border-radius: 14px;
|
|
}
|
|
|
|
/* Header */
|
|
.topbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 14px;
|
|
flex-wrap: wrap;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.hello {
|
|
font-size: 24px;
|
|
font-weight: 800;
|
|
color: var(--ant-colorTextHeading, #111827);
|
|
}
|
|
.sub {
|
|
margin-top: 4px;
|
|
font-size: 12px;
|
|
color: var(--ant-colorTextSecondary, #6b7280);
|
|
}
|
|
|
|
.topbarRight {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
align-items: flex-end;
|
|
min-width: 260px;
|
|
}
|
|
.statusLine {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
.label {
|
|
font-size: 12px;
|
|
color: var(--ant-colorTextSecondary, #6b7280);
|
|
}
|
|
.btnTop {
|
|
min-width: 180px;
|
|
}
|
|
|
|
/* TEST destacado (sin degradado) */
|
|
.testBox {
|
|
border: 2px solid var(--ant-colorPrimary, #1677ff);
|
|
background: var(--ant-colorBgContainer, #fff);
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
}
|
|
|
|
.testHead {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
flex-wrap: wrap; /* ✅ clave para móvil */
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.testTitle {
|
|
font-size: 20px;
|
|
font-weight: 900;
|
|
color: var(--ant-colorTextHeading, #111827);
|
|
}
|
|
.testSub {
|
|
margin-top: 4px;
|
|
font-size: 12px;
|
|
color: var(--ant-colorTextSecondary, #6b7280);
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.testHeadRight {
|
|
min-width: 260px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.btnTest {
|
|
height: 44px;
|
|
border-radius: 10px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.testInfoGrid {
|
|
margin-top: 12px;
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.infoItem {
|
|
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
|
|
background: var(--ant-colorFillAlter, #fafafa);
|
|
border-radius: 10px;
|
|
padding: 10px 12px;
|
|
min-width: 0;
|
|
}
|
|
.infoK {
|
|
font-size: 12px;
|
|
color: var(--ant-colorTextSecondary, #6b7280);
|
|
}
|
|
.infoV {
|
|
margin-top: 4px;
|
|
font-weight: 800;
|
|
color: var(--ant-colorText, #111827);
|
|
word-break: break-word;
|
|
}
|
|
|
|
.strong { font-weight: 900; }
|
|
.warn { color: var(--ant-colorTextHeading, #111827); }
|
|
.hintInline {
|
|
margin-left: 6px;
|
|
font-size: 12px;
|
|
color: var(--ant-colorTextSecondary, #6b7280);
|
|
}
|
|
|
|
.reasons {
|
|
margin: 8px 0 0 18px;
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.summaryBox {
|
|
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
|
|
background: var(--ant-colorFillAlter, #fafafa);
|
|
border-radius: 12px;
|
|
padding: 14px;
|
|
}
|
|
.summaryTitle {
|
|
font-weight: 800;
|
|
color: var(--ant-colorTextHeading, #111827);
|
|
margin-bottom: 10px;
|
|
}
|
|
.summaryGrid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 2fr;
|
|
gap: 12px;
|
|
}
|
|
.summaryK {
|
|
font-size: 12px;
|
|
color: var(--ant-colorTextSecondary, #6b7280);
|
|
}
|
|
.summaryV {
|
|
font-weight: 900;
|
|
color: var(--ant-colorTextHeading, #111827);
|
|
}
|
|
.chips {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.chip {
|
|
border-radius: 999px;
|
|
}
|
|
|
|
/* Secciones */
|
|
.sectionHead {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
align-items: flex-start;
|
|
}
|
|
.sectionTitle {
|
|
font-size: 20px;
|
|
font-weight: 800;
|
|
color: var(--ant-colorTextHeading, #111827);
|
|
}
|
|
.sectionSub {
|
|
margin-top: 4px;
|
|
font-size: 12px;
|
|
color: var(--ant-colorTextSecondary, #6b7280);
|
|
}
|
|
|
|
/* Tabla: evita romper en móvil */
|
|
.tableWrap {
|
|
width: 100%;
|
|
overflow-x: auto;
|
|
}
|
|
:deep(.ant-table-container) {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
/* Acciones: en móvil se apilan */
|
|
.actions {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 8px;
|
|
}
|
|
.microHelp {
|
|
margin-top: 6px;
|
|
font-size: 12px;
|
|
color: var(--ant-colorTextSecondary, #6b7280);
|
|
}
|
|
|
|
.listItem {
|
|
padding-left: 0;
|
|
padding-right: 0;
|
|
}
|
|
.listTitle {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
.listName {
|
|
font-weight: 700;
|
|
}
|
|
.muted {
|
|
color: var(--ant-colorTextSecondary, #6b7280);
|
|
}
|
|
|
|
.mt12 { margin-top: 12px; }
|
|
|
|
/* ✅ Breakpoints reales */
|
|
@media (max-width: 768px) {
|
|
.page { padding: 8px; }
|
|
|
|
.topbarRight {
|
|
width: 100%;
|
|
min-width: 0;
|
|
align-items: stretch;
|
|
}
|
|
.btnTop {
|
|
width: 100%;
|
|
min-width: 0;
|
|
}
|
|
|
|
.testHeadRight {
|
|
width: 100%;
|
|
min-width: 0;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.testInfoGrid {
|
|
grid-template-columns: 1fr; /* ✅ cards info en columna */
|
|
}
|
|
|
|
.summaryGrid {
|
|
grid-template-columns: 1fr; /* ✅ resumen en columna */
|
|
}
|
|
|
|
.actions {
|
|
grid-template-columns: 1fr; /* ✅ botones uno debajo del otro */
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.testBox { padding: 12px; }
|
|
.testTitle { font-size: 16px; }
|
|
}
|
|
</style>
|