|
|
|
|
<script setup>
|
|
|
|
|
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`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** ---------------------------
|
|
|
|
|
* 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 {
|
|
|
|
|
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: "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.");
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
/* Cards */
|
|
|
|
|
.soft-card {
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
}
|
|
|
|
|
.pill-tag {
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
padding: 4px 10px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 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>
|