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.

610 lines
17 KiB
Vue

2 months ago
<script setup>
2 months ago
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`;
2 months ago
}
2 months ago
/** ---------------------------
* 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;
2 months ago
try {
2 months ago
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.");
2 months ago
} finally {
2 months ago
loading.value = false;
2 months ago
}
}
2 months ago
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.");
2 months ago
}
2 months ago
},
});
2 months ago
}
2 months ago
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.");
}
},
});
2 months ago
}
2 months ago
function onViewProcess(process) {
message.info(`Abrir detalle del proceso: ${process.name}`);
2 months ago
}
onMounted(async () => {
2 months ago
await fetchDashboard();
timer = setInterval(() => (nowTick.value = Date.now()), 1000);
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
2 months ago
</script>
2 months ago
<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>
2 months ago
<style scoped>
2 months ago
/* Cards */
.soft-card {
border-radius: 14px;
}
.pill-tag {
border-radius: 999px;
padding: 4px 10px;
font-weight: 600;
2 months ago
}
2 months ago
/* 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;
2 months ago
}
2 months ago
/* 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;
2 months ago
display: flex;
gap: 8px;
flex-wrap: wrap;
}
2 months ago
.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;
}
2 months ago
2 months ago
/* 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;
2 months ago
}
2 months ago
/* 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;
2 months ago
}
2 months ago
</style>