20256
parent
489cfd8f6b
commit
9cbd6d2a88
@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Administracion;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
use App\Models\Noticia;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class NoticiaController extends Controller
|
||||||
|
{
|
||||||
|
// GET /api/noticias
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$perPage = (int) $request->get('per_page', 9);
|
||||||
|
|
||||||
|
$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'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('q')) {
|
||||||
|
$q = trim((string) $request->get('q'));
|
||||||
|
$query->where(function ($sub) use ($q) {
|
||||||
|
$sub->where('titulo', 'like', "%{$q}%")
|
||||||
|
->orWhere('descripcion_corta', 'like', "%{$q}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $query
|
||||||
|
->orderByDesc('destacado')
|
||||||
|
->orderByDesc('fecha_publicacion')
|
||||||
|
->orderByDesc('orden')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->paginate($perPage);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $data->items(),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $data->currentPage(),
|
||||||
|
'last_page' => $data->lastPage(),
|
||||||
|
'per_page' => $data->perPage(),
|
||||||
|
'total' => $data->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/noticias/{noticia}
|
||||||
|
public function show(Noticia $noticia)
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $noticia,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// GET /api/noticias/{noticia}
|
||||||
|
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)
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'titulo' => ['required', 'string', 'max:220'],
|
||||||
|
'slug' => ['nullable', 'string', 'max:260', 'unique:noticias,slug'],
|
||||||
|
'descripcion_corta' => ['nullable', 'string', 'max:500'],
|
||||||
|
'contenido' => ['nullable', 'string'],
|
||||||
|
'categoria' => ['nullable', 'string', 'max:80'],
|
||||||
|
'tag_color' => ['nullable', 'string', 'max:30'],
|
||||||
|
'imagen' => ['nullable', 'file', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
|
||||||
|
'imagen_path' => ['nullable', 'string', 'max:255'],
|
||||||
|
'link_url' => ['nullable', 'string', 'max:600'],
|
||||||
|
'link_texto' => ['nullable', 'string', 'max:120'],
|
||||||
|
'fecha_publicacion' => ['nullable', 'date'],
|
||||||
|
'publicado' => ['nullable', 'boolean'],
|
||||||
|
'destacado' => ['nullable', 'boolean'],
|
||||||
|
'orden' => ['nullable', 'integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// slug por defecto
|
||||||
|
if (empty($data['slug'])) {
|
||||||
|
$data['slug'] = Str::slug($data['titulo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// subir imagen si viene
|
||||||
|
if ($request->hasFile('imagen')) {
|
||||||
|
$path = $request->file('imagen')->store('noticias', 'public');
|
||||||
|
$data['imagen_path'] = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// si publican sin fecha, poner ahora
|
||||||
|
if (!empty($data['publicado']) && empty($data['fecha_publicacion'])) {
|
||||||
|
$data['fecha_publicacion'] = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
$noticia = Noticia::create($data);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $noticia,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/PATCH /api/noticias/{noticia}
|
||||||
|
public function update(Request $request, Noticia $noticia)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'titulo' => ['sometimes', 'required', 'string', 'max:220'],
|
||||||
|
'slug' => ['sometimes', 'nullable', 'string', 'max:260', 'unique:noticias,slug,' . $noticia->id],
|
||||||
|
'descripcion_corta' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||||
|
'contenido' => ['sometimes', 'nullable', 'string'],
|
||||||
|
'categoria' => ['sometimes', 'nullable', 'string', 'max:80'],
|
||||||
|
'tag_color' => ['sometimes', 'nullable', 'string', 'max:30'],
|
||||||
|
'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'],
|
||||||
|
'link_texto' => ['sometimes', 'nullable', 'string', 'max:120'],
|
||||||
|
'fecha_publicacion' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'publicado' => ['sometimes', 'boolean'],
|
||||||
|
'destacado' => ['sometimes', 'boolean'],
|
||||||
|
'orden' => ['sometimes', 'integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// si llega imagen, reemplazar
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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'])) {
|
||||||
|
$data['fecha_publicacion'] = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// si cambian titulo y slug no vino, regenerar slug (opcional)
|
||||||
|
if (array_key_exists('titulo', $data) && !array_key_exists('slug', $data)) {
|
||||||
|
$data['slug'] = Str::slug($data['titulo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$noticia->update($data);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $noticia->fresh(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$noticia->delete();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Noticia eliminada correctamente',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
use App\Models\ProcesoAdmision;
|
||||||
|
use App\Models\ProcesoAdmisionDetalle;
|
||||||
|
|
||||||
|
class WebController extends Controller
|
||||||
|
{
|
||||||
|
|
||||||
|
public function GetProcesoAdmision()
|
||||||
|
{
|
||||||
|
$procesos = ProcesoAdmision::select(
|
||||||
|
'id',
|
||||||
|
'titulo',
|
||||||
|
'subtitulo',
|
||||||
|
'descripcion',
|
||||||
|
'slug',
|
||||||
|
'tipo_proceso',
|
||||||
|
'modalidad',
|
||||||
|
'publicado',
|
||||||
|
'fecha_publicacion',
|
||||||
|
'fecha_inicio_preinscripcion',
|
||||||
|
'fecha_fin_preinscripcion',
|
||||||
|
'fecha_inicio_inscripcion',
|
||||||
|
'fecha_fin_inscripcion',
|
||||||
|
'fecha_examen1',
|
||||||
|
'fecha_examen2',
|
||||||
|
'fecha_resultados',
|
||||||
|
'fecha_inicio_biometrico',
|
||||||
|
'fecha_fin_biometrico',
|
||||||
|
'imagen_path',
|
||||||
|
'banner_path',
|
||||||
|
'brochure_path',
|
||||||
|
'link_preinscripcion',
|
||||||
|
'link_inscripcion',
|
||||||
|
'link_resultados',
|
||||||
|
'link_reglamento',
|
||||||
|
'estado',
|
||||||
|
'created_at',
|
||||||
|
'updated_at'
|
||||||
|
)
|
||||||
|
->with([
|
||||||
|
'detalles' => function ($query) {
|
||||||
|
$query->select(
|
||||||
|
'id',
|
||||||
|
'proceso_admision_id',
|
||||||
|
'tipo',
|
||||||
|
'titulo_detalle',
|
||||||
|
'descripcion',
|
||||||
|
'listas',
|
||||||
|
'meta',
|
||||||
|
'url',
|
||||||
|
'imagen_path',
|
||||||
|
'imagen_path_2',
|
||||||
|
'created_at',
|
||||||
|
'updated_at'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
])
|
||||||
|
->latest() // 🔥 Esto ordena por created_at DESC
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $procesos
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Noticia extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $table = 'noticias';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'titulo',
|
||||||
|
'slug',
|
||||||
|
'descripcion_corta',
|
||||||
|
'contenido',
|
||||||
|
'categoria',
|
||||||
|
'tag_color',
|
||||||
|
'imagen_path',
|
||||||
|
'link_url',
|
||||||
|
'link_texto',
|
||||||
|
'fecha_publicacion',
|
||||||
|
'publicado',
|
||||||
|
'destacado',
|
||||||
|
'orden',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'fecha_publicacion' => 'datetime',
|
||||||
|
'publicado' => 'boolean',
|
||||||
|
'destacado' => 'boolean',
|
||||||
|
'orden' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $appends = ['imagen_url'];
|
||||||
|
|
||||||
|
public function getImagenUrlAttribute(): ?string
|
||||||
|
{
|
||||||
|
if (!$this->imagen_path) return null;
|
||||||
|
return asset('storage/' . ltrim($this->imagen_path, '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-generar slug si no viene
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (Noticia $noticia) {
|
||||||
|
if (!$noticia->slug) {
|
||||||
|
$noticia->slug = Str::slug($noticia->titulo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<NavbarModerno />
|
||||||
|
<section class="convocatorias-modern">
|
||||||
|
<div class="section-container">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="header-with-badge">
|
||||||
|
<h2 class="section-title">CEPREUNA</h2>
|
||||||
|
<a-badge count="Modalidad" class="new-badge" />
|
||||||
|
</div>
|
||||||
|
<p class="section-subtitle">
|
||||||
|
Examen de admisión del Centro Preuniversitario (Capítulo VI — Sub-capítulo I)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-card class="main-convocatoria-card">
|
||||||
|
<div class="card-badge">CEPREUNA</div>
|
||||||
|
|
||||||
|
<div class="convocatoria-header">
|
||||||
|
<div>
|
||||||
|
<h3>Examen de Admisión del Centro Preuniversitario</h3>
|
||||||
|
<p class="convocatoria-date">Convocatoria: una vez por semestre</p>
|
||||||
|
</div>
|
||||||
|
<a-tag class="status-tag" color="success">CEPREUNA</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-alert
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
class="soft-alert"
|
||||||
|
message="Dirigido a estudiantes que concluyeron el quinto año de secundaria y cursaron el ciclo preparatorio del Centro Preuniversitario de la UNA-Puno."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-divider class="custom-divider" />
|
||||||
|
|
||||||
|
<a-card class="soft-card" size="small">
|
||||||
|
<h4 class="subheading">Evaluación</h4>
|
||||||
|
<p class="text">
|
||||||
|
El postulante rinde un examen de conocimientos con contenidos alineados al perfil del ingresante
|
||||||
|
establecido por la UNA-Puno, en la fecha indicada en el cronograma de admisión.
|
||||||
|
</p>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card class="soft-card" size="small">
|
||||||
|
<h4 class="subheading">Asignación de vacantes</h4>
|
||||||
|
<p class="text">
|
||||||
|
Las vacantes se cubren de acuerdo con el puntaje alcanzado, hasta completar el número ofertado
|
||||||
|
por los programas de estudio, según lo dispuesto por el reglamento.
|
||||||
|
</p>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card class="soft-card" size="small">
|
||||||
|
<h4 class="subheading">Requisitos y documentos</h4>
|
||||||
|
<a-list size="small" :split="false" class="info-list">
|
||||||
|
<a-list-item>
|
||||||
|
Presentar la constancia de no adeudar al CEPREUNA con la debida anticipación.
|
||||||
|
</a-list-item>
|
||||||
|
<a-list-item>
|
||||||
|
Presentar los documentos exigidos por el reglamento para esta modalidad (según requisitos generales).
|
||||||
|
</a-list-item>
|
||||||
|
</a-list>
|
||||||
|
</a-card>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<FooterModerno />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import NavbarModerno from '../../nabvar.vue'
|
||||||
|
import FooterModerno from '../../Footer.vue'
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Diseño tipo Convocatorias (sin mayúsculas globales) */
|
||||||
|
.convocatorias-modern {
|
||||||
|
position: relative;
|
||||||
|
padding: 40px 0;
|
||||||
|
font-family: "Times New Roman", Times, serif;
|
||||||
|
background: #fbfcff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.convocatorias-modern::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(to right, rgba(13, 27, 82, 0.06) 0, rgba(13, 27, 82, 0.06) 1px, transparent 1px, transparent 24px),
|
||||||
|
repeating-linear-gradient(to bottom, rgba(13, 27, 82, 0.06) 0, rgba(13, 27, 82, 0.06) 1px, transparent 1px, transparent 24px);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.section-container { position: relative; z-index: 1; max-width: 1200px; margin: 0 auto; padding: 0 24px; }
|
||||||
|
.section-header { text-align: center; margin-bottom: 40px; }
|
||||||
|
.header-with-badge { display: inline-flex; align-items: center; justify-content: center; gap: 14px; }
|
||||||
|
.section-title { font-size: 2.4rem; font-weight: 700; color: #0d1b52; margin: 0; }
|
||||||
|
.section-subtitle { font-size: 1.125rem; color: #666; max-width: 760px; margin: 14px auto 0; }
|
||||||
|
|
||||||
|
.new-badge :deep(.ant-badge-count) {
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #ffd700);
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-convocatoria-card { position: relative; border: none; box-shadow: 0 10px 34px rgba(0,0,0,0.08); border-radius: 16px; }
|
||||||
|
.main-convocatoria-card :deep(.ant-card-body) { padding: 28px; }
|
||||||
|
|
||||||
|
.card-badge {
|
||||||
|
position: absolute; top: -12px; left: 24px;
|
||||||
|
background: linear-gradient(45deg, #1890ff, #52c41a);
|
||||||
|
color: white; padding: 6px 16px; border-radius: 999px;
|
||||||
|
font-size: 0.75rem; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.convocatoria-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; margin-bottom: 14px; }
|
||||||
|
.convocatoria-header h3 { margin: 0; font-size: 1.55rem; color: #1a237e; }
|
||||||
|
.convocatoria-date { color: #666; margin: 6px 0 0; font-size: 0.95rem; }
|
||||||
|
|
||||||
|
.status-tag { font-weight: 700; padding: 4px 12px; border-radius: 999px; white-space: nowrap; }
|
||||||
|
|
||||||
|
.custom-divider { margin: 18px 0; }
|
||||||
|
.soft-alert { border-radius: 12px; }
|
||||||
|
|
||||||
|
.soft-card { border-radius: 12px; border: 1px solid rgba(0,0,0,0.06); margin-top: 12px; }
|
||||||
|
.subheading { margin: 0 0 6px; color: #1a237e; font-weight: 700; }
|
||||||
|
.text { margin: 0; color: #666; line-height: 1.7; }
|
||||||
|
.info-list :deep(.ant-list-item) { padding: 8px 0; border: none; color: #666; }
|
||||||
|
</style>
|
||||||
@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<NavbarModerno/>
|
||||||
|
<section class="convocatorias-modern">
|
||||||
|
<div class="section-container">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="header-with-badge">
|
||||||
|
<h2 class="section-title">Extraordinario</h2>
|
||||||
|
<a-badge count="Modalidades" class="new-badge" />
|
||||||
|
</div>
|
||||||
|
<p class="section-subtitle">
|
||||||
|
Examen convocado una vez al año con varias formas de postulación (Capítulo VI — Sub-capítulo II)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-card class="main-convocatoria-card">
|
||||||
|
<div class="card-badge">Extraordinario</div>
|
||||||
|
|
||||||
|
<div class="convocatoria-header">
|
||||||
|
<div>
|
||||||
|
<h3>Examen de Admisión Extraordinario</h3>
|
||||||
|
<p class="convocatoria-date">Convocatoria: una vez al año</p>
|
||||||
|
</div>
|
||||||
|
<a-tag class="status-tag" color="orange">Extraordinario</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-card class="soft-card" size="small">
|
||||||
|
<h4 class="subheading">Evaluación</h4>
|
||||||
|
<p class="text">
|
||||||
|
El postulante rinde un examen de conocimientos con temas y contenidos alineados al perfil del ingresante,
|
||||||
|
en la fecha prevista en el cronograma de admisión.
|
||||||
|
</p>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card class="soft-card" size="small">
|
||||||
|
<h4 class="subheading">Asignación de vacantes</h4>
|
||||||
|
<p class="text">
|
||||||
|
Las vacantes se cubren de acuerdo con el puntaje alcanzado hasta completar el número ofertado por los
|
||||||
|
programas de estudio, conforme a lo establecido en el reglamento.
|
||||||
|
</p>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-divider class="custom-divider" />
|
||||||
|
|
||||||
|
<h4 class="subheading" style="margin: 0 0 10px;">Formas de postulación</h4>
|
||||||
|
|
||||||
|
<a-collapse expand-icon-position="end" class="collapse-clean">
|
||||||
|
<a-collapse-panel key="a" header="Primeros puestos / COAR">
|
||||||
|
<p class="text">
|
||||||
|
Dirigido a estudiantes que obtuvieron los primeros lugares del orden de mérito en secundaria, con vigencia
|
||||||
|
definida por el reglamento; incluye egresados del COAR según corresponda.
|
||||||
|
</p>
|
||||||
|
<a-divider class="custom-divider" />
|
||||||
|
<a-list size="small" :split="false" class="info-list">
|
||||||
|
<a-list-item>Comprobantes de pago por admisión y carpeta de postulante.</a-list-item>
|
||||||
|
<a-list-item>Documento de identidad (original y copia).</a-list-item>
|
||||||
|
<a-list-item>Examen médico y aptitud vocacional solo para programas que lo exigen.</a-list-item>
|
||||||
|
<a-list-item>Solicitud generada tras la preinscripción virtual.</a-list-item>
|
||||||
|
<a-list-item>Certificados que acrediten estudios y condición de mérito según corresponda.</a-list-item>
|
||||||
|
</a-list>
|
||||||
|
</a-collapse-panel>
|
||||||
|
|
||||||
|
<a-collapse-panel key="b" header="Graduados o titulados">
|
||||||
|
<a-list size="small" :split="false" class="info-list">
|
||||||
|
<a-list-item>Comprobantes de pago por admisión y carpeta.</a-list-item>
|
||||||
|
<a-list-item>Documento de identidad (original y copia).</a-list-item>
|
||||||
|
<a-list-item>Solicitud generada tras la preinscripción virtual.</a-list-item>
|
||||||
|
<a-list-item>Grado o título certificado por la institución de origen.</a-list-item>
|
||||||
|
<a-list-item>Para extranjeros: legalizaciones requeridas según normativa.</a-list-item>
|
||||||
|
<a-list-item>Constancias de institución de origen o verificación según corresponda.</a-list-item>
|
||||||
|
</a-list>
|
||||||
|
</a-collapse-panel>
|
||||||
|
|
||||||
|
<a-collapse-panel key="c" header="Traslado interno">
|
||||||
|
<a-list size="small" :split="false" class="info-list">
|
||||||
|
<a-list-item>Comprobantes de pago por admisión y carpeta.</a-list-item>
|
||||||
|
<a-list-item>Documento de identidad (original y copia).</a-list-item>
|
||||||
|
<a-list-item>Solicitud indicando programa de origen y programa al que postula, según afinidad.</a-list-item>
|
||||||
|
<a-list-item>Historial académico y constancias de matrícula según requisitos establecidos.</a-list-item>
|
||||||
|
</a-list>
|
||||||
|
</a-collapse-panel>
|
||||||
|
|
||||||
|
<a-collapse-panel key="d" header="Traslado externo (nacional o internacional)">
|
||||||
|
<a-list size="small" :split="false" class="info-list">
|
||||||
|
<a-list-item>Comprobantes de pago por admisión y carpeta.</a-list-item>
|
||||||
|
<a-list-item>Documento de identidad (DNI / carné de extranjería / pasaporte) según corresponda.</a-list-item>
|
||||||
|
<a-list-item>Solicitud de postulación al mismo programa de estudio.</a-list-item>
|
||||||
|
<a-list-item>Certificados de estudios visados; en internacional, requisitos legales adicionales.</a-list-item>
|
||||||
|
<a-list-item>Constancia de matrícula vigente del semestre anterior o similar.</a-list-item>
|
||||||
|
</a-list>
|
||||||
|
</a-collapse-panel>
|
||||||
|
|
||||||
|
<a-collapse-panel key="e" header="Deportistas destacados">
|
||||||
|
<a-list size="small" :split="false" class="info-list">
|
||||||
|
<a-list-item>Comprobantes de pago por admisión y carpeta.</a-list-item>
|
||||||
|
<a-list-item>Documento de identidad (original y copia).</a-list-item>
|
||||||
|
<a-list-item>Resolución y récord deportivo documentado conforme a la normativa aplicable.</a-list-item>
|
||||||
|
<a-list-item>Certificado de estudios secundarios.</a-list-item>
|
||||||
|
</a-list>
|
||||||
|
</a-collapse-panel>
|
||||||
|
|
||||||
|
<a-collapse-panel key="f" header="Beneficiarios del Plan Integral de Reparaciones (PIR)">
|
||||||
|
<a-list size="small" :split="false" class="info-list">
|
||||||
|
<a-list-item>Solicitud generada tras la preinscripción virtual.</a-list-item>
|
||||||
|
<a-list-item>Documento de identidad (original y copia).</a-list-item>
|
||||||
|
<a-list-item>Constancias de registro correspondientes (RUV/REBRED u otras).</a-list-item>
|
||||||
|
<a-list-item>Examen médico y aptitud vocacional solo para programas que lo exigen.</a-list-item>
|
||||||
|
<a-list-item>Certificado de estudios secundarios.</a-list-item>
|
||||||
|
</a-list>
|
||||||
|
</a-collapse-panel>
|
||||||
|
</a-collapse>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<FooterModerno/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import NavbarModerno from '../../nabvar.vue'
|
||||||
|
import FooterModerno from '../../Footer.vue'
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* mismo estilo base del componente CEPREUNA */
|
||||||
|
.convocatorias-modern{position:relative;padding:40px 0;font-family:"Times New Roman",Times,serif;background:#fbfcff;overflow:hidden;}
|
||||||
|
.convocatorias-modern::before{content:"";position:absolute;inset:0;pointer-events:none;z-index:0;background-image:repeating-linear-gradient(to right,rgba(13,27,82,.06) 0,rgba(13,27,82,.06) 1px,transparent 1px,transparent 24px),repeating-linear-gradient(to bottom,rgba(13,27,82,.06) 0,rgba(13,27,82,.06) 1px,transparent 1px,transparent 24px);opacity:.55;}
|
||||||
|
.section-container{position:relative;z-index:1;max-width:1200px;margin:0 auto;padding:0 24px;}
|
||||||
|
.section-header{text-align:center;margin-bottom:40px;}
|
||||||
|
.header-with-badge{display:inline-flex;align-items:center;justify-content:center;gap:14px;}
|
||||||
|
.section-title{font-size:2.4rem;font-weight:700;color:#0d1b52;margin:0;}
|
||||||
|
.section-subtitle{font-size:1.125rem;color:#666;max-width:860px;margin:14px auto 0;}
|
||||||
|
.new-badge :deep(.ant-badge-count){background:linear-gradient(45deg,#ff6b6b,#ffd700);box-shadow:0 2px 8px rgba(255,107,107,.3);border-radius:999px;}
|
||||||
|
.main-convocatoria-card{position:relative;border:none;box-shadow:0 10px 34px rgba(0,0,0,.08);border-radius:16px;}
|
||||||
|
.main-convocatoria-card :deep(.ant-card-body){padding:28px;}
|
||||||
|
.card-badge{position:absolute;top:-12px;left:24px;background:linear-gradient(45deg,#1890ff,#52c41a);color:#fff;padding:6px 16px;border-radius:999px;font-size:.75rem;font-weight:700;}
|
||||||
|
.convocatoria-header{display:flex;justify-content:space-between;align-items:flex-start;gap:14px;margin-bottom:14px;}
|
||||||
|
.convocatoria-header h3{margin:0;font-size:1.55rem;color:#1a237e;}
|
||||||
|
.convocatoria-date{color:#666;margin:6px 0 0;font-size:.95rem;}
|
||||||
|
.status-tag{font-weight:700;padding:4px 12px;border-radius:999px;white-space:nowrap;}
|
||||||
|
.custom-divider{margin:18px 0;}
|
||||||
|
.soft-card{border-radius:12px;border:1px solid rgba(0,0,0,0.06);margin-top:12px;}
|
||||||
|
.subheading{margin:0 0 6px;color:#1a237e;font-weight:700;}
|
||||||
|
.text{margin:0;color:#666;line-height:1.7;}
|
||||||
|
.info-list :deep(.ant-list-item){padding:8px 0;border:none;color:#666;}
|
||||||
|
.collapse-clean :deep(.ant-collapse-item){border-radius:12px;overflow:hidden;margin-bottom:10px;border:1px solid rgba(0,0,0,0.06);}
|
||||||
|
.collapse-clean :deep(.ant-collapse-header){font-weight:700;color:#1a237e;}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<NavbarModerno />
|
||||||
|
<section class="convocatorias-modern">
|
||||||
|
<div class="section-container">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="header-with-badge">
|
||||||
|
<h2 class="section-title">Admisión General</h2>
|
||||||
|
<a-badge count="Semestral" class="new-badge" />
|
||||||
|
</div>
|
||||||
|
<p class="section-subtitle">
|
||||||
|
Modalidad dirigida a egresados de secundaria (incluye postulantes con discapacidad acreditada)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-card class="main-convocatoria-card">
|
||||||
|
<div class="card-badge">General</div>
|
||||||
|
|
||||||
|
<div class="convocatoria-header">
|
||||||
|
<div>
|
||||||
|
<h3>Examen de Admisión General</h3>
|
||||||
|
<p class="convocatoria-date">Convocatoria: una vez por semestre</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-tag class="status-tag" color="blue">General</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-alert
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
class="soft-alert"
|
||||||
|
message="Incluye postulantes con discapacidad debidamente acreditados mediante su certificado correspondiente."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-divider class="custom-divider" />
|
||||||
|
|
||||||
|
<a-card class="soft-card" size="small">
|
||||||
|
<h4 class="subheading">A quién está dirigido</h4>
|
||||||
|
<p class="text">
|
||||||
|
Dirigido a estudiantes egresados que hayan concluido educación secundaria en EBR, EBA y COAR.
|
||||||
|
</p>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card class="soft-card" size="small">
|
||||||
|
<h4 class="subheading">Evaluación</h4>
|
||||||
|
<p class="text">
|
||||||
|
Examen de conocimientos basado en contenidos alineados al perfil del ingresante, según el cronograma de admisión.
|
||||||
|
</p>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card class="soft-card" size="small">
|
||||||
|
<h4 class="subheading">Asignación de vacantes</h4>
|
||||||
|
<p class="text">
|
||||||
|
Se asignan por puntaje hasta completar el número ofertado por los programas de estudio, conforme al reglamento.
|
||||||
|
</p>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card class="soft-card" size="small">
|
||||||
|
<h4 class="subheading">Documentación</h4>
|
||||||
|
<p class="text">
|
||||||
|
El postulante debe presentar los documentos exigidos por el reglamento para esta modalidad.
|
||||||
|
</p>
|
||||||
|
</a-card>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<FooterModerno/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import NavbarModerno from '../../nabvar.vue'
|
||||||
|
import FooterModerno from '../../Footer.vue'
|
||||||
|
import Nabvar from '../../nabvar.vue';
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* mismo estilo base */
|
||||||
|
.convocatorias-modern{position:relative;padding:40px 0;font-family:"Times New Roman",Times,serif;background:#fbfcff;overflow:hidden;}
|
||||||
|
.convocatorias-modern::before{content:"";position:absolute;inset:0;pointer-events:none;z-index:0;background-image:repeating-linear-gradient(to right,rgba(13,27,82,.06) 0,rgba(13,27,82,.06) 1px,transparent 1px,transparent 24px),repeating-linear-gradient(to bottom,rgba(13,27,82,.06) 0,rgba(13,27,82,.06) 1px,transparent 1px,transparent 24px);opacity:.55;}
|
||||||
|
.section-container{position:relative;z-index:1;max-width:1200px;margin:0 auto;padding:0 24px;}
|
||||||
|
.section-header{text-align:center;margin-bottom:40px;}
|
||||||
|
.header-with-badge{display:inline-flex;align-items:center;justify-content:center;gap:14px;}
|
||||||
|
.section-title{font-size:2.4rem;font-weight:700;color:#0d1b52;margin:0;}
|
||||||
|
.section-subtitle{font-size:1.125rem;color:#666;max-width:860px;margin:14px auto 0;}
|
||||||
|
.new-badge :deep(.ant-badge-count){background:linear-gradient(45deg,#ff6b6b,#ffd700);box-shadow:0 2px 8px rgba(255,107,107,.3);border-radius:999px;}
|
||||||
|
.main-convocatoria-card{position:relative;border:none;box-shadow:0 10px 34px rgba(0,0,0,.08);border-radius:16px;}
|
||||||
|
.main-convocatoria-card :deep(.ant-card-body){padding:28px;}
|
||||||
|
.card-badge{position:absolute;top:-12px;left:24px;background:linear-gradient(45deg,#1890ff,#52c41a);color:#fff;padding:6px 16px;border-radius:999px;font-size:.75rem;font-weight:700;}
|
||||||
|
.convocatoria-header{display:flex;justify-content:space-between;align-items:flex-start;gap:14px;margin-bottom:14px;}
|
||||||
|
.convocatoria-header h3{margin:0;font-size:1.55rem;color:#1a237e;}
|
||||||
|
.convocatoria-date{color:#666;margin:6px 0 0;font-size:.95rem;}
|
||||||
|
.status-tag{font-weight:700;padding:4px 12px;border-radius:999px;white-space:nowrap;}
|
||||||
|
.custom-divider{margin:18px 0;}
|
||||||
|
.soft-alert{border-radius:12px;}
|
||||||
|
.soft-card{border-radius:12px;border:1px solid rgba(0,0,0,0.06);margin-top:12px;}
|
||||||
|
.subheading{margin:0 0 6px;color:#1a237e;font-weight:700;}
|
||||||
|
.text{margin:0;color:#666;line-height:1.7;}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,411 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from "vue"
|
||||||
|
import axios from "axios"
|
||||||
|
import NavbarModerno from '../../nabvar.vue'
|
||||||
|
import FooterModerno from '../../Footer.vue'
|
||||||
|
import {
|
||||||
|
CalendarOutlined,
|
||||||
|
FileSearchOutlined,
|
||||||
|
} from "@ant-design/icons-vue"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const procesos = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref("")
|
||||||
|
|
||||||
|
const yaPasoFechaExamen = (fec_2, fec_1) => {
|
||||||
|
if (!fec_2 && !fec_1) return true
|
||||||
|
|
||||||
|
const fechaReferencia = fec_2 || fec_1
|
||||||
|
const fecha = new Date(fechaReferencia)
|
||||||
|
|
||||||
|
if (Number.isNaN(fecha.getTime())) return true
|
||||||
|
|
||||||
|
fecha.setDate(fecha.getDate() + 1)
|
||||||
|
|
||||||
|
const hoy = new Date()
|
||||||
|
return hoy >= fecha
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFecha = (value) => {
|
||||||
|
if (!value) return ""
|
||||||
|
const d = new Date(value)
|
||||||
|
if (Number.isNaN(d.getTime())) return ""
|
||||||
|
return d.toLocaleDateString("es-PE", { year: "numeric", month: "short", day: "2-digit" })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ""
|
||||||
|
try {
|
||||||
|
const response = await axios.get("https://inscripciones.admision.unap.edu.pe/api/get-procesos")
|
||||||
|
if (response.data?.estado) {
|
||||||
|
procesos.value = Array.isArray(response.data?.res) ? response.data.res : []
|
||||||
|
} else {
|
||||||
|
procesos.value = []
|
||||||
|
errorMsg.value = "La API respondió en un formato inesperado."
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
procesos.value = []
|
||||||
|
errorMsg.value = "Error al cargar procesos."
|
||||||
|
console.error("Error al cargar procesos:", error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const procesosAgrupados = computed(() => {
|
||||||
|
const agrupado = {}
|
||||||
|
for (const proceso of procesos.value) {
|
||||||
|
const anio = proceso?.anio ?? "Sin año"
|
||||||
|
if (!agrupado[anio]) agrupado[anio] = []
|
||||||
|
agrupado[anio].push(proceso)
|
||||||
|
}
|
||||||
|
return agrupado
|
||||||
|
})
|
||||||
|
|
||||||
|
const aniosOrdenados = computed(() => {
|
||||||
|
return Object.keys(procesosAgrupados.value).sort((a, b) => Number(b) - Number(a))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Solo resultados (examen ya pasó) */
|
||||||
|
const resultadosPorAnio = computed(() => {
|
||||||
|
const out = {}
|
||||||
|
for (const anio of aniosOrdenados.value) {
|
||||||
|
out[anio] = (procesosAgrupados.value[anio] || []).filter((p) =>
|
||||||
|
yaPasoFechaExamen(p?.fec_2, p?.fec_1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const hayResultados = computed(() =>
|
||||||
|
aniosOrdenados.value.some((anio) => (resultadosPorAnio.value[anio] || []).length > 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const linkResultados = (p) => `https://inscripciones.admision.unap.edu.pe/${p?.slug}/resultados`
|
||||||
|
|
||||||
|
const estadoTag = (p) => {
|
||||||
|
return yaPasoFechaExamen(p?.fec_2, p?.fec_1)
|
||||||
|
? { text: "FINALIZADO", color: "default" }
|
||||||
|
: { text: "PRÓXIMAMENTE", color: "orange" }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NavbarModerno />
|
||||||
|
|
||||||
|
<section id="resultados" class="convocatorias-modern">
|
||||||
|
<div class="section-container">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="header-with-badge">
|
||||||
|
<h2 class="section-title">Resultados</h2>
|
||||||
|
</div>
|
||||||
|
<p class="section-subtitle">
|
||||||
|
Consulta los resultados por año y proceso. Solo se muestran cuando el examen ya pasó.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estados -->
|
||||||
|
<a-card class="main-convocatoria-card" :loading="loading">
|
||||||
|
<div class="card-badge">Resultados</div>
|
||||||
|
|
||||||
|
<template v-if="errorMsg">
|
||||||
|
<div style="padding: 6px 2px; color: #dc2626; font-weight: 700;">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="!hayResultados && !loading">
|
||||||
|
<div style="padding: 6px 2px; color: #666;">
|
||||||
|
Aún no hay resultados disponibles para mostrar.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div style="padding: 6px 2px; color:#666;">
|
||||||
|
Resultados disponibles organizados por año:
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- ✅ CADA AÑO ES SU PROPIA SECCIÓN / CARD -->
|
||||||
|
<div v-for="anio in aniosOrdenados" :key="anio">
|
||||||
|
<a-card
|
||||||
|
v-if="(resultadosPorAnio[anio] || []).length"
|
||||||
|
class="year-section-card"
|
||||||
|
>
|
||||||
|
<div class="year-header">
|
||||||
|
<div class="year-icon">
|
||||||
|
<CalendarOutlined />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="year-title">Año {{ anio }}</h3>
|
||||||
|
<p class="year-subtitle">Procesos con resultados disponibles del {{ anio }}.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-divider class="custom-divider" />
|
||||||
|
|
||||||
|
<!-- ✅ UNA SOLA COLUMNA -->
|
||||||
|
<div class="secondary-list one-col">
|
||||||
|
<a-card
|
||||||
|
v-for="proceso in resultadosPorAnio[anio]"
|
||||||
|
:key="proceso.id"
|
||||||
|
class="secondary-convocatoria-card"
|
||||||
|
>
|
||||||
|
<div class="convocatoria-header">
|
||||||
|
<div>
|
||||||
|
<h4 class="secondary-title">
|
||||||
|
EXAMEN {{ (proceso?.nombre ?? "proceso").toLowerCase() }}
|
||||||
|
</h4>
|
||||||
|
<p class="convocatoria-date">
|
||||||
|
Examen:
|
||||||
|
{{ formatFecha(proceso?.fec_2 || proceso?.fec_1) || (proceso?.fecha_examen ) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-tag class="status-tag" :color="estadoTag(proceso).color">
|
||||||
|
{{ estadoTag(proceso).text }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
|
||||||
|
|
||||||
|
<a-button type="primary" ghost size="small" :href="linkResultados(proceso)" target="_blank">
|
||||||
|
<template #icon><FileSearchOutlined /></template>
|
||||||
|
Ver Resultados
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<FooterModerno />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ====== BASE CONVOCATORIAS ====== */
|
||||||
|
|
||||||
|
.convocatorias-modern {
|
||||||
|
position: relative;
|
||||||
|
padding: 40px 0;
|
||||||
|
font-family: "Times New Roman", Times, serif;
|
||||||
|
background: #fbfcff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.convocatorias-modern::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
to right,
|
||||||
|
rgba(13, 27, 82, 0.06) 0,
|
||||||
|
rgba(13, 27, 82, 0.06) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 24px
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(13, 27, 82, 0.06) 0,
|
||||||
|
rgba(13, 27, 82, 0.06) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 24px
|
||||||
|
);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-with-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b52;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #666;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 14px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-badge :deep(.ant-badge-count) {
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #ffd700);
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card “estado” */
|
||||||
|
.main-convocatoria-card {
|
||||||
|
position: relative;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-convocatoria-card :deep(.ant-card-body) {
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -12px;
|
||||||
|
left: 24px;
|
||||||
|
background: linear-gradient(45deg, #1890ff, #52c41a);
|
||||||
|
color: white;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ CARD POR AÑO */
|
||||||
|
.year-section-card {
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.07);
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-section-card :deep(.ant-card-body) {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(24, 144, 255, 0.12);
|
||||||
|
color: #1890ff;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
color: #1a237e;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: "Times New Roman", Times, serif;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-divider {
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards internas */
|
||||||
|
.secondary-convocatoria-card {
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-convocatoria-card :deep(.ant-card-body) {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.convocatoria-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #1a237e;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: "Times New Roman", Times, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.convocatoria-date {
|
||||||
|
color: #666;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.convocatoria-desc {
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer > :last-child {
|
||||||
|
margin-left: auto; /* 👉 empuja el último a la derecha */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ UNA SOLA COLUMNA SIEMPRE */
|
||||||
|
.secondary-list.one-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.section-title { font-size: 2.1rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.convocatorias-modern { padding: 55px 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
// src/store/noticiasPublicas.store.js
|
||||||
|
import { defineStore } from "pinia"
|
||||||
|
import api from "../axiosPostulante"
|
||||||
|
|
||||||
|
export const useNoticiasPublicasStore = defineStore("noticiasPublicas", {
|
||||||
|
state: () => ({
|
||||||
|
noticias: [],
|
||||||
|
noticiaActual: null,
|
||||||
|
loading: false,
|
||||||
|
loadingOne: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchNoticias() {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
// ✅ usa TU ruta real
|
||||||
|
const res = await api.get("/noticias", {
|
||||||
|
params: { publicado: true, per_page: 9999 },
|
||||||
|
})
|
||||||
|
this.noticias = res.data?.data ?? []
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.response?.data?.message || "Error al cargar noticias"
|
||||||
|
this.noticias = []
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchNoticia(identifier) {
|
||||||
|
this.loadingOne = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/noticias/${identifier}`)
|
||||||
|
this.noticiaActual = res.data?.data ?? null
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.response?.data?.message || "Error al cargar la noticia"
|
||||||
|
this.noticiaActual = null
|
||||||
|
} finally {
|
||||||
|
this.loadingOne = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearNoticiaActual() {
|
||||||
|
this.noticiaActual = null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -0,0 +1,207 @@
|
|||||||
|
// src/store/noticiasStore.js
|
||||||
|
import { defineStore } from "pinia"
|
||||||
|
import api from "../axios" // <-- cambia a tu axios (admin) si tienes otro
|
||||||
|
|
||||||
|
export const useNoticiasStore = defineStore("noticias", {
|
||||||
|
state: () => ({
|
||||||
|
noticias: [],
|
||||||
|
noticia: null,
|
||||||
|
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
deleting: false,
|
||||||
|
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// paginación (si tu back manda meta)
|
||||||
|
meta: {
|
||||||
|
current_page: 1,
|
||||||
|
last_page: 1,
|
||||||
|
per_page: 9,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// filtros
|
||||||
|
filters: {
|
||||||
|
publicado: null, // true/false/null
|
||||||
|
categoria: "",
|
||||||
|
q: "",
|
||||||
|
per_page: 9,
|
||||||
|
page: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
// para el público: solo publicadas (si ya las filtras en backend, esto es opcional)
|
||||||
|
publicadas: (state) => state.noticias.filter((n) => n.publicado),
|
||||||
|
|
||||||
|
// para ordenar en frontend (opcional)
|
||||||
|
ordenadas: (state) =>
|
||||||
|
[...state.noticias].sort((a, b) => {
|
||||||
|
const da = a.fecha_publicacion ? new Date(a.fecha_publicacion).getTime() : 0
|
||||||
|
const db = b.fecha_publicacion ? new Date(b.fecha_publicacion).getTime() : 0
|
||||||
|
return db - da
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
// ========= LISTAR =========
|
||||||
|
async cargarNoticias(extraParams = {}) {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
per_page: this.filters.per_page,
|
||||||
|
page: this.filters.page,
|
||||||
|
...extraParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.filters.publicado !== null && this.filters.publicado !== "") {
|
||||||
|
params.publicado = this.filters.publicado ? 1 : 0
|
||||||
|
}
|
||||||
|
if (this.filters.categoria) params.categoria = this.filters.categoria
|
||||||
|
if (this.filters.q) params.q = this.filters.q
|
||||||
|
|
||||||
|
// Ruta agrupada:
|
||||||
|
// GET /api/administracion/noticias
|
||||||
|
const res = await api.get("/admin/noticias", { params })
|
||||||
|
|
||||||
|
this.noticias = res.data?.data ?? []
|
||||||
|
this.meta = res.data?.meta ?? this.meta
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.response?.data?.message || "Error al cargar noticias"
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========= VER 1 =========
|
||||||
|
async cargarNoticia(id) {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/admin/noticias/${id}`)
|
||||||
|
this.noticia = res.data?.data ?? res.data
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.response?.data?.message || "Error al cargar la noticia"
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========= CREAR =========
|
||||||
|
// payload: { titulo, descripcion_corta, contenido, categoria, tag_color, fecha_publicacion, publicado, destacado, orden, link_url, link_texto, imagen(File) }
|
||||||
|
async crearNoticia(payload) {
|
||||||
|
this.saving = true
|
||||||
|
this.error = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const form = this._toFormData(payload)
|
||||||
|
|
||||||
|
const res = await api.post("/admin/noticias", form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const noticia = res.data?.data ?? null
|
||||||
|
if (noticia) {
|
||||||
|
// opcional: agrega arriba
|
||||||
|
this.noticias = [noticia, ...this.noticias]
|
||||||
|
}
|
||||||
|
return noticia
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.response?.data?.message || "Error al crear noticia"
|
||||||
|
console.error(err)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
this.saving = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========= ACTUALIZAR =========
|
||||||
|
async actualizarNoticia(id, payload) {
|
||||||
|
this.saving = true
|
||||||
|
this.error = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Si hay imagen (File), conviene multipart
|
||||||
|
const hasFile = payload?.imagen instanceof File
|
||||||
|
let res
|
||||||
|
|
||||||
|
if (hasFile) {
|
||||||
|
const form = this._toFormData(payload)
|
||||||
|
|
||||||
|
// Si tu ruta es PUT, axios con multipart PUT funciona.
|
||||||
|
res = await api.put(`/admin/noticias/${id}`, form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// JSON normal
|
||||||
|
res = await api.put(`/administracion/noticias/${id}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = res.data?.data ?? null
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
this.noticias = this.noticias.map((n) => (n.id === id ? updated : n))
|
||||||
|
if (this.noticia?.id === id) this.noticia = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.response?.data?.message || "Error al actualizar noticia"
|
||||||
|
console.error(err)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
this.saving = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========= ELIMINAR =========
|
||||||
|
async eliminarNoticia(id) {
|
||||||
|
this.deleting = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/noticias/${id}`)
|
||||||
|
this.noticias = this.noticias.filter((n) => n.id !== id)
|
||||||
|
if (this.noticia?.id === id) this.noticia = null
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.response?.data?.message || "Error al eliminar noticia"
|
||||||
|
console.error(err)
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
this.deleting = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========= Helpers =========
|
||||||
|
setFiltro(key, value) {
|
||||||
|
this.filters[key] = value
|
||||||
|
},
|
||||||
|
|
||||||
|
resetFiltros() {
|
||||||
|
this.filters = { publicado: null, categoria: "", q: "", per_page: 9, page: 1 }
|
||||||
|
},
|
||||||
|
|
||||||
|
_toFormData(payload = {}) {
|
||||||
|
const form = new FormData()
|
||||||
|
|
||||||
|
Object.entries(payload).forEach(([k, v]) => {
|
||||||
|
if (v === undefined || v === null) return
|
||||||
|
|
||||||
|
// booleans como 1/0 (Laravel feliz)
|
||||||
|
if (typeof v === "boolean") {
|
||||||
|
form.append(k, v ? "1" : "0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form.append(k, v)
|
||||||
|
})
|
||||||
|
|
||||||
|
return form
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
// src/store/web.js
|
||||||
|
import { defineStore } from "pinia"
|
||||||
|
import api from "../axiosPostulante"
|
||||||
|
|
||||||
|
export const useWebAdmisionStore = defineStore("procesoAdmision", {
|
||||||
|
state: () => ({
|
||||||
|
procesos: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
// Si hay uno VIGENTE, úsalo como principal; si no, usa el primero.
|
||||||
|
procesoPrincipal: (state) => {
|
||||||
|
if (!state.procesos?.length) return null
|
||||||
|
return state.procesos.find((p) => p.estado === "publicado") ?? state.procesos[0]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Por si lo necesitas después
|
||||||
|
ultimoProceso: (state) => {
|
||||||
|
return state.procesos?.length ? state.procesos[0] : null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async cargarProcesos() {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get("/procesos-admision")
|
||||||
|
this.procesos = response.data?.data ?? response.data ?? []
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.response?.data?.message || "Error al cargar procesos"
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,630 @@
|
|||||||
|
<!-- src/views/administracion/noticias/NoticiasAdmin.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="areas-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="areas-header">
|
||||||
|
<div class="header-title">
|
||||||
|
<h2>Noticias</h2>
|
||||||
|
<p class="subtitle">Gestión de noticias y comunicados</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-button type="primary" @click="showCreateModal">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
Nueva Noticia
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="filters-section">
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="searchText"
|
||||||
|
placeholder="Buscar por título o descripción..."
|
||||||
|
@search="handleSearch"
|
||||||
|
style="width: 340px"
|
||||||
|
size="large"
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-select
|
||||||
|
v-model:value="publicadoFilter"
|
||||||
|
placeholder="Publicado"
|
||||||
|
style="width: 200px"
|
||||||
|
size="large"
|
||||||
|
@change="handleFilterChange"
|
||||||
|
allowClear
|
||||||
|
>
|
||||||
|
<a-select-option :value="null">Todos</a-select-option>
|
||||||
|
<a-select-option :value="true">Publicado</a-select-option>
|
||||||
|
<a-select-option :value="false">Borrador</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<a-input
|
||||||
|
v-model:value="categoriaFilter"
|
||||||
|
placeholder="Categoría (opcional)"
|
||||||
|
style="width: 220px"
|
||||||
|
size="large"
|
||||||
|
allowClear
|
||||||
|
@pressEnter="handleFilterChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-button size="large" @click="clearFilters">
|
||||||
|
<ReloadOutlined /> Limpiar
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla -->
|
||||||
|
<div class="areas-table-container">
|
||||||
|
<a-table
|
||||||
|
:data-source="noticiasStore.noticias"
|
||||||
|
:columns="columns"
|
||||||
|
:loading="noticiasStore.loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
@change="handleTableChange"
|
||||||
|
class="areas-table"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<!-- Imagen -->
|
||||||
|
<template v-if="column.key === 'imagen'">
|
||||||
|
<div class="thumb">
|
||||||
|
<a-image
|
||||||
|
v-if="record.imagen_url"
|
||||||
|
:src="record.imagen_url"
|
||||||
|
:preview="true"
|
||||||
|
:width="56"
|
||||||
|
:height="40"
|
||||||
|
style="object-fit: cover; border-radius: 8px"
|
||||||
|
/>
|
||||||
|
<div v-else class="thumb-empty">Sin imagen</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Publicado -->
|
||||||
|
<template v-if="column.key === 'publicado'">
|
||||||
|
<a-tag :color="record.publicado ? 'green' : 'default'">
|
||||||
|
{{ record.publicado ? "Publicado" : "Borrador" }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Destacado -->
|
||||||
|
<template v-if="column.key === 'destacado'">
|
||||||
|
<a-tag :color="record.destacado ? 'gold' : 'default'">
|
||||||
|
{{ record.destacado ? "Destacado" : "Normal" }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Fecha -->
|
||||||
|
<template v-if="column.key === 'fecha_publicacion'">
|
||||||
|
{{ formatDate(record.fecha_publicacion) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Acciones -->
|
||||||
|
<template v-if="column.key === 'acciones'">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" class="action-btn" @click="showEditModal(record)">
|
||||||
|
<EditOutlined /> Editar
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button danger type="link" class="action-btn" @click="confirmDelete(record)">
|
||||||
|
<DeleteOutlined /> Eliminar
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Crear / Editar -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="modalVisible"
|
||||||
|
:title="isEditing ? 'Editar Noticia' : 'Nueva Noticia'"
|
||||||
|
:confirm-loading="noticiasStore.saving"
|
||||||
|
@ok="handleSubmit"
|
||||||
|
@cancel="closeModal"
|
||||||
|
width="720px"
|
||||||
|
class="area-modal"
|
||||||
|
>
|
||||||
|
<a-form ref="formRef" :model="formState" layout="vertical">
|
||||||
|
<a-form-item label="Título" required>
|
||||||
|
<a-input v-model:value="formState.titulo" placeholder="Ej: Comunicado oficial..." />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="Descripción corta (para tarjetas)" required>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="formState.descripcion_corta"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="Resumen breve (máx ~500 caracteres)"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :xs="24" :md="12">
|
||||||
|
<a-form-item label="Categoría (opcional)">
|
||||||
|
<a-input v-model:value="formState.categoria" placeholder="Ej: Resultados" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :md="12">
|
||||||
|
<a-form-item label="Color del ribbon/tag (opcional)">
|
||||||
|
<a-input v-model:value="formState.tag_color" placeholder="blue | red | green | gold ..." />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :xs="24" :md="12">
|
||||||
|
<a-form-item label="Fecha de publicación (opcional)">
|
||||||
|
<a-date-picker
|
||||||
|
v-model:value="fechaPicker"
|
||||||
|
style="width: 100%"
|
||||||
|
format="DD/MM/YYYY"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :md="12">
|
||||||
|
<a-form-item label="Orden (opcional)">
|
||||||
|
<a-input-number v-model:value="formState.orden" style="width: 100%" :min="0" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :xs="24" :md="12">
|
||||||
|
<a-form-item label="Publicado">
|
||||||
|
<a-switch v-model:checked="formState.publicado" checked-children="Sí" un-checked-children="No" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :md="12">
|
||||||
|
<a-form-item label="Destacado">
|
||||||
|
<a-switch v-model:checked="formState.destacado" checked-children="Sí" un-checked-children="No" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item label="Contenido (opcional)">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="formState.contenido"
|
||||||
|
:rows="6"
|
||||||
|
placeholder="Contenido completo (si lo usarás en el detalle de la noticia)"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :xs="24" :md="16">
|
||||||
|
<a-form-item label="Link (opcional)">
|
||||||
|
<a-input v-model:value="formState.link_url" placeholder="https://..." />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :xs="24" :md="8">
|
||||||
|
<a-form-item label="Texto del link (opcional)">
|
||||||
|
<a-input v-model:value="formState.link_texto" placeholder="Ver más" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- Imagen -->
|
||||||
|
<a-form-item label="Imagen (opcional)">
|
||||||
|
<a-upload
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
:max-count="1"
|
||||||
|
:show-upload-list="false"
|
||||||
|
>
|
||||||
|
<a-button>
|
||||||
|
<UploadOutlined /> Seleccionar imagen
|
||||||
|
</a-button>
|
||||||
|
</a-upload>
|
||||||
|
|
||||||
|
<div class="image-preview" v-if="imagePreviewUrl || formState.imagen_url">
|
||||||
|
<a-image
|
||||||
|
:src="imagePreviewUrl || formState.imagen_url"
|
||||||
|
:preview="true"
|
||||||
|
style="border-radius: 12px; overflow: hidden"
|
||||||
|
/>
|
||||||
|
<a-button danger type="link" @click="clearImage" class="remove-image">
|
||||||
|
Quitar imagen
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-alert
|
||||||
|
v-if="noticiasStore.error"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:message="noticiasStore.error"
|
||||||
|
/>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- Modal Eliminar -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="deleteModalVisible"
|
||||||
|
title="Eliminar Noticia"
|
||||||
|
ok-type="danger"
|
||||||
|
ok-text="Eliminar"
|
||||||
|
:confirm-loading="noticiasStore.deleting"
|
||||||
|
@ok="handleDelete"
|
||||||
|
@cancel="deleteModalVisible = false"
|
||||||
|
width="520px"
|
||||||
|
>
|
||||||
|
<a-alert type="warning" show-icon message="¿Deseas eliminar esta noticia?" />
|
||||||
|
<div class="delete-info">
|
||||||
|
<p><strong>{{ noticiaToDelete?.titulo }}</strong></p>
|
||||||
|
<p class="muted">Esta acción no se puede deshacer.</p>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, computed, watch } from "vue"
|
||||||
|
import { message } from "ant-design-vue"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
} from "@ant-design/icons-vue"
|
||||||
|
|
||||||
|
import { useNoticiasStore } from "../../../store/noticiasStore"
|
||||||
|
|
||||||
|
const noticiasStore = useNoticiasStore()
|
||||||
|
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const deleteModalVisible = ref(false)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const noticiaToDelete = ref(null)
|
||||||
|
const formRef = ref()
|
||||||
|
|
||||||
|
const searchText = ref("")
|
||||||
|
const publicadoFilter = ref(null)
|
||||||
|
const categoriaFilter = ref("")
|
||||||
|
|
||||||
|
const fechaPicker = ref(null) // dayjs
|
||||||
|
const imagePreviewUrl = ref("")
|
||||||
|
const selectedImageFile = ref(null)
|
||||||
|
|
||||||
|
const formState = reactive({
|
||||||
|
id: null,
|
||||||
|
titulo: "",
|
||||||
|
descripcion_corta: "",
|
||||||
|
contenido: "",
|
||||||
|
categoria: "",
|
||||||
|
tag_color: "",
|
||||||
|
link_url: "",
|
||||||
|
link_texto: "",
|
||||||
|
fecha_publicacion: null, // ISO string (opcional)
|
||||||
|
publicado: false,
|
||||||
|
destacado: false,
|
||||||
|
orden: null,
|
||||||
|
|
||||||
|
// solo para mostrar cuando editas
|
||||||
|
imagen_url: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pagination = computed(() => ({
|
||||||
|
current: noticiasStore.meta?.current_page || 1,
|
||||||
|
pageSize: noticiasStore.meta?.per_page || 9,
|
||||||
|
total: noticiasStore.meta?.total || 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
|
||||||
|
{ title: "Imagen", key: "imagen", width: 90 },
|
||||||
|
{ title: "Título", dataIndex: "titulo", key: "titulo" },
|
||||||
|
{ title: "Categoría", dataIndex: "categoria", key: "categoria", width: 140 },
|
||||||
|
{ title: "Publicado", dataIndex: "publicado", key: "publicado", width: 110 },
|
||||||
|
{ title: "Destacado", dataIndex: "destacado", key: "destacado", width: 120 },
|
||||||
|
{ title: "Fecha", dataIndex: "fecha_publicacion", key: "fecha_publicacion", width: 140 },
|
||||||
|
{ title: "Acciones", key: "acciones", width: 180, align: "center" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const showCreateModal = () => {
|
||||||
|
isEditing.value = false
|
||||||
|
resetForm()
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const showEditModal = (noticia) => {
|
||||||
|
isEditing.value = true
|
||||||
|
resetForm()
|
||||||
|
|
||||||
|
Object.assign(formState, {
|
||||||
|
id: noticia.id,
|
||||||
|
titulo: noticia.titulo || "",
|
||||||
|
descripcion_corta: noticia.descripcion_corta || "",
|
||||||
|
contenido: noticia.contenido || "",
|
||||||
|
categoria: noticia.categoria || "",
|
||||||
|
tag_color: noticia.tag_color || "",
|
||||||
|
link_url: noticia.link_url || "",
|
||||||
|
link_texto: noticia.link_texto || "",
|
||||||
|
fecha_publicacion: noticia.fecha_publicacion || null,
|
||||||
|
publicado: !!noticia.publicado,
|
||||||
|
destacado: !!noticia.destacado,
|
||||||
|
orden: noticia.orden ?? null,
|
||||||
|
imagen_url: noticia.imagen_url || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
fechaPicker.value = noticia.fecha_publicacion ? dayjs(noticia.fecha_publicacion) : null
|
||||||
|
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
modalVisible.value = false
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(formState, {
|
||||||
|
id: null,
|
||||||
|
titulo: "",
|
||||||
|
descripcion_corta: "",
|
||||||
|
contenido: "",
|
||||||
|
categoria: "",
|
||||||
|
tag_color: "",
|
||||||
|
link_url: "",
|
||||||
|
link_texto: "",
|
||||||
|
fecha_publicacion: null,
|
||||||
|
publicado: false,
|
||||||
|
destacado: false,
|
||||||
|
orden: null,
|
||||||
|
imagen_url: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
fechaPicker.value = null
|
||||||
|
imagePreviewUrl.value = ""
|
||||||
|
selectedImageFile.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(fechaPicker, (v) => {
|
||||||
|
formState.fecha_publicacion = v ? v.toISOString() : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const beforeUpload = (file) => {
|
||||||
|
// preview local
|
||||||
|
selectedImageFile.value = file
|
||||||
|
imagePreviewUrl.value = URL.createObjectURL(file)
|
||||||
|
message.success("Imagen seleccionada")
|
||||||
|
// IMPORTANTE: evitar auto-upload (lo hacemos con el submit)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearImage = () => {
|
||||||
|
selectedImageFile.value = null
|
||||||
|
imagePreviewUrl.value = ""
|
||||||
|
// si quieres que al actualizar se borre imagen, envía imagen_path = null
|
||||||
|
// aquí solo la quito del preview; el back no borra a menos que lo programes.
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
if (!formState.titulo?.trim() || !formState.descripcion_corta?.trim()) {
|
||||||
|
message.warning("Completa al menos Título y Descripción corta.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
titulo: formState.titulo,
|
||||||
|
descripcion_corta: formState.descripcion_corta,
|
||||||
|
contenido: formState.contenido || null,
|
||||||
|
categoria: formState.categoria || null,
|
||||||
|
tag_color: formState.tag_color || null,
|
||||||
|
link_url: formState.link_url || null,
|
||||||
|
link_texto: formState.link_texto || null,
|
||||||
|
fecha_publicacion: formState.fecha_publicacion || null,
|
||||||
|
publicado: formState.publicado,
|
||||||
|
destacado: formState.destacado,
|
||||||
|
orden: formState.orden,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedImageFile.value) payload.imagen = selectedImageFile.value
|
||||||
|
|
||||||
|
if (isEditing.value) {
|
||||||
|
await noticiasStore.actualizarNoticia(formState.id, payload)
|
||||||
|
message.success("Noticia actualizada")
|
||||||
|
} else {
|
||||||
|
await noticiasStore.crearNoticia(payload)
|
||||||
|
message.success("Noticia creada")
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal()
|
||||||
|
await fetchTable()
|
||||||
|
} catch (e) {
|
||||||
|
message.error("Error al guardar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = (noticia) => {
|
||||||
|
noticiaToDelete.value = noticia
|
||||||
|
deleteModalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await noticiasStore.eliminarNoticia(noticiaToDelete.value.id)
|
||||||
|
message.success("Noticia eliminada")
|
||||||
|
deleteModalVisible.value = false
|
||||||
|
await fetchTable()
|
||||||
|
} catch {
|
||||||
|
message.error("Error al eliminar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
noticiasStore.setFiltro("q", searchText.value)
|
||||||
|
noticiasStore.setFiltro("page", 1)
|
||||||
|
await fetchTable()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilterChange = async () => {
|
||||||
|
noticiasStore.setFiltro("publicado", publicadoFilter.value)
|
||||||
|
noticiasStore.setFiltro("categoria", categoriaFilter.value)
|
||||||
|
noticiasStore.setFiltro("page", 1)
|
||||||
|
await fetchTable()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilters = async () => {
|
||||||
|
searchText.value = ""
|
||||||
|
publicadoFilter.value = null
|
||||||
|
categoriaFilter.value = ""
|
||||||
|
noticiasStore.resetFiltros()
|
||||||
|
await fetchTable()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTableChange = async (pg) => {
|
||||||
|
noticiasStore.setFiltro("page", pg.current)
|
||||||
|
noticiasStore.setFiltro("per_page", pg.pageSize)
|
||||||
|
await fetchTable()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTable = async () => {
|
||||||
|
await noticiasStore.cargarNoticias()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date) => {
|
||||||
|
if (!date) return "-"
|
||||||
|
const d = new Date(date)
|
||||||
|
if (isNaN(d.getTime())) return "-"
|
||||||
|
return d.toLocaleDateString("es-PE")
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchTable()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.areas-container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.areas-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.areas-table-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.areas-table :deep(.ant-table-thead > tr > th) {
|
||||||
|
background: #fafafa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f1f1f;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.areas-table :deep(.ant-table-tbody > tr > td) {
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.areas-table :deep(.ant-table-tbody > tr:hover > td) {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.thumb-empty {
|
||||||
|
width: 56px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px dashed #d9d9d9;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
margin-top: 10px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.remove-image {
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-info {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #777;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.areas-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-section {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-section .ant-input-search,
|
||||||
|
.filters-section .ant-select,
|
||||||
|
.filters-section .ant-input,
|
||||||
|
.filters-section .ant-btn {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.areas-table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.areas-table {
|
||||||
|
min-width: 980px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue