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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue