last_updated

main
elmer-20 2 months ago
parent 7a528bd7bf
commit 618d9b4bb7

@ -3,7 +3,6 @@
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\Noticia;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@ -18,13 +17,12 @@ class NoticiaController extends Controller
$query = Noticia::query();
// filtros opcionales
if ($request->filled('publicado')) {
$query->where('publicado', $request->boolean('publicado'));
}
if ($request->filled('categoria')) {
$query->where('categoria', $request->string('categoria'));
$query->where('categoria', (string) $request->get('categoria'));
}
if ($request->filled('q')) {
@ -44,7 +42,7 @@ class NoticiaController extends Controller
return response()->json([
'success' => true,
'data' => $data->items(),
'data' => $data->items(), // incluye imagen_url por el accessor/appends
'meta' => [
'current_page' => $data->currentPage(),
'last_page' => $data->lastPage(),
@ -59,22 +57,22 @@ class NoticiaController extends Controller
{
return response()->json([
'success' => true,
'data' => $noticia,
'data' => $noticia, // incluye imagen_url por el accessor/appends
]);
}
// GET /api/noticias/{noticia}
public function showPublic(Noticia $noticia)
{
abort_unless($noticia->publicado, 404);
return response()->json([
'success' => true,
'data' => $noticia,
]);
}
// GET /api/noticias-publicas/{noticia} (o la ruta que uses)
public function showPublic(Noticia $noticia)
{
abort_unless($noticia->publicado, 404);
return response()->json([
'success' => true,
'data' => $noticia,
]);
}
// POST /api/noticias (multipart/form-data si viene imagen)
// POST /api/noticias (multipart/form-data si viene imagen)
public function store(Request $request)
{
$data = $request->validate([
@ -84,9 +82,12 @@ public function showPublic(Noticia $noticia)
'contenido' => ['nullable', 'string'],
'categoria' => ['nullable', 'string', 'max:80'],
'tag_color' => ['nullable', 'string', 'max:30'],
// ✅ dos formas de imagen
'imagen' => ['nullable', 'file', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
'imagen_path' => ['nullable', 'string', 'max:255'],
'link_url' => ['nullable', 'string', 'max:600'],
'imagen_url' => ['nullable', 'url', 'max:600'],
'link_url' => ['nullable', 'url', 'max:600'],
'link_texto' => ['nullable', 'string', 'max:120'],
'fecha_publicacion' => ['nullable', 'date'],
'publicado' => ['nullable', 'boolean'],
@ -94,15 +95,19 @@ public function showPublic(Noticia $noticia)
'orden' => ['nullable', 'integer'],
]);
// slug por defecto
// slug por defecto (igual tu modelo lo genera, pero aquí lo dejamos por consistencia)
if (empty($data['slug'])) {
$data['slug'] = Str::slug($data['titulo']);
}
// subir imagen si viene
// si viene archivo, manda a storage y prioriza archivo
if ($request->hasFile('imagen')) {
$path = $request->file('imagen')->store('noticias', 'public');
$data['imagen_path'] = $path;
$data['imagen_url'] = null; // ✅ evita conflicto con url externa
} else {
// si viene imagen_url externa, no debe haber imagen_path
$data['imagen_path'] = null;
}
// si publican sin fecha, poner ahora
@ -114,7 +119,7 @@ public function showPublic(Noticia $noticia)
return response()->json([
'success' => true,
'data' => $noticia,
'data' => $noticia->fresh(),
], 201);
}
@ -128,9 +133,12 @@ public function showPublic(Noticia $noticia)
'contenido' => ['sometimes', 'nullable', 'string'],
'categoria' => ['sometimes', 'nullable', 'string', 'max:80'],
'tag_color' => ['sometimes', 'nullable', 'string', 'max:30'],
// ✅ dos formas de imagen
'imagen' => ['sometimes', 'nullable', 'file', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
'imagen_path' => ['sometimes', 'nullable', 'string', 'max:255'],
'link_url' => ['sometimes', 'nullable', 'string', 'max:600'],
'imagen_url' => ['sometimes', 'nullable', 'url', 'max:600'],
'link_url' => ['sometimes', 'nullable', 'url', 'max:600'],
'link_texto' => ['sometimes', 'nullable', 'string', 'max:120'],
'fecha_publicacion' => ['sometimes', 'nullable', 'date'],
'publicado' => ['sometimes', 'boolean'],
@ -138,17 +146,36 @@ public function showPublic(Noticia $noticia)
'orden' => ['sometimes', 'integer'],
]);
// si llega imagen, reemplazar
// Si llega imagen archivo, reemplaza la anterior y limpia imagen_url externa
if ($request->hasFile('imagen')) {
if ($noticia->imagen_path && Storage::disk('public')->exists($noticia->imagen_path)) {
Storage::disk('public')->delete($noticia->imagen_path);
}
$path = $request->file('imagen')->store('noticias', 'public');
$data['imagen_path'] = $path;
$data['imagen_url'] = null; // ✅ prioridad archivo
}
// Si llega imagen_url (externa), borra la imagen física anterior y limpia imagen_path
if (array_key_exists('imagen_url', $data) && !empty($data['imagen_url'])) {
if ($noticia->imagen_path && Storage::disk('public')->exists($noticia->imagen_path)) {
Storage::disk('public')->delete($noticia->imagen_path);
}
$data['imagen_path'] = null;
}
// Si explícitamente mandan imagen_url = null (quitar url) y no mandan archivo,
// no tocamos imagen_path (se queda como está). Si quieres que también limpie path,
// dime y lo cambiamos.
// si se marca publicado y no hay fecha, set now
if (array_key_exists('publicado', $data) && $data['publicado'] && empty($noticia->fecha_publicacion) && empty($data['fecha_publicacion'])) {
if (
array_key_exists('publicado', $data) &&
$data['publicado'] &&
empty($noticia->fecha_publicacion) &&
empty($data['fecha_publicacion'])
) {
$data['fecha_publicacion'] = now();
}
@ -168,7 +195,6 @@ public function showPublic(Noticia $noticia)
// DELETE /api/noticias/{noticia}
public function destroy(Noticia $noticia)
{
// opcional: borrar imagen al eliminar
if ($noticia->imagen_path && Storage::disk('public')->exists($noticia->imagen_path)) {
Storage::disk('public')->delete($noticia->imagen_path);
}

@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class Noticia extends Model
@ -20,6 +21,7 @@ class Noticia extends Model
'categoria',
'tag_color',
'imagen_path',
'imagen_url', // ✅ agrega esto si también lo guardas en BD
'link_url',
'link_texto',
'fecha_publicacion',
@ -35,15 +37,24 @@ class Noticia extends Model
'orden' => 'integer',
];
// ✅ se incluirá en el JSON
protected $appends = ['imagen_url'];
public function getImagenUrlAttribute(): ?string
{
if (!$this->imagen_path) return null;
return asset('storage/' . ltrim($this->imagen_path, '/'));
// 1) Si en BD hay una URL externa, úsala
if (!empty($this->attributes['imagen_url'])) {
return $this->attributes['imagen_url'];
}
// 2) Si hay imagen en storage, genera URL absoluta
if (!empty($this->imagen_path)) {
return url(Storage::disk('public')->url($this->imagen_path));
}
return null;
}
// Auto-generar slug si no viene
protected static function booted(): void
{
static::saving(function (Noticia $noticia) {

@ -79,7 +79,7 @@ Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
Route::delete('/noticias/{noticia}', [NoticiaController::class, 'destroy']);
});
Route::get('/noticias', [NoticiaController::class, 'index']);
Route::get('/noticias/{noticia}', [NoticiaController::class, 'showPublic']);
Route::get('/noticias/{noticia:slug}', [NoticiaController::class, 'showPublic']);
Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {

@ -9,7 +9,7 @@ const api = axios.create({
}
});
// Request interceptor
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
@ -25,7 +25,7 @@ api.interceptors.request.use(
}
)
// Response interceptor
api.interceptors.response.use(
(response) => {
return response
@ -33,21 +33,19 @@ api.interceptors.response.use(
async (error) => {
const originalRequest = error.config
// Si el error es 401 y no es un intento de re-autenticación
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
// Limpiar autenticación
localStorage.removeItem('token')
localStorage.removeItem('user')
// Redirigir a login
router.push('/login')
router.push('account/auth/login')
return Promise.reject(error)
}
// Manejar otros errores
if (error.response?.status === 403) {
router.push('/unauthorized')
}

@ -13,7 +13,7 @@
<p class="footer-text">
Institución pública de educación superior comprometida con la
formación académica, científica y humanística de la región.
formación académica de la región.
</p>
</div>
@ -40,8 +40,8 @@
<h4>Contacto</h4>
<ul>
<li>Av. Floral N° 1153 Puno</li>
<li>📞 (051) 123-456</li>
<li> admision@unap.edu.pe</li>
<li>📞 (+51) 957 734 361</li>
<li> dgadmision@unap.edu.pe</li>
</ul>
</div>
</div>

@ -1,8 +1,7 @@
<template>
<section class="process-section" aria-labelledby="faq-title">
<div class="section-container">
<!-- Header -->
<div class="section-header">
<h2 id="faq-title" class="section-title">
Preguntas Frecuentes
@ -15,7 +14,6 @@
<div class="process-card">
<!-- FAQ -->
<a-collapse accordion class="modern-collapse">
<a-collapse-panel key="1" header="01. ¿Puedo postular con DNI caducado?">
@ -76,7 +74,6 @@
</template>
<style scoped>
/* reutiliza la misma base visual de ProcessSection */
.process-section {
padding: 30px 0;
@ -116,7 +113,7 @@
background: #fff;
}
/* Ilustración */
.illustration-box {
text-align: center;
margin-bottom: 20px;
@ -132,7 +129,6 @@
color: #6b7280;
}
/* Collapse estilo limpio */
.modern-collapse {
background: transparent;
}
@ -154,7 +150,6 @@
border-top: 1px solid #eef2f7;
}
/* Responsive */
@media (max-width: 768px) {
.section-title {
font-size: 1.6rem;

@ -15,7 +15,6 @@
<a-skeleton v-if="store.loading" active :paragraph="{ rows: 8 }" />
<div v-else class="convocatorias-grid">
<!-- PRINCIPAL -->
<a-card v-if="store.procesoPrincipal" class="main-convocatoria-card">
<div class="card-badge">Principal</div>
@ -103,14 +102,13 @@
<a-divider class="custom-divider" />
<!-- PREINSCRIPCION -->
<div class="preinscripcion-section">
<div class="preinscripcion-info">
<h4 class="subheading">Preinscripción en Línea</h4>
<p>Completa tu preinscripción de manera virtual y segura</p>
</div>
<!-- Botón centrado abajo y estilo igual a Portal del Postulante -->
<div v-if="store.procesoPrincipal?.link_preinscripcion" class="preinscripcion-btn-wrap">
<a-button
type="primary"
@ -138,7 +136,6 @@
</div>
</a-card>
<!-- SECUNDARIAS (hardcode por ahora) -->
<div class="secondary-list">
<a-card class="secondary-convocatoria-card">
@ -192,7 +189,6 @@
</div>
</section>
<!-- MODAL (para detalles por tipo) -->
<a-modal
v-model:open="modalVisible"
:title="tituloModal"
@ -206,7 +202,6 @@
:key="detalle.id"
style="margin-bottom: 25px"
>
<!-- <h3>{{ detalle.titulo_detalle }}</h3> -->
<p v-if="detalle.descripcion">
{{ detalle.descripcion }}
@ -494,14 +489,12 @@ const abrirPorTipo = (tipo) => {
color: #666;
}
/* fila inferior centrada */
.preinscripcion-btn-wrap {
display: flex;
justify-content: center;
margin-top: 6px;
}
/* botón claro estilo AntDV */
.preinscripcion-btn-light {
height: 46px;
padding: 0 18px;

@ -5,12 +5,10 @@
:style="{ backgroundImage: `url(${heroImg})` }"
aria-label="Sección principal de admisión"
>
<!-- Overlay oscuro -->
<div class="hero-overlay"></div>
<div class="hero-container">
<!-- CONTENIDO -->
<div class="hero-content">
<div class="hero-badges">
<a-tag class="hero-tag">Convocatoria 2026</a-tag>
@ -93,7 +91,6 @@ defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
<style scoped>
/* 🔥 TODA LA SECCIÓN EN TIMES */
.hero,
.hero * {
font-family: "Times New Roman", Times, serif;
@ -107,7 +104,7 @@ defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
background-position: center;
background-repeat: no-repeat;
overflow: hidden;
--text: rgba(245, 247, 255, 0.92); /* blanco suave */
--text: rgba(245, 247, 255, 0.92);
--muted: rgba(229, 235, 255, 0.72);
}
.hero-container {
@ -147,7 +144,6 @@ defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
font-weight: bold;
}
/* TITULO */
.hero-title {
font-size: 3.5rem;
font-weight: bold;
@ -211,7 +207,7 @@ defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
background: rgba(190, 200, 228, 0.55);
padding: 12px 24px;
border-radius: 16px;
width: fit-content; /* 👈 se ajusta al contenido */
width: fit-content;
max-width: 100%;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}

@ -1,7 +1,6 @@
<!-- src/components/web/NewsSection.vue -->
<template>
<!-- Si no hay noticias, NO renderiza NADA -->
<section class="news-section">
<section class="news-section">
<div class="container">
<!-- Header centrado -->
<div class="header">
@ -19,84 +18,145 @@
<a-divider class="divider" />
<!-- Grid -->
<div v-if="mappedNoticias.length">
<a-row :gutter="[24, 24]">
<a-col
v-for="noticia in mappedNoticias"
:key="noticia.id"
:xs="24"
:sm="12"
:lg="8"
>
<a-badge-ribbon
:text="noticia.categoria || 'Noticia'"
:color="noticia.tagColor"
<div v-if="mappedNoticias.length">
<a-row :gutter="[24, 24]">
<a-col
v-for="noticia in mappedNoticias"
:key="noticia.id"
:xs="24"
:sm="12"
:lg="8"
>
<a-card hoverable class="card" :bodyStyle="{ padding: '16px' }">
<!-- Cover SOLO si hay imagen -->
<template v-if="noticia.imagen" #cover>
<div
class="cover"
:style="{ backgroundImage: `url(${noticia.imagen})` }"
>
<div class="cover-overlay" />
<div class="date-pill">
<a-badge-ribbon
:text="noticia.categoria || 'Noticia'"
:color="noticia.tagColor"
>
<a-card
hoverable
class="card"
:bodyStyle="{ padding: '16px' }"
@click="openModal(noticia)"
>
<template v-if="noticia.imagen" #cover>
<div
class="cover"
:style="{ backgroundImage: `url(${noticia.imagen})` }"
>
<div class="cover-overlay" />
<div class="date-pill">
<CalendarOutlined />
<span>{{ noticia.fecha }}</span>
</div>
</div>
</template>
<a-space direction="vertical" size="small" class="content">
<div v-if="!noticia.imagen" class="date-inline">
<CalendarOutlined />
<span>{{ noticia.fecha }}</span>
</div>
</div>
</template>
<a-space direction="vertical" size="small" class="content">
<!-- Si NO hay imagen, mostramos la fecha aquí -->
<div v-if="!noticia.imagen" class="date-inline">
<CalendarOutlined />
<span>{{ noticia.fecha }}</span>
</div>
<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" @click="handleLeerMas(noticia)">
Leer más
<ArrowRightOutlined />
</a-button>
<a-tag v-if="noticia.destacado" color="gold" class="tag-soft">
Destacado
</a-tag>
</div>
</a-space>
</a-card>
</a-badge-ribbon>
</a-col>
</a-row>
<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"
@click.stop="openModal(noticia)"
>
Leer más
<ArrowRightOutlined />
</a-button>
<a-tag v-if="noticia.destacado" color="gold" class="tag-soft">
Destacado
</a-tag>
</div>
</a-space>
</a-card>
</a-badge-ribbon>
</a-col>
</a-row>
</div>
<a-modal
v-model:open="modalOpen"
:title="modalTitle"
:footer="null"
:width="860"
destroyOnClose
@afterClose="onAfterClose"
>
<div v-if="noticiasStore.loadingOne" style="padding: 8px 0">
<a-skeleton active :paragraph="{ rows: 6 }" />
</div>
<a-alert
v-else-if="noticiasStore.error"
type="error"
show-icon
:message="noticiasStore.error"
/>
<div v-else>
<div v-if="modalImage" class="modal-cover">
<img :src="modalImage" alt="Imagen de noticia" />
</div>
<div class="modal-meta">
<a-tag :color="modalTagColor">{{ modalCategoria }}</a-tag>
<span class="modal-date">
<CalendarOutlined />
{{ modalFecha }}
</span>
</div>
<a-typography-paragraph v-if="modalDescripcion" class="modal-desc">
{{ modalDescripcion }}
</a-typography-paragraph>
<div v-if="modalContenido" class="modal-content" v-html="modalContenido" />
<a-empty v-else description="No hay contenido disponible para esta noticia." />
<div v-if="modalLinkUrl" class="modal-link">
<a-button
type="primary"
:href="modalLinkUrl"
target="_blank"
rel="noopener"
>
{{ modalLinkTexto }}
</a-button>
</div>
</div>
</a-modal>
</div>
</section>
</template>
<script setup>
import { computed, onMounted } from "vue"
import { computed, onMounted, ref } from "vue"
import { CalendarOutlined, ArrowRightOutlined } from "@ant-design/icons-vue"
import { useNoticiasPublicasStore } from "../../store/noticiasPublicas.store"
const noticiasStore = useNoticiasPublicasStore()
onMounted(() => {
// si ya está cargado, no vuelve a pedir
if (!noticiasStore.noticias.length) noticiasStore.fetchNoticias()
})
@ -105,10 +165,7 @@ const mappedNoticias = computed(() => {
const titulo = n.titulo ?? "Sin título"
const descripcion = n.descripcion_corta ?? n.descripcion ?? "Sin descripción"
// SIN imagen por defecto (NO /images/extra.jpg)
const imagen =
n.imagen_url ||
(n.imagen_path ? `http://localhost:8000/storage/${n.imagen_path}` : null)
const imagen = n.imagen_url ?? null
return {
id: n.id,
@ -125,10 +182,55 @@ const mappedNoticias = computed(() => {
})
})
const handleLeerMas = (noticia) => {
// aquí tú decides: navegar, abrir modal, etc.
// por ahora solo lo dejamos listo para que conectes tu acción
console.log("Leer más:", noticia.slug || noticia.id)
const modalOpen = ref(false)
const selectedNoticia = ref(null)
const modalTitle = computed(() => selectedNoticia.value?.titulo ?? "Detalle de noticia")
const detail = computed(() => noticiasStore.noticiaActual)
const modalImage = computed(() => {
const d = detail.value
return d?.imagen_url ?? selectedNoticia.value?.imagen ?? null
})
const modalFecha = computed(() => {
const d = detail.value
return formatFecha(d?.fecha_publicacion ?? selectedNoticia.value?.raw?.fecha_publicacion)
})
const modalCategoria = computed(() => {
return detail.value?.categoria ?? selectedNoticia.value?.categoria ?? "General"
})
const modalTagColor = computed(() => {
return normalizeTagColor(detail.value?.tag_color ?? selectedNoticia.value?.raw?.tag_color)
})
const modalDescripcion = computed(() => {
const d = detail.value
return d?.descripcion_corta ?? selectedNoticia.value?.descripcion ?? ""
})
const modalContenido = computed(() => {
return detail.value?.contenido ?? ""
})
const modalLinkUrl = computed(() => detail.value?.link_url ?? null)
const modalLinkTexto = computed(() => detail.value?.link_texto ?? "Leer más")
const openModal = async (noticia) => {
selectedNoticia.value = noticia
modalOpen.value = true
noticiasStore.clearNoticiaActual()
const identifier = noticia.slug || noticia.id
await noticiasStore.fetchNoticia(identifier)
}
const onAfterClose = () => {
noticiasStore.clearNoticiaActual()
selectedNoticia.value = null
}
const formatFecha = (iso) => {
@ -162,6 +264,7 @@ const normalizeTagColor = (c) => {
}
</script>
<style scoped>
.news-section {
position: relative;
@ -257,8 +360,12 @@ const normalizeTagColor = (c) => {
.cover {
position: relative;
height: 200px;
background-size: cover;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
background-color: rgba(17, 26, 86, 0.06);
}
.cover-overlay {
@ -333,6 +440,70 @@ const normalizeTagColor = (c) => {
font-weight: 700;
}
.modal-cover {
width: 100%;
height: 360px;
overflow: hidden;
border-radius: 14px;
margin-bottom: 14px;
border: 1px solid rgba(17, 26, 86, 0.10);
background: rgba(17, 26, 86, 0.05);
display: flex;
align-items: center;
justify-content: center;
}
.modal-cover img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.modal-meta {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 0 10px;
}
.modal-date {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(0, 0, 0, 0.55);
font-weight: 700;
}
.modal-desc {
color: rgba(0, 0, 0, 0.70);
line-height: 1.7;
margin-bottom: 12px !important;
}
.modal-content {
line-height: 1.8;
color: rgba(0, 0, 0, 0.80);
}
.modal-content :deep(img) {
max-width: 100%;
height: auto;
display: block;
}
.modal-content :deep(p) {
margin: 0 0 12px;
}
.modal-content :deep(img) {
max-width: 100%;
border-radius: 12px;
}
.modal-link {
margin-top: 16px;
}
@media (max-width: 768px) {
.news-section {
padding: 64px 0;

@ -120,11 +120,9 @@ import { useWebAdmisionStore } from "../../store/web"
const store = useWebAdmisionStore()
/** Responsive */
const isMobile = ref(false)
const checkScreen = () => (isMobile.value = window.innerWidth < 768)
/** reloj (para refrescar estados “activo hoy”) */
const now = ref(new Date())
let timer = null
@ -142,7 +140,6 @@ onUnmounted(() => {
if (timer) clearInterval(timer)
})
/** Helpers fecha */
const toDate = (value) => {
if (!value) return null
const d = new Date(value)
@ -196,18 +193,13 @@ const fmtRange = (start, end) => {
return "Por definir"
}
/** Título */
const tituloProceso = computed(() => {
const p = store.procesoPrincipal
if (!p) return "Proceso de Admisión"
return p.titulo || p.tipo_proceso || "Proceso de Admisión"
})
/**
* REGLA IMPORTANTE:
* Preinscripción dura hasta el fin de inscripción:
* inicio_preinscripcion -> fin_inscripcion
*/
const active = computed(() => {
const p = store.procesoPrincipal
if (!p) return { pre: false, ins: false, exa: false, res: false, bio: false }
@ -240,7 +232,6 @@ const noActive = computed(() => {
return !a.pre && !a.ins && !a.exa && !a.res && !a.bio
})
/** Status por step: permite que PRE e INS sean "process" a la vez */
const getStepStatus = (index, p) => {
const n = now.value
@ -282,7 +273,7 @@ const getStepStatus = (index, p) => {
return "wait"
}
/** Poner un “🟢” cuando esté activo */
const withActiveBadge = (label, isActive) => (isActive ? `🟢 ${label}` : label)
const stepsItems = computed(() => {
@ -327,7 +318,6 @@ const stepsItems = computed(() => {
]
})
/** “Qué hacer hoy” (claro para jóvenes) */
const tareasHoy = computed(() => {
const p = store.procesoPrincipal
if (!p) return []
@ -486,7 +476,6 @@ const tareasHoy = computed(() => {
flex-shrink: 0;
}
/* ====== Guía rápida ====== */
.help-box {
margin-top: 14px;
border-top: 1px dashed #e5e7eb;
@ -586,7 +575,6 @@ const tareasHoy = computed(() => {
line-height: 1.5;
}
/* Responsive */
@media (max-width: 992px) {
.section-title {
font-size: 1.85rem;

@ -1,4 +1,3 @@
<!-- components/programas/ProgramasSection.vue (SOLO ÁREAS, estilo AntDV) -->
<template>
<section class="areas-section">
<div class="section-container">

@ -1,4 +1,3 @@
<!-- components/stats/StatsSection.vue -->
<template>
<section class="stats-section" aria-labelledby="stats-title">
<div class="section-container">

@ -22,7 +22,7 @@
/>
</nav>
<div class="right-actions desktop-only">
<!-- <div class="right-actions desktop-only">
<router-link
v-if="!authStore.isAuthenticated"
to="/login-postulante"
@ -40,7 +40,7 @@
Mi Portal
</a-button>
</router-link>
</div>
</div> -->
<a-button class="mobile-menu-btn mobile-only" type="text" @click="drawerOpen = true">
@ -81,7 +81,7 @@
:openKeys="mobileOpenKeys"
/>
<div class="drawer-auth">
<!-- <div class="drawer-auth">
<router-link
v-if="!authStore.isAuthenticated"
to="/login-postulante"
@ -105,7 +105,7 @@
Mi Portal
</a-button>
</router-link>
</div>
</div> -->
</div>
</a-drawer>
</a-layout-header>
@ -144,7 +144,7 @@ const navItems = computed(() => [
{ key: "sociales", label: "Sociales" },
],
},
{ key: "procesos", label: "Procesos" },
{
key: "modalidades",
label: "Modalidades",
@ -165,11 +165,10 @@ watch(drawerOpen, (open) => {
const routesByKey = {
inicio: "/",
programas: "/programas",
ingenierias: "/programas/ingenierias",
biomedicas: "/programas/biomedicas",
sociales: "/programas/sociales",
procesos: "/procesos",
programas: "",
ingenierias: "",
biomedicas: "",
sociales: "",
modalidades: "/modalidades",
cepreuna: "/modalidades/cepreuna",
extraordinario: "/modalidades/extraordinario",

@ -10,7 +10,7 @@ const routes = [
{ path: '/', component: WebPage },
{ path: '/login', component: Login, meta: { guest: true } },
{ path: '/account/auth/login', component: Login, meta: { guest: true } },
{
path: '/login-postulante',

@ -8,8 +8,8 @@ export const useExamenStore = defineStore('examenStore', {
examenActual: null,
preguntas: [],
cargando: false,
calificando: false, // ✅ nuevo
resultado: null, // ✅ nuevo (para guardar resultados_examenes)
calificando: false,
resultado: null,
error: null,
}),
@ -105,7 +105,7 @@ export const useExamenStore = defineStore('examenStore', {
if (index !== -1 && data.success) {
this.preguntas[index].respuesta = respuesta
this.preguntas[index].es_correcta = data.correcta // 1, 0 o 2
this.preguntas[index].es_correcta = data.correcta
this.preguntas[index].puntaje = data.puntaje
}
@ -116,7 +116,6 @@ export const useExamenStore = defineStore('examenStore', {
}
},
// ✅ NUEVO: Calificar examen
async calificarExamen(examenId) {
try {
this.error = null
@ -182,8 +181,8 @@ export const useExamenStore = defineStore('examenStore', {
this.examenActual = null
this.preguntas = []
this.cargando = false
this.calificando = false // ✅ nuevo
this.resultado = null // ✅ nuevo
this.calificando = false
this.resultado = null
this.error = null
}
}

@ -1,4 +1,3 @@
// src/store/noticiasPublicas.store.js
import { defineStore } from "pinia"
import api from "../axiosPostulante"
@ -16,7 +15,6 @@ export const useNoticiasPublicasStore = defineStore("noticiasPublicas", {
this.loading = true
this.error = null
try {
// ✅ usa TU ruta real
const res = await api.get("/noticias", {
params: { publicado: true, per_page: 9999 },
})
@ -32,6 +30,7 @@ export const useNoticiasPublicasStore = defineStore("noticiasPublicas", {
async fetchNoticia(identifier) {
this.loadingOne = true
this.error = null
this.noticiaActual = null
try {
const res = await api.get(`/noticias/${identifier}`)
this.noticiaActual = res.data?.data ?? null

@ -104,7 +104,7 @@ export const useUserStore = defineStore('user', {
console.error('Error en logout:', error)
} finally {
this.clearAuth()
router.push('/login')
router.push('/account/auth/login')
}
},
@ -125,7 +125,7 @@ export const useUserStore = defineStore('user', {
redirectByRole() {
if (!this.user || !this.user.roles?.length) {
router.push('/login')
router.push('/account/auth/login')
return
}

@ -1,10 +1,10 @@
<template>
<div class="login-container">
<div class="login-card">
<!-- Logo y título -->
<div class="login-header">
<div class="logo">
<!-- <img src="/logo.png" alt="Logo" /> -->
<img src="/logotiny.png" alt="Logo" />
</div>
<h2>{{ isRegister ? 'Crear Cuenta' : 'Iniciar Sesión' }}</h2>
<p class="subtitle">
@ -15,7 +15,6 @@
</p>
</div>
<!-- Formulario -->
<a-form
ref="formRef"
:model="formState"
@ -24,8 +23,7 @@
layout="vertical"
class="login-form"
>
<!-- Nombre (solo registro) -->
<!-- Nombre (solo registro) -->
<a-form-item v-if="isRegister" label="Nombre completo" name="name">
<a-input
v-model:value="formState.name"
@ -39,7 +37,7 @@
</a-input>
</a-form-item>
<!-- Email -->
<a-form-item label="Correo electrónico" name="email">
<a-input
v-model:value="formState.email"
@ -53,7 +51,7 @@
</a-input>
</a-form-item>
<!-- Contraseña -->
<a-form-item label="Contraseña" name="password">
<a-input-password
v-model:value="formState.password"
@ -67,7 +65,7 @@
</a-input-password>
</a-form-item>
<!-- Confirmar contraseña (solo registro) -->
<a-form-item v-if="isRegister" label="Confirmar contraseña" name="password_confirmation">
<a-input-password
v-model:value="formState.password_confirmation"
@ -81,7 +79,7 @@
</a-input-password>
</a-form-item>
<!-- Recordarme (solo login) -->
<div v-if="!isRegister" class="remember-forgot">
<a-checkbox v-model:checked="rememberMe">
Recordarme
@ -91,7 +89,6 @@
</a-button>
</div>
<!-- Botón principal -->
<a-form-item>
<a-button
type="primary"
@ -105,7 +102,6 @@
</a-button>
</a-form-item>
<!-- Cambiar modo -->
<div class="toggle-mode">
<span>
{{ isRegister
@ -118,7 +114,6 @@
</a-button>
</div>
<!-- Términos (solo registro) -->
<div v-if="isRegister" class="terms">
<p>
Al registrarse, acepta nuestros
@ -153,19 +148,18 @@ const formState = reactive({
password_confirmation: ''
})
// Configurar notificación para que aparezca en el centro superior
notification.config({
placement: 'top',
duration: 2,
maxCount: 1,
})
// Función para mostrar toast/notification
const showToast = (type, message, description = '') => {
const config = {
message,
description,
duration: 2, // 2 segundos
duration: 2,
placement: 'top',
}
@ -187,7 +181,6 @@ const showToast = (type, message, description = '') => {
}
}
// Reglas de validación
const rules = {
name: [
{
@ -237,18 +230,15 @@ const rules = {
]
}
// Cambiar entre login/registro
const toggleMode = () => {
isRegister.value = !isRegister.value
formRef.value?.resetFields()
}
// Olvidó contraseña
const handleForgotPassword = () => {
showToast('info', 'Recuperación de contraseña', 'Contacte al administrador del sistema')
}
// Enviar formulario
const handleSubmit = async () => {
try {
loading.value = true
@ -259,7 +249,7 @@ const handleSubmit = async () => {
'¡Registro exitoso!',
'Tu cuenta ha sido creada correctamente'
)
toggleMode() // Cambiar a login
toggleMode()
} else {
await userStore.login(formState.email, formState.password)
showToast('success', '¡Bienvenido!', 'Inicio de sesión exitoso')
@ -267,7 +257,7 @@ const handleSubmit = async () => {
} catch (error) {
console.error('Error:', error)
// Extraer mensaje de error del backend
let errorMessage = 'Ocurrió un error inesperado'
let errorDetails = ''
@ -317,7 +307,7 @@ const handleSubmit = async () => {
errorDetails = 'Verifique su conexión a internet'
}
// Mostrar error en toast
showToast('error', errorMessage, errorDetails)
} finally {

@ -8,7 +8,6 @@
@cancel="handleCancel"
class="course-modal"
>
<!-- Barra de búsqueda -->
<div class="search-section">
<a-input-search
v-model:value="searchText"
@ -22,7 +21,6 @@
</a-input-search>
</div>
<!-- Lista de cursos -->
<div class="courses-container">
<a-spin :spinning="loading">
<div class="courses-list">
@ -124,15 +122,12 @@ const filteredCursos = computed(() => {
)
})
// Contador de cursos seleccionados
const selectedCursosCount = computed(() => selectedCursos.value.length)
// Verificar si un curso está seleccionado
const isCursoSelected = (cursoId) => {
return selectedCursos.value.includes(cursoId)
}
// Toggle de selección de curso
const toggleCurso = (cursoId) => {
const index = selectedCursos.value.indexOf(cursoId)
if (index > -1) {
@ -142,20 +137,17 @@ const toggleCurso = (cursoId) => {
}
}
// Buscar cursos
const handleSearch = () => {
// La búsqueda se maneja en computed filteredCursos
}
// Cancelar
const handleCancel = () => {
visible.value = false
selectedCursos.value = []
searchText.value = ''
areaStore.clearState() // Limpiar estado
areaStore.clearState()
}
// Guardar cambios
const handleSave = async () => {
try {
const result = await areaStore.vincularCursos(props.areaId, selectedCursos.value)
@ -171,7 +163,6 @@ const handleSave = async () => {
}
}
// Función para cargar cursos
const loadCursos = async () => {
if (props.areaId) {
await areaStore.fetchCursosPorArea(props.areaId)
@ -180,26 +171,21 @@ const loadCursos = async () => {
}
}
// Cargar cursos cuando se abre el modal
watch(() => props.open, async (newVal) => {
if (newVal && props.areaId) {
// Usar nextTick para asegurar que el modal esté montado
await nextTick()
await loadCursos()
}
})
// También cargar cuando cambia el áreaId (por si cambia mientras el modal está abierto)
watch(() => props.areaId, async (newVal) => {
if (props.open && newVal) {
await loadCursos()
}
})
// Limpiar al cerrar
watch(() => props.open, (newVal) => {
if (!newVal) {
// Pequeño delay para permitir que la animación de cierre termine
setTimeout(() => {
selectedCursos.value = []
searchText.value = ''
@ -208,7 +194,6 @@ watch(() => props.open, (newVal) => {
}
})
// También cargar cursos cuando el componente se monta si ya está abierto
onMounted(() => {
if (props.open && props.areaId) {
loadCursos()
@ -302,7 +287,6 @@ onMounted(() => {
border-top: 1px solid #f0f0f0;
}
/* Scrollbar personalizado */
.courses-container::-webkit-scrollbar {
width: 6px;
}

@ -8,7 +8,6 @@
@cancel="handleCancel"
class="process-modal"
>
<!-- Barra de búsqueda -->
<div class="search-section">
<a-input-search
v-model:value="searchText"
@ -22,7 +21,6 @@
</a-input-search>
</div>
<!-- Lista de procesos -->
<div class="processes-container">
<a-spin :spinning="loading">
<div v-if="procesosDisponibles.length === 0 && !loading" class="empty-state">
@ -69,7 +67,7 @@
</a-spin>
</div>
<!-- Resumen -->
<div class="summary-section">
<a-alert
:message="`${selectedProcesosCount} procesos seleccionados de ${procesosDisponibles.length} disponibles`"
@ -78,7 +76,6 @@
/>
</div>
<!-- Acciones -->
<div class="modal-footer">
<a-button @click="handleCancel">Cancelar</a-button>
<a-button type="primary" @click="handleSave" :loading="loading">
@ -125,7 +122,6 @@ const loading = computed(() => areaStore.loading)
const procesosDisponibles = computed(() => areaStore.procesosDisponibles || [])
const procesosVinculados = computed(() => areaStore.procesosVinculados || [])
/* ================= FILTRO ================= */
const filteredProcesos = computed(() => {
if (!searchText.value) return procesosDisponibles.value
@ -150,7 +146,6 @@ const toggleProceso = (id) => {
: selectedProcesos.value.push(id)
}
/* ================= ACCIONES ================= */
const handleCancel = () => {
visible.value = false
resetModal()
@ -172,7 +167,7 @@ const handleSave = async () => {
}
const handleSearch = () => {
// La búsqueda se maneja en computed filteredProcesos
}
const loadProcesos = async () => {
@ -187,7 +182,6 @@ const resetModal = () => {
selectedProcesos.value = []
}
/* ================= WATCHERS ================= */
watch(() => props.open, async (open) => {
if (open && props.areaId) {
await nextTick()
@ -201,14 +195,12 @@ watch(() => props.areaId, async (id) => {
}
})
// También cargar cursos cuando el componente se monta si ya está abierto
onMounted(() => {
if (props.open && props.areaId) {
loadProcesos()
}
})
// Limpiar al cerrar
watch(() => props.open, (newVal) => {
if (!newVal) {
setTimeout(() => {

@ -1,7 +1,6 @@
<template>
<div class="postulantes-container">
<!-- Header -->
<div class="header">
<div>
<h2 class="page-title">Gestión de Postulantes</h2>
@ -16,7 +15,6 @@
/>
</div>
<!-- Tabla -->
<a-card>
<a-table
:columns="columns"
@ -28,27 +26,22 @@
>
<template #bodyCell="{ column, record }">
<!-- Nombre -->
<template v-if="column.key === 'name'">
<strong>{{ record.name }}</strong>
</template>
<!-- Email -->
<template v-else-if="column.key === 'email'">
{{ record.email }}
</template>
<!-- DNI -->
<template v-else-if="column.key === 'dni'">
<a-tag color="blue">{{ record.dni }}</a-tag>
</template>
<!-- Última actividad -->
<template v-else-if="column.key === 'last_activity'">
{{ record.last_activity ?? 'Sin actividad' }}
</template>
<!-- Acciones -->
<template v-else-if="column.key === 'acciones'">
<a-space>
<a-button type="text" @click="editar(record)">
@ -61,7 +54,6 @@
</a-table>
</a-card>
<!-- Modal Editar -->
<a-modal
v-model:open="showModal"
title="Editar Postulante"

@ -505,7 +505,7 @@ const updatePageInfo = (key) => {
const logout = () => {
userStore.logout()
router.push('/login')
router.push('/account/auth/login')
}
const irPerfil = () => {

@ -1,4 +1,3 @@
<!-- DashboardPostulante.vue (test + procesos activos) -->
<template>
<a-spin :spinning="loading">
@ -15,7 +14,6 @@
<a-divider class="softDivider" />
<!-- TEST -->
<a-card :bordered="false" class="testBox">
@ -55,7 +53,6 @@
<a-divider class="softDivider" />
<!-- PROCESOS ACTIVOS -->
<div class="sectionHead">
<div>
@ -114,7 +111,6 @@
</div>
</div>
<!-- Mensaje cuando no hay procesos -->
<a-empty
v-if="!loading && state.processes.length === 0"
class="mt12"
@ -130,9 +126,9 @@ import { Modal, message } from "ant-design-vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../../store/postulanteStore";
// Ajusta a tus rutas reales
const ROUTE_TEST_PANEL = { name: "PanelTest" }; // tu panel del test
const ROUTE_PROCESS_DETAIL = (id) => ({ name: "ProcesoDetalle", params: { id } }); // opcional
const ROUTE_TEST_PANEL = { name: "PanelTest" };
const ROUTE_PROCESS_DETAIL = (id) => ({ name: "ProcesoDetalle", params: { id } });
const router = useRouter();
const authStore = useAuthStore();
@ -140,8 +136,8 @@ const authStore = useAuthStore();
const loading = ref(false);
const state = reactive({
test: { hasAssigned: true }, // viene de tu backend/store
processes: [], // procesos activos
test: { hasAssigned: true },
processes: [],
});
const canGoTest = computed(() => !!state.test.hasAssigned);
@ -153,7 +149,7 @@ const processColumns = computed(() => [
{ title: "Acciones", key: "actions" },
]);
// Mock: reemplaza por tu store / api real
const api = {
async getDashboard() {
return new Promise((resolve) => {
@ -217,7 +213,7 @@ function goToTest() {
}
function onViewProcess(process) {
// router.push(ROUTE_PROCESS_DETAIL(process.id));
message.info(`Abrir detalle del proceso: ${process.name}`);
}
@ -245,9 +241,7 @@ onMounted(fetchDashboard);
</script>
<style scoped>
/* =========================
BASE estilo Convocatorias
========================= */
.dashboard-modern {
position: relative;
padding: 40px 0;
@ -289,7 +283,7 @@ onMounted(fetchDashboard);
padding: 0 24px;
}
/* Layout container */
.page {
width: 100%;
max-width: 1120px;
@ -297,14 +291,12 @@ onMounted(fetchDashboard);
padding: 0;
}
/* Helpers */
.mt12 { margin-top: 12px; }
.microHelp { font-size: 0.95rem; color: #666; margin-top: 6px; }
.softDivider { margin: 18px 0; }
/* =========================
Topbar
========================= */
.topbar {
display: flex;
justify-content: space-between;
@ -339,9 +331,7 @@ onMounted(fetchDashboard);
font-weight: 700;
}
/* =========================
Test destacado (card principal)
========================= */
.testBox {
position: relative;
border: none;
@ -354,7 +344,6 @@ onMounted(fetchDashboard);
padding: 28px;
}
/* Badge tipo convocatorias */
.cardBadge {
position: absolute;
top: -12px;
@ -424,9 +413,7 @@ onMounted(fetchDashboard);
flex: 0 0 auto;
}
/* =========================
Section header
========================= */
.sectionHead {
display: flex;
justify-content: space-between;
@ -458,9 +445,6 @@ onMounted(fetchDashboard);
border-radius: 999px;
}
/* =========================
Table "card look"
========================= */
.tableWrap {
width: 100%;
overflow-x: auto;
@ -489,7 +473,6 @@ onMounted(fetchDashboard);
color: #666;
}
/* Status pill estilo convocatorias */
.status-tag {
font-weight: 700;
padding: 4px 12px;
@ -497,7 +480,6 @@ onMounted(fetchDashboard);
white-space: nowrap;
}
/* Actions */
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
@ -516,46 +498,40 @@ onMounted(fetchDashboard);
font-weight: 700;
}
/* Responsive */
@media (max-width: 768px) {
.hide-mobile{
display: none !important;
}
/* ✅ Quita el padding superior del wrapper (era 40px) */
.test-modern{
padding-top: 0 !important;
padding-bottom: 24px; /* opcional */
padding-bottom: 24px;
}
/* ✅ Quita padding lateral del container para que sea edge-to-edge */
.section-container{
max-width: none;
padding: 0 !important;
margin: 0 !important;
}
/* ✅ Card principal a todo el ancho y pegado arriba */
.hero{
grid-template-columns: 1fr;
width: 100%;
margin: 0 !important;
border-radius: 0 !important;
/* importante: sin “espacio arriba” */
padding: 14px 16px 18px !important;
}
/* ✅ Asegura que el primer texto no empuje hacia abajo */
.heroKicker{ margin-top: 0 !important; }
.heroTitle{ margin-top: 6px !important; }
.heroText{ margin-top: 8px !important; }
/* Para que la sección “Tu camino” no se pegue a los bordes */
.section{
padding: 0 16px;
}
/* Facts */
.heroFacts{
grid-template-columns: repeat(2, minmax(0, 1fr));
}

@ -4,10 +4,10 @@
<a-col :xs="22" :sm="20" :md="20" :lg="16" :xl="14">
<div class="auth-shell">
<a-row :gutter="[0, 0]" class="auth-layout">
<!-- PANEL IZQUIERDO: FORM -->
<a-col :xs="24" :md="12" class="auth-pane auth-pane-form">
<div class="pane-inner">
<!-- Branding -->
<div class="brand">
<div class="brand-mark">
<img
@ -56,7 +56,6 @@
</a-input>
</a-form-item>
<!-- Nombre -->
<a-form-item v-if="isRegister" label="Nombre completo" name="name">
<a-input
v-model:value="formState.name"
@ -67,14 +66,14 @@
</a-input>
</a-form-item>
<!-- Email -->
<a-form-item label="Correo electrónico" name="email">
<a-input v-model:value="formState.email" size="large" placeholder="correo@ejemplo.com">
<template #prefix><MailOutlined /></template>
</a-input>
</a-form-item>
<!-- Password -->
<a-form-item label="Contraseña" name="password">
<a-input-password
v-model:value="formState.password"
@ -85,7 +84,7 @@
</a-input-password>
</a-form-item>
<!-- Confirm Password -->
<a-form-item
v-if="isRegister"
label="Confirmar contraseña"
@ -100,7 +99,7 @@
</a-input-password>
</a-form-item>
<!-- Recordarme -->
<a-row
v-if="!isRegister"
justify="space-between"
@ -138,7 +137,6 @@
</div>
</a-col>
<!-- PANEL DERECHO: INFO (CORTO Y DINÁMICO) -->
<a-col :xs="24" :md="12" class="auth-pane auth-pane-info">
<div class="pane-inner pane-inner-info">
<div class="info-top">
@ -223,10 +221,7 @@ const isRegister = ref(false);
const rememberMe = ref(false);
const loading = ref(false);
/**
* Logo en /public (recomendado):
* public/logotiny.png -> "/logotiny.png"
*/
const logoSrc = "/logotiny.png";
const logoError = ref(false);
@ -479,7 +474,6 @@ checkExistingAuth();
font-weight: 800;
}
/* Info panel corto */
.info-top {
text-align: left;
}
@ -531,7 +525,6 @@ checkExistingAuth();
border-top: 1px solid var(--ant-colorBorderSecondary, rgba(0, 0, 0, 0.06));
}
/* Responsive */
@media (max-width: 768px) {
.auth-pane {
padding: 22px;
@ -545,7 +538,6 @@ checkExistingAuth();
}
}
/* Fallback si no hay color-mix */
@supports not (color: color-mix(in srgb, white 50%, black)) {
.info-tag {
background: rgba(22, 119, 255, 0.12);

@ -23,7 +23,6 @@
<a-spin :spinning="loading">
<!-- Tools -->
<div class="tools">
<div class="counter">
<span class="counterLabel">Total</span>
@ -38,7 +37,6 @@
/>
</div>
<!-- ================= DESKTOP / TABLE ================= -->
<div class="tableWrap desktopOnly">
<a-table
:dataSource="procesosFiltrados"
@ -79,7 +77,7 @@
</a-table>
</div>
<!-- ================= MOBILE / CARDS ================= -->
<div class="cards mobileOnly">
<template v-if="procesosFiltrados.length">
<div
@ -189,7 +187,7 @@ onMounted(() => {
<style scoped>
/* Card */
.card {
width: 100%;
max-width: 1100px;
@ -197,7 +195,7 @@ onMounted(() => {
border-radius: 14px;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
@ -219,7 +217,6 @@ onMounted(() => {
margin-top: 4px;
}
/* Tools */
.tools {
display: grid;
grid-template-columns: 220px 1fr;
@ -244,7 +241,7 @@ onMounted(() => {
border-radius: 12px;
}
/* Table */
.tableWrap {
width: 100%;
overflow-x: auto;
@ -264,7 +261,7 @@ onMounted(() => {
color: #6b7280;
}
/* Status */
.statusPill {
padding: 4px 10px;
border-radius: 999px;
@ -336,7 +333,7 @@ onMounted(() => {
margin-top: 12px;
}
/* Responsive */
.desktopOnly { display: block; }
.mobileOnly { display: none; }

@ -17,7 +17,6 @@
</template>
<a-spin :spinning="loading">
<!-- Tools -->
<div class="tools">
<div class="counter">
<span class="counterLabel">Total</span>
@ -32,7 +31,6 @@
/>
</div>
<!-- Desktop/tablet: tabla -->
<div v-if="!isMobile" class="tableWrap">
<a-table
class="table"
@ -78,7 +76,7 @@
</a-table>
</div>
<!-- Mobile: cards -->
<div v-else class="cards">
<template v-if="pagosFiltrados.length">
<div v-for="p in pagosFiltrados" :key="p.key" class="itemCard">
@ -122,7 +120,6 @@
<a-empty v-else description="No se encontraron pagos" />
</div>
<!-- Nota (sin colores fuertes) -->
<div class="note">
<div class="noteTitle">Nota</div>
<div class="noteText">
@ -211,7 +208,6 @@ const pagosFiltrados = computed(() => {
});
});
/* ✅ Responsive real: móvil = cards */
const isMobile = ref(false);
let mq = null;
@ -235,14 +231,13 @@ onBeforeUnmount(() => {
</script>
<style scoped>
/* Formal 17+ | sin degradados | un solo acento: Primary */
.card {
max-width: 1100px;
margin: 16px auto;
border-radius: 14px;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
@ -274,7 +269,6 @@ onBeforeUnmount(() => {
border-radius: 10px;
}
/* Tools */
.tools {
display: grid;
grid-template-columns: 220px 1fr;
@ -305,7 +299,6 @@ onBeforeUnmount(() => {
border-radius: 12px;
}
/* Table */
.tableWrap {
border-radius: 12px;
overflow: hidden;
@ -315,7 +308,6 @@ onBeforeUnmount(() => {
overflow: hidden;
}
/* Pills (neutras, sin verde/azul/naranja por tipo) */
.pill {
display: inline-flex;
align-items: center;
@ -334,11 +326,10 @@ onBeforeUnmount(() => {
}
.pill--soft {
border-color: rgba(22,119,255,.25);
background: rgba(22,119,255,.08); /* ✅ único acento */
background: rgba(22,119,255,.08);
color: var(--ant-colorTextHeading, #111827);
}
/* Cells */
.codigoCell {
display: flex;
gap: 8px;
@ -368,14 +359,13 @@ onBeforeUnmount(() => {
.monto {
font-weight: 900;
color: var(--ant-colorPrimary, #1677ff); /* ✅ único acento */
color: var(--ant-colorPrimary, #1677ff);
}
.fecha {
font-weight: 800;
color: var(--ant-colorText, #374151);
}
/* Mobile cards */
.cards {
display: grid;
gap: 12px;
@ -438,7 +428,6 @@ onBeforeUnmount(() => {
color: var(--ant-colorPrimary, #1677ff);
}
/* Nota */
.note {
margin-top: 14px;
border-radius: 14px;
@ -457,7 +446,6 @@ onBeforeUnmount(() => {
line-height: 1.5;
}
/* Responsive */
@media (max-width: 640px) {
.card {
margin: 0;

@ -1,4 +1,3 @@
<!-- views/PortalView.vue -->
<template>
<a-layout class="portal-layout">
<!-- Header -->
@ -19,7 +18,7 @@
</div>
<div class="header-right">
<!-- Perfil -->
<a-dropdown :trigger="['click']" placement="bottomRight">
<div class="profile-trigger">
<a-avatar
@ -72,19 +71,17 @@
</div>
</a-layout-header>
<!-- Main layout -->
<a-layout
class="main-layout"
:class="{ 'layout-collapsed': sidebarCollapsed && !isMobile }"
>
<!-- Backdrop móvil -->
<div
v-if="isMobile && !sidebarCollapsed"
class="sidebar-backdrop"
@click="sidebarCollapsed = true"
/>
<!-- Sidebar -->
<a-layout-sider
v-model:collapsed="sidebarCollapsed"
:width="sidebarWidth"
@ -179,7 +176,6 @@
</div>
</a-layout-sider>
<!-- Content -->
<a-layout-content class="content">
<div class="content-container">
<a-card class="content-card" :bordered="false">
@ -253,7 +249,6 @@ const getAvatarColor = (name) => {
const handleMenuSelect = ({ key }) => {
selectedKeys.value = [key]
// Ajusta aquí tus rutas reales
const routes = {
'dashboard-postulante': { name: 'DashboardPostulante' },
'test-postulante': { name: 'TestPostulante' },
@ -279,10 +274,6 @@ const handleLogout = async () => {
}
}
const goToProfile = () => {
message.info('Perfil del postulante')
// router.push('/portal/perfil')
}
const openHelp = () => {
message.info('Centro de ayuda disponible')
@ -302,19 +293,16 @@ onUnmounted(() => {
<style scoped>
/* Tipografía institucional */
.portal-layout,
.portal-layout * {
font-family: "Times New Roman", Times, serif;
}
/* Layout base */
.portal-layout {
min-height: 100vh;
background: var(--ant-colorBgLayout, #f5f5f5);
}
/* ===== Header ===== */
.header {
height: 64px;
padding: 0;
@ -331,8 +319,8 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px; /* más aire */
width: 100%; /* ocupa todo */
padding: 0 32px;
width: 100%;
}
.header-left {
@ -387,7 +375,6 @@ onUnmounted(() => {
margin-top: 2px;
}
/* Profile trigger */
.header-right {
display: flex;
align-items: center;
@ -416,8 +403,8 @@ onUnmounted(() => {
.profile-text {
display: flex;
flex-direction: column;
gap: 0; /* ✅ elimina separación */
line-height: 1.05; /* ✅ compacto */
gap: 0;
line-height: 1.05;
}
@ -425,7 +412,7 @@ onUnmounted(() => {
font-size: 13px;
font-weight: 800;
color: var(--ant-colorText, #374151);
margin: 0; /* ✅ sin margen */
margin: 0;
padding: 0;
}
@ -434,9 +421,9 @@ onUnmounted(() => {
font-size: 12px;
font-weight: 700;
color: var(--ant-colorTextSecondary, #6b7280);
margin: 0; /* ✅ sin margen */
margin: 0;
padding: 0;
transform: translateY(-1px); /* ✅ sube 1px (opcional) */
transform: translateY(-1px);
}
.dropdown-chevron {
font-size: 12px;
@ -518,7 +505,6 @@ onUnmounted(() => {
color: #ff4d4f;
}
/* ===== Sidebar ===== */
.sidebar {
background: var(--ant-colorBgContainer, #fff);
border-right: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
@ -560,7 +546,6 @@ onUnmounted(() => {
letter-spacing: .5px;
}
/* ✅ AntDV menu selected/hover correctos */
.sidebar-menu :deep(.ant-menu-item) {
border-radius: 12px;
margin: 4px 6px;
@ -588,7 +573,6 @@ onUnmounted(() => {
margin-left: auto;
}
/* Sidebar Footer */
.sidebar-footer {
position: absolute;
bottom: 0;
@ -634,7 +618,6 @@ onUnmounted(() => {
margin-top: 2px;
}
/* ===== Main + Content ===== */
.main-layout {
margin-left: 280px;
transition: margin-left .25s ease;
@ -691,7 +674,6 @@ onUnmounted(() => {
opacity: 0.55;
}
/* ===== Mobile ===== */
.sidebar-backdrop {
position: fixed;
inset: 0;
@ -699,18 +681,15 @@ onUnmounted(() => {
z-index: 998;
}
/* Sidebar en móvil */
.sidebar.sidebar-mobile {
top: 64px;
height: calc(100vh - 64px);
}
/* AntDV collapsed mobile (width 0) ya lo oculta, pero reforzamos por UX */
.sidebar.sidebar-mobile :deep(.ant-layout-sider-children) {
height: 100%;
}
/* Responsive */
@media (max-width: 992px) {
.main-layout,
.main-layout.layout-collapsed {

@ -644,7 +644,7 @@ const finalizarExamen = async () => {
})
setTimeout(() => modal.destroy(), 2000)
} finally {
// evita doble click mientras califica
setTimeout(() => {
finalizando.value = false
}, 5000)
@ -652,7 +652,7 @@ const finalizarExamen = async () => {
}
/* TIMER */
const finalizarExamenAutomaticamente = () => {
message.error("Tiempo agotado. El examen se finalizará.");
finalizarExamen();
@ -669,7 +669,7 @@ const calcularTiempoRestante = () => {
}
};
/* INIT */
const iniciarSesionExamen = async () => {
if (initOnce.value) return;
initOnce.value = true;
@ -716,16 +716,16 @@ onBeforeUnmount(() => {
</script>
<style scoped>
/* ============ LAYOUT SERIO ============ */
.exam-page {
max-width: 920px;
margin: 0 auto;
padding: 16px;
background: #f6f7f9; /* sobrio */
background: #f6f7f9;
min-height: 100vh;
}
/* ============ HEADER ============ */
.top-card {
border-radius: 14px;
background: #ffffff;
@ -789,7 +789,7 @@ onBeforeUnmount(() => {
color: #6b7280;
}
/* Timer sobrio */
.top-right {
min-width: 180px;
text-align: right;
@ -805,10 +805,10 @@ onBeforeUnmount(() => {
.timer :deep(.ant-statistic-content) {
font-size: 26px;
font-weight: 900;
color: #111827; /* serio, sin rojo chillón */
color: #111827;
}
/* ============ CARDS ============ */
.card {
margin-top: 12px;
border-radius: 16px;
@ -820,7 +820,6 @@ onBeforeUnmount(() => {
padding-bottom: 6px;
}
/* ============ PREGUNTA ============ */
.q-header {
display: flex;
justify-content: space-between;
@ -857,7 +856,6 @@ onBeforeUnmount(() => {
color: #374151;
}
/* Enunciado serio, legible */
.enunciado {
margin-top: 12px;
padding: 14px 14px;
@ -881,7 +879,6 @@ onBeforeUnmount(() => {
overflow-wrap: anywhere;
}
/* ============ RESPUESTAS ============ */
.answer {
margin-top: 14px;
}
@ -923,7 +920,6 @@ onBeforeUnmount(() => {
overflow-wrap: anywhere;
}
/* ============ NAV (SOLO 2 BOTONES) ============ */
.nav {
margin-top: 16px;
display: flex;
@ -942,7 +938,7 @@ onBeforeUnmount(() => {
min-width: 200px;
}
/* Nota discreta */
.note {
margin-top: 12px;
font-size: 12px;
@ -950,7 +946,7 @@ onBeforeUnmount(() => {
line-height: 1.5;
}
/* Responsive */
@media (max-width: 640px) {
.top-right {
text-align: left;

@ -1,14 +1,12 @@
<!-- PanelResultados.vue (DISEÑO ALTERNATIVO: "Scoreboard" moderno, full responsive) -->
<template>
<div class="page alt">
<!-- Header minimal -->
<div class="head">
<div>
<div class="hTitle">Resultados del examen</div>
<div class="hSub">Resumen claro + detalle por curso.</div>
</div>
<!-- (opcional) acciones -->
<div class="headActions">
<a-space class="headSpace">
<a-button class="btnSoft" @click="volver" :disabled="cargando">Volver</a-button>
@ -21,9 +19,7 @@
<a-spin :spinning="cargando" tip="Cargando resultados...">
<template v-if="resultado">
<!-- TOP: Scoreboard -->
<a-row :gutter="[16, 16]">
<!-- Puntaje principal -->
<a-col :xs="24" :lg="10">
<a-card class="card scoreCard" :bordered="false">
<div class="scoreTop">
@ -58,7 +54,6 @@
</a-card>
</a-col>
<!-- KPIs -->
<a-col :xs="24" :lg="14">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :md="8">
@ -115,7 +110,6 @@
</a-col>
</a-row>
<!-- DETALLE: tabla por curso -->
<a-row :gutter="[16, 16]" class="mt16">
<a-col :xs="24">
<a-card title="Desempeño por curso" class="card" :bordered="false">
@ -292,7 +286,6 @@ onMounted(async () => {
</script>
<style scoped>
/* Layout */
.page {
padding: 16px;
max-width: 1120px;
@ -300,12 +293,10 @@ onMounted(async () => {
}
.mt16 { margin-top: 16px; }
/* Diseño alternativo: limpio, tipo "scoreboard" */
.alt {
background: transparent;
}
/* Header */
.head {
display: flex;
justify-content: space-between;
@ -334,7 +325,6 @@ onMounted(async () => {
}
.btnSoft, .btnPrimary { border-radius: 12px; font-weight: 900; }
/* Cards */
.card {
border-radius: 18px;
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
@ -463,7 +453,6 @@ onMounted(async () => {
.courseName { font-weight: 900; color: #111827; line-height: 1.2; }
.courseMeta { margin-top: 2px; font-size: 12px; color: #6b7280; }
/* Pills numéricas (en vez de tags de color) */
.pill {
display: inline-flex;
align-items: center;
@ -482,10 +471,8 @@ onMounted(async () => {
.pill.bad { border-color: rgba(0,0,0,.10); background: rgba(0,0,0,.04); }
.pill.neutral { opacity: .92; }
/* Empty */
.empty { margin-top: 12px; }
/* ✅ Responsive completo */
@media (max-width: 768px) {
.headActions { width: 100%; min-width: 0; justify-content: flex-start; }
.headSpace {

@ -10,25 +10,16 @@ import voucherBn from "../../assets/images/boletabn.jpg";
const router = useRouter();
const examenStore = useExamenStore();
/** =========================
* UI State
* ========================= */
const showModal = ref(false);
const iniciandoExamen = ref(false);
const creandoExamen = ref(false);
const formRef = ref(null);
/**
* SEPARA LOADINGS:
* - loadingPage: solo para cargar data inicial de la página (no debe tumbar modales)
* - loadingAreas: solo para cargar áreas cuando cambias de proceso (se usa en el select)
*/
const loadingPage = ref(false);
const loadingAreas = ref(false);
/** =========================
* Form
* ========================= */
const formState = reactive({
proceso_id: undefined,
area_proceso_id: undefined,
@ -36,9 +27,7 @@ const formState = reactive({
codigo_pago: "",
});
/** =========================
* Modal Voucher
* ========================= */
const secuenciaModalOpen = ref(false);
const secuenciaTipo = ref("caja");
@ -74,9 +63,6 @@ const openSecuencia = (tipo) => {
const modalBodyStyle = computed(() => ({ maxHeight: "72vh", overflowY: "auto" }));
/** =========================
* Datos (computed)
* ========================= */
const hasExamen = computed(() => !!examenStore.examenActual);
const procesoNombre = computed(() => examenStore.examenActual?.proceso?.nombre || "No asignado");
@ -90,7 +76,7 @@ const estadoTexto = computed(() => {
return yaDioTest.value ? "Completado" : "Listo para iniciar";
});
/** Progreso */
const stepCurrent = computed(() => {
if (!hasExamen.value) return 0;
return yaDioTest.value ? 2 : 1;
@ -113,14 +99,12 @@ const estadoAlertDesc = computed(() => {
: "Son 10 preguntas. Al finalizar verás tu resultado al instante.";
});
/** ✅ Botones separados (visibilidad) */
const canIniciar = computed(() => hasExamen.value && !yaDioTest.value);
const canVerResultados = computed(() => hasExamen.value && yaDioTest.value);
const openSeleccionArea = () => {
showModal.value = true;
};
/** Options */
const procesoOptions = computed(() =>
(examenStore.procesos || []).map((p) => ({
value: p.id,
@ -142,13 +126,9 @@ const tipoPagoOptions = [
{ value: "caja", label: "Caja" },
];
/**
* IMPORTANTE:
* La condición de pago debe funcionar aunque venga 1/"1"/true/"true"
*/
const normalizeRequierePago = (v) => v === 1 || v === "1" || v === true || v === "true";
/** Si estás eligiendo proceso en el modal */
const procesoRequierePago = computed(() => {
const pid = formState.proceso_id;
if (pid === undefined || pid === null || pid === "") return false;
@ -161,7 +141,7 @@ const procesoRequierePago = computed(() => {
/** Validación (condicional) */
const rules = {
proceso_id: [{ required: true, message: "Selecciona un proceso", trigger: "change" }],
area_proceso_id: [{ required: true, message: "Selecciona un área", trigger: "change" }],
@ -187,9 +167,7 @@ const rules = {
],
};
/** =========================
* Actions
* ========================= */
const refrescar = async () => {
try {
await examenStore.fetchExamenActual();
@ -199,19 +177,14 @@ const refrescar = async () => {
}
};
/**
* SOLO una forma de cargar áreas: por @change
* (NO uses watch(proceso_id) + @change a la vez)
*/
const handleProcesoChange = async (procesoId) => {
// reset dependientes
formState.area_proceso_id = undefined;
// reset pago cuando cambia proceso
formState.tipo_pago = undefined;
formState.codigo_pago = "";
// limpiar validaciones relacionadas
formRef.value?.clearValidate?.(["area_proceso_id", "tipo_pago", "codigo_pago"]);
if (!procesoId) {
@ -236,10 +209,8 @@ const crearExamen = async () => {
try {
creandoExamen.value = true;
// valida con reglas condicionales
if (formRef.value) await formRef.value.validate();
// si requiere pago, se manda en plano al store (payload final lo arma el store)
const pagoData = procesoRequierePago.value
? { tipo_pago: formState.tipo_pago, codigo_pago: formState.codigo_pago }
: null;
@ -316,7 +287,6 @@ watch(showModal, (open) => {
if (!open) resetModal();
});
/** Lifecycle */
onMounted(async () => {
loadingPage.value = true;
try {
@ -329,7 +299,6 @@ onMounted(async () => {
</script>
<template>
<!-- WRAPPER estilo Convocatorias -->
<div class="heroKicker">Test diagnóstico</div>
<div class="heroTitle">Practica y conoce tu nivel</div>
@ -368,7 +337,6 @@ onMounted(async () => {
</div>
</div>
<!-- BOTONES DE BANCO -->
<div class="bankActions">
<div class="bankHead">
<div class="bankTitle">Requisito de acceso</div>
@ -447,7 +415,6 @@ No se realiza ningún pago adicional.
<a-divider />
<!-- Progreso -->
<section class="section">
<div class="sectionTitle">Tu camino</div>
<div class="sectionSub">Simple, rápido y sin estrés.</div>
@ -467,7 +434,6 @@ No se realiza ningún pago adicional.
<a-divider />
<!-- ================== MODAL SECUENCIA ================== -->
<a-modal
v-model:open="secuenciaModalOpen"
:title="secuenciaTitle"
@ -515,7 +481,6 @@ No se realiza ningún pago adicional.
</a-row>
</a-modal>
<!-- ================== MODAL SELECCIÓN ÁREA ================== -->
<a-modal
v-model:open="showModal"
title="Seleccionar área"
@ -585,9 +550,7 @@ No se realiza ningún pago adicional.
</template>
<style scoped>
/* =========================
BASE (estilo Convocatorias)
========================= */
.test-modern {
position: relative;
padding: 40px 0;
@ -630,15 +593,13 @@ No se realiza ningún pago adicional.
padding: 0 24px;
}
/* Helpers */
.mt12 { margin-top: 12px; }
.mt16 { margin-top: 16px; }
.mb12 { margin-bottom: 12px; }
.muted { color: #666; line-height: 1.6; }
/* =========================
HERO (tarjeta principal tipo convocatorias)
========================= */
.hero {
position: relative;
border: none;
@ -653,7 +614,6 @@ No se realiza ningún pago adicional.
overflow: hidden;
}
/* Badge "Principal" estilo convocatorias */
.hero::after {
position: absolute;
top: -12px;
@ -688,7 +648,6 @@ No se realiza ningún pago adicional.
color: #666;
}
/* Facts */
.heroFacts {
margin-top: 16px;
display: grid;
@ -719,7 +678,7 @@ No se realiza ningún pago adicional.
white-space: nowrap;
}
/* Banco */
.bankActions {
margin-top: 18px;
padding: 16px;
@ -751,7 +710,6 @@ No se realiza ningún pago adicional.
font-weight: 700;
}
/* CTA */
.ctaCard {
border: none;
border-radius: 16px;
@ -784,7 +742,6 @@ No se realiza ningún pago adicional.
line-height: 1.45;
}
/* Status pill */
.statusTag {
font-weight: 700;
padding: 4px 12px;
@ -813,7 +770,6 @@ No se realiza ningún pago adicional.
color: #777;
}
/* Botones */
.ctaBtn {
height: 52px;
border-radius: 12px;
@ -832,7 +788,6 @@ No se realiza ningún pago adicional.
.ctaDivider { margin: 14px 0 0; }
/* Secciones inferiores */
.sectionTitle {
font-size: 1.35rem;
font-weight: 700;
@ -852,7 +807,6 @@ No se realiza ningún pago adicional.
color: #666;
}
/* Modales */
:deep(.voucher-modal .ant-modal-content),
:deep(.select-modal .ant-modal-content) {
border-radius: 16px;
@ -863,7 +817,6 @@ No se realiza ningún pago adicional.
max-width: 92vw;
}
/* Pago */
.pay-block {
margin-top: 10px;
padding: 12px;
@ -872,7 +825,6 @@ No se realiza ningún pago adicional.
background: rgba(13, 27, 82, 0.03);
}
/* Voucher */
.voucher-caption {
margin-bottom: 10px;
color: #666;
@ -893,52 +845,42 @@ No se realiza ningún pago adicional.
gap: 10px;
}
/* Opcional: cards ant-design más “suaves” */
:deep(.ant-card) { border-radius: 12px; }
:deep(.ant-card-bordered) { border: 1px solid rgba(13, 27, 82, 0.10); }
/* =========================
FULL WIDTH + TOP 0 EN MÓVIL
========================= */
@media (max-width: 768px) {
.hide-mobile{
display: none !important;
}
/* ✅ Quita el padding superior del wrapper (era 40px) */
.test-modern{
padding-top: 0 !important;
padding-bottom: 24px; /* opcional */
}
/* ✅ Quita padding lateral del container para que sea edge-to-edge */
.section-container{
max-width: none;
padding: 0 !important;
margin: 0 !important;
}
/* ✅ Card principal a todo el ancho y pegado arriba */
.hero{
grid-template-columns: 1fr;
width: 100%;
margin: 0 !important;
border-radius: 0 !important;
/* importante: sin “espacio arriba” */
padding: 14px 16px 18px !important;
}
/* ✅ Asegura que el primer texto no empuje hacia abajo */
.heroKicker{ margin-top: 0 !important; }
.heroTitle{ margin-top: 6px !important; }
.heroText{ margin-top: 8px !important; }
/* Para que la sección “Tu camino” no se pegue a los bordes */
.section{
padding: 0 16px;
}
/* Facts */
.heroFacts{
grid-template-columns: repeat(2, minmax(0, 1fr));
}

@ -1,4 +1,3 @@
<!-- views/Dashboard.vue -->
<template>
<div style="padding: 24px;">
<a-result

Loading…
Cancel
Save