feat: Implementar conexión de convocatorias vigentes con API de admisión y mejoras en el modal de detalles

main
parent 8f35717dc9
commit 2bdfb94859

@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\Comunicado;
use App\Models\ComunicadoImagen;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Carbon\Carbon;
class ComunicadoController extends Controller
{
// Admin: lista paginada
public function index(Request $request)
{
$comunicados = Comunicado::with('imagenes')
->orderByDesc('created_at')
->paginate(10);
return response()->json($comunicados);
}
// Admin: crear comunicado
public function store(Request $request)
{
$request->validate([
'titulo' => 'required|string|max:255',
'fecha_inicio' => 'nullable|date',
'fecha_fin' => 'nullable|date|after_or_equal:fecha_inicio',
'url_accion' => 'nullable|url|max:500',
'texto_boton' => 'nullable|string|max:60',
'imagenes' => 'required|array|min:1',
'imagenes.*' => 'required|file|mimes:jpg,jpeg,png,webp|max:5120',
]);
$comunicado = Comunicado::create([
'titulo' => $request->titulo,
'activo' => false,
'fecha_inicio' => $request->fecha_inicio,
'fecha_fin' => $request->fecha_fin,
'url_accion' => $request->url_accion,
'texto_boton' => $request->texto_boton,
]);
foreach ($request->file('imagenes') as $orden => $imagen) {
$filename = uniqid() . '.' . $imagen->getClientOriginalExtension();
$path = "comunicados/{$comunicado->id}/{$filename}";
Storage::disk('public')->put($path, file_get_contents($imagen->getRealPath()));
ComunicadoImagen::create([
'comunicado_id' => $comunicado->id,
'imagen_path' => $path,
'orden' => $orden + 1,
]);
}
return response()->json($comunicado->load('imagenes'), 201);
}
// Admin: actualizar comunicado
public function update(Request $request, int $id)
{
$comunicado = Comunicado::findOrFail($id);
$request->validate([
'titulo' => 'sometimes|required|string|max:255',
'fecha_inicio' => 'nullable|date',
'fecha_fin' => 'nullable|date|after_or_equal:fecha_inicio',
'url_accion' => 'nullable|url|max:500',
'texto_boton' => 'nullable|string|max:60',
'imagenes' => 'sometimes|array|min:1',
'imagenes.*' => 'file|mimes:jpg,jpeg,png,webp|max:5120',
]);
$comunicado->update($request->only('titulo', 'fecha_inicio', 'fecha_fin', 'url_accion', 'texto_boton'));
// Si se enviaron nuevas imágenes, AGREGAR a las existentes (no reemplazar)
if ($request->hasFile('imagenes')) {
$maxOrden = $comunicado->imagenes()->max('orden') ?? 0;
foreach ($request->file('imagenes') as $idx => $imagen) {
$filename = uniqid() . '.' . $imagen->getClientOriginalExtension();
$path = "comunicados/{$comunicado->id}/{$filename}";
Storage::disk('public')->put($path, file_get_contents($imagen->getRealPath()));
ComunicadoImagen::create([
'comunicado_id' => $comunicado->id,
'imagen_path' => $path,
'orden' => $maxOrden + $idx + 1,
]);
}
}
return response()->json($comunicado->load('imagenes'));
}
// Admin: eliminar comunicado
public function destroy(int $id)
{
$comunicado = Comunicado::with('imagenes')->findOrFail($id);
foreach ($comunicado->imagenes as $img) {
Storage::disk('public')->delete($img->imagen_path);
}
$comunicado->delete();
return response()->json(['message' => 'Comunicado eliminado correctamente.']);
}
// Admin: activar/desactivar (uno activo a la vez)
public function toggleActivo(int $id)
{
$comunicado = Comunicado::findOrFail($id);
if ($comunicado->activo) {
// Si ya estaba activo, simplemente lo desactiva
$comunicado->update(['activo' => false]);
} else {
// Desactiva todos los demás y activa este
Comunicado::where('activo', true)->update(['activo' => false]);
$comunicado->update(['activo' => true]);
}
return response()->json($comunicado->load('imagenes'));
}
// Admin: eliminar una imagen individual
public function destroyImagen(int $imagenId)
{
$imagen = ComunicadoImagen::findOrFail($imagenId);
Storage::disk('public')->delete($imagen->imagen_path);
$imagen->delete();
return response()->json(['message' => 'Imagen eliminada correctamente.']);
}
// Público: devuelve el comunicado activo con sus imágenes (respeta vigencia)
public function activo()
{
$hoy = Carbon::today();
$comunicado = Comunicado::with('imagenes')
->where('activo', true)
->where(function ($q) use ($hoy) {
$q->whereNull('fecha_inicio')->orWhere('fecha_inicio', '<=', $hoy);
})
->where(function ($q) use ($hoy) {
$q->whereNull('fecha_fin')->orWhere('fecha_fin', '>=', $hoy);
})
->first();
return response()->json($comunicado);
}
}

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Comunicado extends Model
{
protected $fillable = [
'titulo',
'activo',
'fecha_inicio',
'fecha_fin',
'url_accion',
'texto_boton',
];
protected $casts = [
'activo' => 'boolean',
'fecha_inicio' => 'date',
'fecha_fin' => 'date',
];
public function imagenes(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(ComunicadoImagen::class)->orderBy('orden');
}
}

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class ComunicadoImagen extends Model
{
protected $table = 'comunicado_imagenes';
protected $fillable = [
'comunicado_id',
'imagen_path',
'orden',
];
protected $casts = [
'orden' => 'integer',
];
protected $appends = ['imagen_url'];
public function getImagenUrlAttribute(): ?string
{
return $this->imagen_path
? Storage::disk('public')->url($this->imagen_path)
: null;
}
public function comunicado(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Comunicado::class);
}
}

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('comunicados', function (Blueprint $table) {
$table->id();
$table->string('titulo');
$table->boolean('activo')->default(false);
$table->date('fecha_inicio')->nullable();
$table->date('fecha_fin')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('comunicados');
}
};

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('comunicado_imagenes', function (Blueprint $table) {
$table->id();
$table->foreignId('comunicado_id')->constrained('comunicados')->cascadeOnDelete();
$table->string('imagen_path');
$table->integer('orden')->default(1);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('comunicado_imagenes');
}
};

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('comunicados', function (Blueprint $table) {
$table->string('url_accion')->nullable()->after('fecha_fin');
$table->string('texto_boton')->nullable()->after('url_accion');
});
}
public function down(): void
{
Schema::table('comunicados', function (Blueprint $table) {
$table->dropColumn(['url_accion', 'texto_boton']);
});
}
};

@ -19,6 +19,7 @@ use App\Http\Controllers\Administracion\PostulanteController;
use App\Http\Controllers\Administracion\CalificacionController;
use App\Http\Controllers\Administracion\NoticiaController;
use App\Http\Controllers\Administracion\ProcesoAdmisionResultadoArchivoController;
use App\Http\Controllers\Administracion\ComunicadoController;
use App\Http\Controllers\WebController;
Route::get('/user', function (Request $request) {
@ -212,4 +213,17 @@ Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
Route::middleware('auth:sanctum')->get(
'/mis-procesos/{idProceso}/avance',
[PostulanteAuthController::class, 'obtenerAvanceProcesoPostulante']
);
);
// Público: comunicado activo
Route::get('/comunicados/activo', [ComunicadoController::class, 'activo']);
// Admin: CRUD comunicados
Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
Route::get('/comunicados', [ComunicadoController::class, 'index']);
Route::post('/comunicados', [ComunicadoController::class, 'store']);
Route::match(['post', 'put', 'patch'], '/comunicados/{id}', [ComunicadoController::class, 'update']);
Route::delete('/comunicados/{id}', [ComunicadoController::class, 'destroy']);
Route::patch('/comunicados/{id}/toggle-activo', [ComunicadoController::class, 'toggleActivo']);
Route::delete('/comunicados/imagenes/{imagenId}', [ComunicadoController::class, 'destroyImagen']);
});

@ -24,6 +24,8 @@
<FooterModerno />
<ComunicadoModal />
</template>
<script setup>
@ -42,6 +44,7 @@ import StatsSection from './WebPageSections/StatsSection.vue'
import NoticiasSection from './WebPageSections/NoticiasSection.vue'
import ModalidadesSection from './WebPageSections/ModalidadesSection.vue'
import ContactSection from './WebPageSections/ContactSection.vue'
import ComunicadoModal from './WebPageSections/ComunicadoModal.vue'

@ -0,0 +1,368 @@
<template>
<!-- v-if garantiza que si no hay comunicado activo el modal ni se monta -->
<a-modal
v-if="imagenes.length > 0"
v-model:open="visible"
:footer="null"
:closable="false"
:mask-closable="true"
:keyboard="true"
:destroy-on-close="true"
:width="'auto'"
centered
wrap-class-name="comunicado-modal-wrap"
transition-name="comunicado-slide"
@cancel="cerrar"
>
<div class="comunicado-modal">
<!-- Botón cerrar -->
<button class="btn-cerrar" @click="cerrar" aria-label="Cerrar">
<CloseOutlined />
</button>
<!-- Una sola imagen -->
<template v-if="imagenes.length === 1">
<img
:src="imagenes[0].imagen_url"
:alt="comunicado.titulo"
class="comunicado-img"
/>
</template>
<!-- Carrusel si hay más de una imagen -->
<template v-else-if="imagenes.length > 1">
<div class="custom-carousel">
<transition name="fade">
<div :key="currentIndex" class="img-wrapper">
<img
:src="imagenes[currentIndex].imagen_url"
:alt="comunicado.titulo"
class="comunicado-img"
/>
<button class="arrow arrow-prev" @click="prev" aria-label="Anterior">
<LeftOutlined />
</button>
<button class="arrow arrow-next" @click="next" aria-label="Siguiente">
<RightOutlined />
</button>
</div>
</transition>
<div class="dots">
<span
v-for="(_, i) in imagenes"
:key="i"
:class="['dot', { active: i === currentIndex }]"
@click="currentIndex = i"
/>
</div>
</div>
</template>
<!-- Barra inferior: siempre visible -->
<div class="accion-bar">
<a
v-if="comunicado?.url_accion"
:href="comunicado.url_accion"
target="_blank"
rel="noopener noreferrer"
class="btn-accion"
>
{{ comunicado.texto_boton || 'Ver más' }}
</a>
<button class="btn-cerrar-bar" @click="cerrar">Cerrar</button>
</div>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { CloseOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons-vue'
import { useComunicadosStore } from '../../store/comunicadosStore'
const store = useComunicadosStore()
const visible = ref(false)
const currentIndex = ref(0)
const comunicado = computed(() => store.comunicadoActivo)
const imagenes = computed(() => store.comunicadoActivo?.imagenes ?? [])
function prev() {
currentIndex.value = (currentIndex.value - 1 + imagenes.value.length) % imagenes.value.length
}
function next() {
currentIndex.value = (currentIndex.value + 1) % imagenes.value.length
}
onMounted(async () => {
await store.fetchActivo()
if (store.comunicadoActivo && imagenes.value.length > 0) {
visible.value = true
}
})
// Si el comunicado activo desaparece (ej. desactivado desde admin), cerrar el modal
watch(
() => store.comunicadoActivo,
(val) => {
if (!val) visible.value = false
}
)
function cerrar() {
visible.value = false
}
</script>
<style>
/* A. Animación de apertura: slide-up + fade */
.comunicado-slide-enter-active {
animation: comunicadoSlideUp 0.38s cubic-bezier(0.34, 1.1, 0.64, 1);
}
.comunicado-slide-leave-active {
animation: comunicadoSlideDown 0.22s ease forwards;
}
@keyframes comunicadoSlideUp {
from { transform: translateY(40px) scale(0.97); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes comunicadoSlideDown {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(24px); opacity: 0; }
}
.comunicado-modal-wrap .ant-modal {
width: auto !important;
max-width: 94vw;
}
.comunicado-modal-wrap .ant-modal-content {
padding: 0;
border-radius: 12px;
overflow: hidden;
background: transparent;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
}
.comunicado-modal-wrap .ant-modal-body {
padding: 0;
}
</style>
<style scoped>
.comunicado-modal {
position: relative;
line-height: 0;
border-radius: 12px;
overflow: hidden;
}
.comunicado-img {
display: block;
width: auto;
max-width: 100%;
max-height: 75vh;
margin: 0 auto;
}
.btn-cerrar {
position: absolute;
top: 10px;
right: 10px;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
border: none;
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: background 0.2s;
}
.btn-cerrar:hover {
background: rgba(0, 0, 0, 0.8);
}
/* Carrusel propio */
.custom-carousel {
position: relative;
line-height: 0;
}
/* Wrapper que envuelve imagen + flechas; se ajusta al ancho natural de la imagen */
.img-wrapper {
position: relative;
width: fit-content;
max-width: 100%;
margin: 0 auto;
line-height: 0;
}
/* B. Transición entre imágenes: crossfade suave */
.fade-enter-active {
transition: opacity 0.7s ease-in-out;
position: relative;
z-index: 2;
}
.fade-leave-active {
position: absolute;
top: 0;
left: 0;
width: 100%;
transition: opacity 0.7s ease-in-out;
z-index: 1;
}
.fade-enter-from { opacity: 0; }
.fade-enter-to { opacity: 1; }
.fade-leave-from { opacity: 1; }
.fade-leave-to { opacity: 0; }
/* Flechas */
.arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
background: rgba(0, 0, 0, 0.45);
border: none;
color: white;
width: 34px;
height: 34px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: background 0.2s;
}
.arrow:hover {
background: rgba(0, 0, 0, 0.75);
}
.arrow-prev { left: 10px; }
.arrow-next { right: 10px; }
/* Dots */
.dots {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
z-index: 10;
}
/* C. Dots tipo stories */
.dot {
width: 8px;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: width 0.3s ease, background 0.2s, border-color 0.2s;
}
.dot.active {
width: 24px;
background: rgba(255, 255, 255, 0.55);
border-color: rgba(255, 255, 255, 0.7);
}
/* Barra inferior */
.accion-bar {
line-height: normal;
background: #fff;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.btn-accion {
display: inline-block;
background: #1890ff;
color: white;
padding: 8px 28px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
text-decoration: none;
transition: background 0.2s;
white-space: nowrap;
}
.btn-accion:hover {
background: #096dd9;
color: white;
}
.btn-cerrar-bar {
background: transparent;
border: 1px solid #d9d9d9;
color: #555;
padding: 8px 24px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-cerrar-bar:hover {
border-color: #1890ff;
color: #1890ff;
}
/* ── Responsive mobile ── */
@media (max-width: 576px) {
/* Imagen más corta en mobile para dejar espacio a la barra inferior */
.comunicado-img {
max-height: 58vh;
}
/* Touch targets más grandes */
.btn-cerrar {
width: 40px;
height: 40px;
font-size: 16px;
top: 8px;
right: 8px;
}
.arrow {
width: 40px;
height: 40px;
font-size: 16px;
}
/* Barra inferior: stack vertical si hay dos botones */
.accion-bar {
flex-direction: column;
gap: 8px;
padding: 12px;
}
.btn-accion,
.btn-cerrar-bar {
width: 100%;
text-align: center;
padding: 10px 16px;
font-size: 15px;
}
}
</style>

@ -39,6 +39,8 @@
<p class="convocatoria-date">
Examen:
{{ formatFecha(store.procesoPrincipal.fecha_examen1) }}
-
{{ formatFecha(store.procesoPrincipal.fecha_examen2) }}
</p>
</div>

@ -209,6 +209,11 @@ const routes = [
path: '/admin/dashboard/noticias',
name: 'NoticiasAdmisionList',
component: () => import('../views/administrador/procesoadmision/NoticiasAdmin.vue')
},
{
path: '/admin/dashboard/comunicados',
name: 'ComunicadosAdmin',
component: () => import('../views/administrador/comunicados/ComunicadosAdmin.vue')
}
]

@ -0,0 +1,128 @@
import { defineStore } from 'pinia'
import api from '../axios'
import apiPublico from '../axiosPostulante'
export const useComunicadosStore = defineStore('comunicados', {
state: () => ({
comunicados: [],
comunicadoActivo: null,
pagination: { current_page: 1, per_page: 10, total: 0 },
loading: false,
error: null,
}),
actions: {
_setError(err) {
this.error = err?.response?.data?.message || 'Ocurrió un error'
},
// Admin: lista paginada
async fetchComunicados(params = {}) {
this.loading = true
this.error = null
try {
const { data } = await api.get('/admin/comunicados', { params })
this.comunicados = data.data ?? []
this.pagination = {
current_page: data.current_page,
per_page: data.per_page,
total: data.total,
}
return true
} catch (err) {
this._setError(err)
return false
} finally {
this.loading = false
}
},
// Admin: crear
async createComunicado(formData) {
this.error = null
try {
const { data } = await api.post('/admin/comunicados', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
this.comunicados.unshift(data)
return true
} catch (err) {
this._setError(err)
return false
}
},
// Admin: actualizar
async updateComunicado(id, formData) {
this.error = null
try {
const { data } = await api.post(`/admin/comunicados/${id}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const idx = this.comunicados.findIndex((c) => c.id === id)
if (idx !== -1) this.comunicados[idx] = data
return true
} catch (err) {
this._setError(err)
return false
}
},
// Admin: eliminar
async deleteComunicado(id) {
this.error = null
try {
await api.delete(`/admin/comunicados/${id}`)
this.comunicados = this.comunicados.filter((c) => c.id !== id)
return true
} catch (err) {
this._setError(err)
return false
}
},
// Admin: toggle activo
async toggleActivo(id) {
this.error = null
try {
const { data } = await api.patch(`/admin/comunicados/${id}/toggle-activo`)
// Actualiza el estado local: desactiva todos y activa el correspondiente
this.comunicados = this.comunicados.map((c) => ({
...c,
activo: c.id === id ? data.activo : false,
}))
return true
} catch (err) {
this._setError(err)
return false
}
},
// Admin: eliminar imagen individual
async deleteImagen(comunicadoId, imagenId) {
this.error = null
try {
await api.delete(`/admin/comunicados/imagenes/${imagenId}`)
const comunicado = this.comunicados.find((c) => c.id === comunicadoId)
if (comunicado) {
comunicado.imagenes = comunicado.imagenes.filter((img) => img.id !== imagenId)
}
return true
} catch (err) {
this._setError(err)
return false
}
},
// Público: comunicado activo
async fetchActivo() {
this.error = null
try {
const { data } = await apiPublico.get('/comunicados/activo')
this.comunicadoActivo = data
} catch (err) {
this.comunicadoActivo = null
}
},
},
})

@ -0,0 +1,493 @@
<template>
<div class="areas-container">
<!-- Header -->
<div class="areas-header">
<div class="header-title">
<h2>Comunicados</h2>
<p class="subtitle">Gestiona los comunicados que aparecen en la web pública</p>
</div>
<a-button type="primary" @click="abrirModalCrear">
<template #icon><PlusOutlined /></template>
Nuevo comunicado
</a-button>
</div>
<!-- Tabla -->
<div class="areas-table-container">
<a-table
:columns="columnas"
:data-source="store.comunicados"
:loading="store.loading"
:pagination="{
current: store.pagination.current_page,
pageSize: store.pagination.per_page,
total: store.pagination.total,
showTotal: (t) => `${t} comunicados`,
}"
row-key="id"
class="areas-table"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<!-- Preview imágenes -->
<template v-if="column.key === 'imagenes'">
<div class="thumb-group">
<a-image
v-for="img in record.imagenes"
:key="img.id"
:src="img.imagen_url"
:width="48"
:height="40"
style="object-fit: cover; border-radius: 6px; border: 1px solid #f0f0f0;"
/>
<div v-if="!record.imagenes?.length" class="thumb-empty">Sin imagen</div>
</div>
</template>
<!-- Estado activo -->
<template v-else-if="column.key === 'activo'">
<a-switch
:checked="record.activo"
:loading="togglingId === record.id"
checked-children="Activo"
un-checked-children="Inactivo"
@change="onToggleActivo(record)"
/>
</template>
<!-- Vigencia -->
<template v-else-if="column.key === 'vigencia'">
<span v-if="record.fecha_inicio || record.fecha_fin" style="font-size: 12px; color: #666;">
{{ record.fecha_inicio ?? '∞' }} {{ record.fecha_fin ?? '∞' }}
</span>
<a-tag v-else color="default">Sin vigencia</a-tag>
</template>
<!-- Botón acción -->
<template v-else-if="column.key === 'url_accion'">
<a
v-if="record.url_accion"
:href="record.url_accion"
target="_blank"
style="font-size: 12px;"
>
{{ record.texto_boton || 'Ver más' }}
</a>
<span v-else style="color: #bbb; font-size: 12px;"></span>
</template>
<!-- Acciones -->
<template v-else-if="column.key === 'acciones'">
<a-space>
<a-button type="link" class="action-btn" @click="abrirModalEditar(record)">
<EditOutlined /> Editar
</a-button>
<a-button type="link" danger class="action-btn" @click="confirmarEliminar(record)">
<DeleteOutlined /> Eliminar
</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- Modal crear / editar -->
<a-modal
v-model:open="modalVisible"
:title="modoEdicion ? 'Editar comunicado' : 'Nuevo comunicado'"
:confirm-loading="guardando"
ok-text="Guardar"
cancel-text="Cancelar"
width="560px"
class="area-modal"
@ok="onGuardar"
@cancel="cerrarModal"
>
<a-form :model="form" layout="vertical" ref="formRef">
<a-form-item
label="Título (referencia interna)"
name="titulo"
:rules="[{ required: true, message: 'El título es requerido' }]"
>
<a-input v-model:value="form.titulo" placeholder="Ej: Comunicado cronograma Feb 2026" />
</a-form-item>
<a-form-item label="Vigencia (opcional)">
<a-space>
<a-date-picker
v-model:value="form.fecha_inicio"
placeholder="Desde"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
<span style="color: #999;"></span>
<a-date-picker
v-model:value="form.fecha_fin"
placeholder="Hasta"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
</a-space>
<div style="font-size: 12px; color: #999; margin-top: 4px;">
Si no se define, el comunicado no tiene fecha límite.
</div>
</a-form-item>
<a-form-item label="Botón de acción (opcional)">
<a-space direction="vertical" style="width: 100%;">
<a-input
v-model:value="form.url_accion"
placeholder="https://ejemplo.com/inscripcion"
addon-before="URL"
/>
<a-input
v-model:value="form.texto_boton"
placeholder="Ej: Inscríbete aquí, Ver cronograma..."
addon-before="Texto"
:maxlength="60"
show-count
/>
</a-space>
<div style="font-size: 12px; color: #999; margin-top: 4px;">
Si defines una URL, aparecerá un botón en el modal de la web.
</div>
</a-form-item>
<!-- Imágenes actuales (solo en edición) -->
<a-form-item v-if="modoEdicion && imagenesActuales.length > 0" label="Imágenes actuales">
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<div
v-for="img in imagenesActuales"
:key="img.id"
style="position: relative; display: inline-block;"
>
<a-image
:src="img.imagen_url"
:width="72"
:height="72"
style="object-fit: cover; border-radius: 4px; border: 1px solid #f0f0f0;"
/>
<a-popconfirm
title="¿Eliminar esta imagen?"
ok-text="Sí"
cancel-text="No"
@confirm="onEliminarImagen(img)"
>
<button class="btn-remove-img"></button>
</a-popconfirm>
</div>
</div>
<div style="font-size: 12px; color: #999; margin-top: 4px;">
Haz clic en para eliminar una imagen existente.
</div>
</a-form-item>
<!-- Agregar imágenes -->
<a-form-item
:label="modoEdicion ? 'Agregar más imágenes' : 'Imágenes del comunicado'"
name="imagenes"
:rules="modoEdicion ? [] : [{ validator: validarImagenes }]"
>
<a-upload
v-model:file-list="fileImagenes"
list-type="picture-card"
:before-upload="() => false"
accept="image/jpeg,image/png,image/webp"
multiple
>
<div v-if="fileImagenes.length < 8">
<PlusOutlined />
<div style="margin-top: 8px; font-size: 12px;">Agregar</div>
</div>
</a-upload>
<div v-if="modoEdicion" style="font-size: 12px; color: #999; margin-top: 4px;">
Las imágenes que subas se agregarán a las existentes.
</div>
</a-form-item>
<a-alert v-if="store.error" type="error" show-icon :message="store.error" />
</a-form>
</a-modal>
<!-- Modal eliminar -->
<a-modal
v-model:open="deleteModalVisible"
title="Eliminar comunicado"
ok-type="danger"
ok-text="Eliminar"
cancel-text="Cancelar"
:confirm-loading="eliminando"
width="480px"
@ok="onEliminar"
@cancel="deleteModalVisible = false"
>
<a-alert type="warning" show-icon message="¿Deseas eliminar este comunicado?" />
<div class="delete-info">
<p><strong>{{ comunicadoAEliminar?.titulo }}</strong></p>
<p class="muted">Esta acción eliminará también todas sus imágenes y no se puede deshacer.</p>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import { useComunicadosStore } from '../../../store/comunicadosStore'
const store = useComunicadosStore()
const columnas = [
{ title: 'Título', dataIndex: 'titulo', key: 'titulo' },
{ title: 'Imágenes', key: 'imagenes', width: 160 },
{ title: 'Activo', key: 'activo', width: 140 },
{ title: 'Vigencia', key: 'vigencia', width: 200 },
{ title: 'Botón web', key: 'url_accion', width: 130 },
{ title: 'Acciones', key: 'acciones', width: 160, align: 'center' },
]
// Estado modales
const modalVisible = ref(false)
const deleteModalVisible = ref(false)
const modoEdicion = ref(false)
const comunicadoEditando = ref(null)
const comunicadoAEliminar = ref(null)
const guardando = ref(false)
const eliminando = ref(false)
const togglingId = ref(null)
const formRef = ref(null)
// Form
const form = ref({ titulo: '', fecha_inicio: null, fecha_fin: null, url_accion: '', texto_boton: '' })
const fileImagenes = ref([])
const imagenesActuales = computed(() => comunicadoEditando.value?.imagenes ?? [])
onMounted(() => {
store.fetchComunicados()
})
function onTableChange(pagination) {
store.fetchComunicados({ page: pagination.current })
}
function abrirModalCrear() {
modoEdicion.value = false
comunicadoEditando.value = null
form.value = { titulo: '', fecha_inicio: null, fecha_fin: null, url_accion: '', texto_boton: '' }
fileImagenes.value = []
modalVisible.value = true
}
function abrirModalEditar(record) {
modoEdicion.value = true
comunicadoEditando.value = record
form.value = {
titulo: record.titulo,
fecha_inicio: record.fecha_inicio ?? null,
fecha_fin: record.fecha_fin ?? null,
url_accion: record.url_accion ?? '',
texto_boton: record.texto_boton ?? '',
}
fileImagenes.value = []
modalVisible.value = true
}
function cerrarModal() {
modalVisible.value = false
formRef.value?.resetFields()
}
async function validarImagenes() {
if (!modoEdicion.value && fileImagenes.value.length === 0) {
return Promise.reject('Agrega al menos una imagen')
}
return Promise.resolve()
}
async function onGuardar() {
try {
await formRef.value.validate()
} catch {
return
}
const fd = new FormData()
fd.append('titulo', form.value.titulo)
if (form.value.fecha_inicio) fd.append('fecha_inicio', form.value.fecha_inicio)
if (form.value.fecha_fin) fd.append('fecha_fin', form.value.fecha_fin)
if (form.value.url_accion) fd.append('url_accion', form.value.url_accion)
if (form.value.texto_boton) fd.append('texto_boton', form.value.texto_boton)
fileImagenes.value.forEach((f) => fd.append('imagenes[]', f.originFileObj))
guardando.value = true
const ok = modoEdicion.value
? await store.updateComunicado(comunicadoEditando.value.id, fd)
: await store.createComunicado(fd)
guardando.value = false
if (ok) {
message.success(modoEdicion.value ? 'Comunicado actualizado' : 'Comunicado creado')
cerrarModal()
store.fetchComunicados()
} else {
message.error(store.error ?? 'Error al guardar')
}
}
function confirmarEliminar(record) {
comunicadoAEliminar.value = record
deleteModalVisible.value = true
}
async function onEliminar() {
eliminando.value = true
const ok = await store.deleteComunicado(comunicadoAEliminar.value.id)
eliminando.value = false
if (ok) {
message.success('Comunicado eliminado')
deleteModalVisible.value = false
} else {
message.error(store.error ?? 'Error al eliminar')
}
}
async function onEliminarImagen(img) {
const ok = await store.deleteImagen(comunicadoEditando.value.id, img.id)
if (ok) {
message.success('Imagen eliminada')
} else {
message.error(store.error ?? 'Error al eliminar imagen')
}
}
async function onToggleActivo(record) {
togglingId.value = record.id
const ok = await store.toggleActivo(record.id)
togglingId.value = null
if (!ok) message.error(store.error ?? 'Error al cambiar estado')
}
</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;
}
.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-group {
display: flex;
gap: 4px;
flex-wrap: wrap;
align-items: center;
}
.thumb-empty {
width: 48px;
height: 40px;
border-radius: 6px;
border: 1px dashed #d9d9d9;
display: grid;
place-items: center;
font-size: 11px;
color: #999;
}
.btn-remove-img {
position: absolute;
top: -6px;
right: -6px;
background: #ff4d4f;
border: none;
color: white;
border-radius: 50%;
width: 18px;
height: 18px;
cursor: pointer;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.delete-info {
margin-top: 12px;
}
.muted {
color: #777;
margin: 0;
}
@media (max-width: 768px) {
.areas-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.areas-table-container {
overflow-x: auto;
}
.areas-table {
min-width: 900px;
}
}
</style>

@ -220,11 +220,18 @@
</a-menu-item>
<a-menu-item key="noticias-lista" class="menu-item">
<div class="menu-item-content">
<AppstoreOutlined class="menu-icon" />
<ReadOutlined class="menu-icon" />
<span class="menu-label">Noticias</span>
</div>
</a-menu-item>
<a-menu-item key="comunicados-lista" class="menu-item">
<div class="menu-item-content">
<NotificationOutlined class="menu-icon" />
<span class="menu-label">Comunicados</span>
</div>
</a-menu-item>
</a-sub-menu>
<!-- Análisis -->
@ -335,7 +342,10 @@ import {
SafetyOutlined,
ExportOutlined,
HomeOutlined,
NotificationOutlined,
ReadOutlined,
} from '@ant-design/icons-vue'
import NoticiasAdmin from '../procesoadmision/NoticiasAdmin.vue'
const router = useRouter()
const route = useRoute()
@ -432,6 +442,7 @@ const handleMenuSelect = ({ key }) => {
'procesos-lista': { name: 'ProcesosAdmisionList' },
'noticias-lista': { name: 'NoticiasAdmisionList' },
'comunicados-lista': { name: 'ComunicadosAdmin' },
'resultados': { name: 'AcademiaResultados' },
'reportes': { name: 'AcademiaReportes' },
'config-academia': { name: 'AcademiaConfig' },
@ -481,11 +492,17 @@ const updatePageInfo = (key) => {
title: 'cursos',
subtitle: 'Lista de Cursos'
},
'examenes-reglas-lista': {
section: 'Exámenes',
subSection: 'Reglas',
title: 'Reglas',
subtitle: 'Lista de Reglas'
'examenes-reglas-lista': {
section: 'Exámenes',
subSection: 'Reglas',
title: 'Reglas',
subtitle: 'Lista de Reglas'
},
'comunicados-lista': {
section: 'WebConf',
subSection: 'Comunicados',
title: 'Comunicados',
subtitle: 'Gestión de comunicados de la web pública'
}
}

@ -66,7 +66,8 @@ Verificar tablas importadas:
docker exec admision_2026_db mysql -uroot -proot admision_2026 -e "SHOW TABLES;"
```
Debe mostrar 34 tablas (users, postulantes, areas, cursos, examenes, procesos_admision, etc.)
Debe mostrar 34 tablas base (users, postulantes, areas, cursos, examenes, procesos_admision, etc.).
Las tablas `comunicados` y `comunicado_imagenes` se crean en el paso 4.5 via migraciones.
> **Nota:** El dump incluye la estructura de todas las tablas **y** los registros en `migrations`.
> Aun así, siempre que hagas `git pull` debes correr `php artisan migrate` — el dump cubre las tablas

Loading…
Cancel
Save