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

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